Codey speaks the Agent Client Protocol on stdin and stdout, so a control plane that drives Codex or Claude Code can drive this instead. T3 Code compiles its providers in and has no Codey entry, but every provider takes a binary path and its Grok provider is a plain ACP agent — so 'codey agent stdio' occupies that slot with no change to their source. internal/acp is the wire: JSON-RPC 2.0, one object per line, every inbound message dispatched on its own goroutine because a permission request answered on the read loop is a deadlock with a peer doing the same. internal/serve is the bridge, and it adds no agent behaviour — Observe becomes session/update, Approve becomes session/request_permission, and a prompt becomes a turn. One process serves many sessions, each with its own history, its own root and its own record of what was allowed. Text is coalesced before it goes out: the client's inbound queue is 32 deep and drops the oldest, so a notification per token would lose the middle of a sentence. Buffered text is flushed before any tool runs, which is the case a timer would have been for. Two invocations exist only for the provider card — 'codey --version' and 'codey models' — shaped for the regular expressions that read them. The conformance test replays T3 Code's literal handshake, so a nightly of theirs that breaks us says where. Event grew an ID so a tool_start and its result pair by the model's own tool_use id rather than by arrival order, and approval.Describe is exported because a tool row and an approval box want the same sentence.
Showing 18 changed files with 3680 additions and 21 deletions.
| @@ -999,6 +999,67 @@ set is for. The query and the URL are the detail on the prompt, so what is | ||
| 999 | 999 | leaving is on screen before it leaves. "Always this tool" is the answer most |
| 1000 | 1000 | people give once. |
| 1001 | 1001 | |
| 1002 | + ## Inside another editor | |
| 1003 | + | |
| 1004 | + Codey can be the agent behind somebody else's UI. It speaks the | |
| 1005 | + [Agent Client Protocol](https://agentclientprotocol.com) — JSON-RPC over stdin | |
| 1006 | + and stdout — so a control plane that drives Codex, Claude Code or Cursor can | |
| 1007 | + drive this instead: | |
| 1008 | + | |
| 1009 | + ```bash | |
| 1010 | + codey agent stdio | |
| 1011 | + ``` | |
| 1012 | + | |
| 1013 | + Nothing but protocol frames goes to stdout in that mode; diagnostics go to | |
| 1014 | + stderr. The connection ends when stdin does. | |
| 1015 | + | |
| 1016 | + ### T3 Code | |
| 1017 | + | |
| 1018 | + [T3 Code](https://t3.codes) compiles its providers in, so there is no Codey | |
| 1019 | + entry to select. There is, however, a binary path on every provider, and its | |
| 1020 | + Grok provider is a plain ACP agent — which means Codey can take that slot with | |
| 1021 | + no change to their source. In **Settings → Providers**, enable Grok, set | |
| 1022 | + **Binary path** to your `codey`, and rename the instance: | |
| 1023 | + | |
| 1024 | + ```json | |
| 1025 | + "providerInstances": { | |
| 1026 | + "codey": { | |
| 1027 | + "driver": "grok", | |
| 1028 | + "displayName": "Codey", | |
| 1029 | + "enabled": true, | |
| 1030 | + "config": { "binaryPath": "/path/to/codey" }, | |
| 1031 | + "environment": [ | |
| 1032 | + { "name": "ANTHROPIC_API_KEY", "value": "sk-ant-...", "sensitive": true } | |
| 1033 | + ] | |
| 1034 | + } | |
| 1035 | + } | |
| 1036 | + ``` | |
| 1037 | + | |
| 1038 | + That file is `~/.t3/userdata/settings.json`; the settings panel writes the same | |
| 1039 | + thing. The per-instance environment is how Codey gets a credential without | |
| 1040 | + inheriting whatever the desktop app was launched with — and without it, the | |
| 1041 | + provider card will say Codey is installed and not logged in, which is true. | |
| 1042 | + | |
| 1043 | + Threads stream text, show tool calls as they run, raise an approval prompt you | |
| 1044 | + answer in T3 Code's own panel, and stop when you press stop. The model picker | |
| 1045 | + is filled from the endpoint Codey is pointed at. What does not work: resuming a | |
| 1046 | + thread after a restart (Codey does not advertise session loading, so T3 Code | |
| 1047 | + will not offer it), and the reasoning view, because the turn loop does not | |
| 1048 | + report thinking to a front end yet. | |
| 1049 | + | |
| 1050 | + The card will say Grok and show the xAI glyph. The display name and accent | |
| 1051 | + colour are yours to set; the icon is not. That is the price of occupying a slot | |
| 1052 | + instead of adding one. | |
| 1053 | + | |
| 1054 | + Two invocations exist only for that provider card: `codey --version`, and | |
| 1055 | + `codey models`, which prints whether there is a credential and what the | |
| 1056 | + endpoint serves. Both are shaped for the regular expressions on the other end | |
| 1057 | + rather than for a person. | |
| 1058 | + | |
| 1059 | + `docs/superpowers/specs/2026-09-19-t3code-provider-design.md` is the research | |
| 1060 | + this is built on: what every T3 Code provider requires of a binary, and why the | |
| 1061 | + Grok slot is the one that fits. | |
| 1062 | + | |
| 1002 | 1063 | ## Evals |
| 1003 | 1064 | |
| 1004 | 1065 | ```bash |
| @@ -1071,12 +1132,14 @@ internal/ask questions the agent puts to you mid-task, and the tool for it | ||
| 1071 | 1132 | internal/attach pictures pasted into a prompt: clipboard, files, markers |
| 1072 | 1133 | internal/metrics JSONL event log |
| 1073 | 1134 | internal/ui line-based terminal rendering |
| 1135 | + internal/acp Agent Client Protocol: the wire, and its vocabulary | |
| 1136 | + internal/serve Codey as an agent another program drives, over ACP | |
| 1074 | 1137 | internal/tui full-screen Bubble Tea front end, and the setup wizard |
| 1075 | 1138 | evals task suite and runner |
| 1076 | 1139 | ``` |
| 1077 | 1140 | |
| 1078 | 1141 | Two rules hold the shape: `agent` never imports `ui` or `tui`, so the eval |
| 1079 | - runner can drive the loop headless; and every tool returns `(string, error)` | |
| 1142 | + runner and the ACP front end can drive the loop headless; and every tool returns `(string, error)` | |
| 1080 | 1143 | where the error is text the model reads, not an exception Codey swallows. |
| 1081 | 1144 | A failed edit is a normal event in the transcript. |
| 1082 | 1145 | |
| @@ -0,0 +1,261 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + // The command line another program drives Codey through. | |
| 4 | + // | |
| 5 | + // Three invocations here are not for a person. A control plane that runs | |
| 6 | + // coding agents — T3 Code is the one this was built against — spawns the | |
| 7 | + // binary three ways: once to ask its version, once to ask what models it has | |
| 8 | + // and whether it is logged in, and once to run the agent itself over a pipe. | |
| 9 | + // Only the third does any work; the first two exist so the provider card can | |
| 10 | + // say "installed" and "authenticated" before anyone starts a thread. | |
| 11 | + // | |
| 12 | + // The shapes are not ours to choose. They are what the client already sends to | |
| 13 | + // the binary whose slot Codey is occupying, which is why the flags arrive | |
| 14 | + // around the subcommand rather than before it and why the two probes print | |
| 15 | + // prose a regular expression reads. | |
| 16 | + | |
| 17 | + import ( | |
| 18 | + "context" | |
| 19 | + "fmt" | |
| 20 | + "io" | |
| 21 | + "os" | |
| 22 | + "sort" | |
| 23 | + "strings" | |
| 24 | + "time" | |
| 25 | + | |
| 26 | + "codey/internal/acp" | |
| 27 | + "codey/internal/agent" | |
| 28 | + "codey/internal/llm" | |
| 29 | + "codey/internal/metrics" | |
| 30 | + "codey/internal/serve" | |
| 31 | + "codey/internal/tools" | |
| 32 | + ) | |
| 33 | + | |
| 34 | + // invKind is which of the four things this command line is. | |
| 35 | + type invKind int | |
| 36 | + | |
| 37 | + const ( | |
| 38 | + invNormal invKind = iota | |
| 39 | + invVersion | |
| 40 | + invModels | |
| 41 | + invAgent | |
| 42 | + ) | |
| 43 | + | |
| 44 | + // invocation is the command line, read. | |
| 45 | + type invocation struct { | |
| 46 | + Kind invKind | |
| 47 | + // Permission is the approval floor an agent run was launched with. | |
| 48 | + Permission serve.PermissionMode | |
| 49 | + // Rest is what is left for ordinary flag parsing once the words that | |
| 50 | + // belong to the subcommand are taken out. | |
| 51 | + Rest []string | |
| 52 | + } | |
| 53 | + | |
| 54 | + // scanArgs reads the command line before the flag package sees it. | |
| 55 | + // | |
| 56 | + // It has to, for two reasons. `--version` is not one of our flags, and flag | |
| 57 | + // parsing would exit(2) on it rather than answer. And the agent invocation | |
| 58 | + // splits its flags around the subcommand — `--permission-mode default agent | |
| 59 | + // stdio`, but `agent --always-approve stdio` — which no flag package parses, | |
| 60 | + // because it is not one grammar but a client's habit. | |
| 61 | + // | |
| 62 | + // Everything unrecognised is handed on rather than rejected. The launch | |
| 63 | + // arguments are a free-text field in somebody's settings panel, and the flags | |
| 64 | + // Codey does understand should keep working when they arrive through it. | |
| 65 | + func scanArgs(args []string) invocation { | |
| 66 | + // The probes are exact: the client runs the binary with this one word and | |
| 67 | + // nothing else. Requiring that keeps `-C models` from being mistaken for | |
| 68 | + // a request to list them. | |
| 69 | + if len(args) == 1 { | |
| 70 | + switch args[0] { | |
| 71 | + case "--version", "-version": | |
| 72 | + return invocation{Kind: invVersion} | |
| 73 | + case "models": | |
| 74 | + return invocation{Kind: invModels} | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + var ( | |
| 79 | + sawAgent, sawStdio, always bool | |
| 80 | + mode string | |
| 81 | + rest []string | |
| 82 | + ) | |
| 83 | + for i := 0; i < len(args); i++ { | |
| 84 | + a := args[i] | |
| 85 | + switch { | |
| 86 | + case a == "agent": | |
| 87 | + sawAgent = true | |
| 88 | + case a == "stdio": | |
| 89 | + sawStdio = true | |
| 90 | + case a == "--always-approve", a == "-always-approve": | |
| 91 | + always = true | |
| 92 | + case a == "--permission-mode", a == "-permission-mode": | |
| 93 | + if i+1 < len(args) { | |
| 94 | + mode = args[i+1] | |
| 95 | + i++ | |
| 96 | + } | |
| 97 | + case strings.HasPrefix(a, "--permission-mode="): | |
| 98 | + mode = strings.TrimPrefix(a, "--permission-mode=") | |
| 99 | + case strings.HasPrefix(a, "-permission-mode="): | |
| 100 | + mode = strings.TrimPrefix(a, "-permission-mode=") | |
| 101 | + default: | |
| 102 | + rest = append(rest, a) | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + if !sawAgent || !sawStdio { | |
| 107 | + // Not an agent run after all, so nothing was ours to consume: hand the | |
| 108 | + // line back exactly as it came. | |
| 109 | + return invocation{Kind: invNormal, Rest: args} | |
| 110 | + } | |
| 111 | + return invocation{Kind: invAgent, Permission: permissionMode(mode, always), Rest: rest} | |
| 112 | + } | |
| 113 | + | |
| 114 | + // permissionMode maps the client's vocabulary onto ours. An unrecognised mode | |
| 115 | + // is the strictest one: a name we do not know is not a reason to stop asking. | |
| 116 | + func permissionMode(mode string, always bool) serve.PermissionMode { | |
| 117 | + if always { | |
| 118 | + return serve.ModeFullAccess | |
| 119 | + } | |
| 120 | + switch mode { | |
| 121 | + case "acceptEdits": | |
| 122 | + return serve.ModeAcceptEdits | |
| 123 | + case "auto": | |
| 124 | + return serve.ModeAuto | |
| 125 | + case "bypassPermissions", "full-access": | |
| 126 | + return serve.ModeFullAccess | |
| 127 | + default: | |
| 128 | + return serve.ModeAsk | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + // printVersion answers the version probe. | |
| 133 | + // | |
| 134 | + // The line has to contain something matching \bv?(\d+\.\d+\.\d+)\b, because | |
| 135 | + // that is the regular expression on the other end. Anything else reads as an | |
| 136 | + // installed binary that cannot run. | |
| 137 | + func printVersion(w io.Writer) { | |
| 138 | + fmt.Fprintf(w, "codey %s\n", version) | |
| 139 | + } | |
| 140 | + | |
| 141 | + // modelsTimeout bounds the listing. The probe that runs this has its own, | |
| 142 | + // shorter patience, and a listing that has not arrived by then is better | |
| 143 | + // reported as the one model we know about than not reported at all. | |
| 144 | + const modelsTimeout = 8 * time.Second | |
| 145 | + | |
| 146 | + // printModels answers the model-and-login probe. | |
| 147 | + // | |
| 148 | + // The format is prose because the reader is a regular expression: a line | |
| 149 | + // saying whether we are logged in, then one bullet per model with the default | |
| 150 | + // marked. It exits zero even when it has nothing good to say — a non-zero exit | |
| 151 | + // makes the client discard the whole listing, including the part that was | |
| 152 | + // true. | |
| 153 | + func printModels(w io.Writer, client *llm.Client) { | |
| 154 | + if !client.HasCredentials() { | |
| 155 | + fmt.Fprintln(w, "You are not logged in. Set ANTHROPIC_API_KEY, or log in with Claude Code.") | |
| 156 | + return | |
| 157 | + } | |
| 158 | + fmt.Fprintf(w, "You are logged in to %s.\n", client.BaseURL) | |
| 159 | + | |
| 160 | + ctx, cancel := context.WithTimeout(context.Background(), modelsTimeout) | |
| 161 | + defer cancel() | |
| 162 | + | |
| 163 | + models, err := client.ListModels(ctx) | |
| 164 | + if err != nil || len(models) == 0 { | |
| 165 | + // An endpoint that does not answer still serves the model we were | |
| 166 | + // configured with, and offering that beats offering nothing. | |
| 167 | + fmt.Fprintf(w, "* %s (default)\n", client.Model()) | |
| 168 | + return | |
| 169 | + } | |
| 170 | + | |
| 171 | + for _, m := range sortedModels(models) { | |
| 172 | + if m.ID == client.Model() { | |
| 173 | + fmt.Fprintf(w, "* %s (default)\n", m.ID) | |
| 174 | + continue | |
| 175 | + } | |
| 176 | + fmt.Fprintf(w, "- %s\n", m.ID) | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + // sortedModels puts the newest first, which is the order a picker should show | |
| 181 | + // and not the order every endpoint answers in. | |
| 182 | + func sortedModels(models []llm.Model) []llm.Model { | |
| 183 | + out := append([]llm.Model(nil), models...) | |
| 184 | + sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) | |
| 185 | + return out | |
| 186 | + } | |
| 187 | + | |
| 188 | + // acpDeps is what an agent run borrows from the ordinary startup path. Nothing | |
| 189 | + // here is built for this mode: it is the same client, the same event log, the | |
| 190 | + // same options and the same tool factory the TUI gets, which is the point — | |
| 191 | + // an agent driven through a pipe should be able to do exactly what an agent | |
| 192 | + // driven through a keyboard can. | |
| 193 | + type acpDeps struct { | |
| 194 | + Permission serve.PermissionMode | |
| 195 | + Client *llm.Client | |
| 196 | + // ClientOpts rebuilds the client for another model. The model lives on the | |
| 197 | + // client rather than on the agent, so a session that selects one needs a | |
| 198 | + // client of its own. | |
| 199 | + ClientOpts []llm.Option | |
| 200 | + Log *metrics.Logger | |
| 201 | + Base agent.Options | |
| 202 | + // Tools is main's factory: a root in, a tool-set builder out. | |
| 203 | + Tools func(root string) func(*agent.State, *metrics.Logger) *tools.Registry | |
| 204 | + } | |
| 205 | + | |
| 206 | + // serveACP runs the agent over stdin and stdout until the client hangs up. | |
| 207 | + // | |
| 208 | + // Nothing else in this process may write to stdout from here on. The stream is | |
| 209 | + // the protocol, and one stray line desynchronises a parser that has no way to | |
| 210 | + // resynchronise. | |
| 211 | + func serveACP(ctx context.Context, deps acpDeps) error { | |
| 212 | + models, current := offeredModels(ctx, deps.Client) | |
| 213 | + | |
| 214 | + srv := serve.New(serve.Options{ | |
| 215 | + Version: version, | |
| 216 | + Permission: deps.Permission, | |
| 217 | + Models: models, | |
| 218 | + DefaultModel: current, | |
| 219 | + | |
| 220 | + NewAgent: func(root, model string, state *agent.State) (*agent.Agent, error) { | |
| 221 | + client := deps.Client | |
| 222 | + if model != "" && model != client.Model() { | |
| 223 | + client = llm.New(append(deps.ClientOpts, llm.WithModel(model))...) | |
| 224 | + } | |
| 225 | + reg := deps.Tools(root)(state, deps.Log) | |
| 226 | + return agent.New(client, reg, state, deps.Log, deps.Base), nil | |
| 227 | + }, | |
| 228 | + }) | |
| 229 | + | |
| 230 | + deps.Log.Log(metrics.Event{Type: "acp_start", Extra: map[string]any{ | |
| 231 | + "permission": string(deps.Permission), "model": deps.Client.Model(), | |
| 232 | + }}) | |
| 233 | + | |
| 234 | + return srv.Run(ctx, os.Stdin, os.Stdout) | |
| 235 | + } | |
| 236 | + | |
| 237 | + // offeredModels is the picker a session is given. An endpoint that will not | |
| 238 | + // list them leaves the picker empty, and the client then shows whatever it | |
| 239 | + // learned from the `models` probe instead. | |
| 240 | + func offeredModels(ctx context.Context, client *llm.Client) ([]acp.ModelInfo, string) { | |
| 241 | + listCtx, cancel := context.WithTimeout(ctx, modelsTimeout) | |
| 242 | + defer cancel() | |
| 243 | + | |
| 244 | + found, err := client.ListModels(listCtx) | |
| 245 | + if err != nil || len(found) == 0 { | |
| 246 | + return nil, client.Model() | |
| 247 | + } | |
| 248 | + | |
| 249 | + out := make([]acp.ModelInfo, 0, len(found)) | |
| 250 | + for _, m := range sortedModels(found) { | |
| 251 | + out = append(out, acp.ModelInfo{ModelID: m.ID, Name: displayName(m)}) | |
| 252 | + } | |
| 253 | + return out, client.Model() | |
| 254 | + } | |
| 255 | + | |
| 256 | + func displayName(m llm.Model) string { | |
| 257 | + if m.DisplayName != "" { | |
| 258 | + return m.DisplayName | |
| 259 | + } | |
| 260 | + return m.ID | |
| 261 | + } | |
| @@ -0,0 +1,185 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bytes" | |
| 5 | + "net/http" | |
| 6 | + "net/http/httptest" | |
| 7 | + "regexp" | |
| 8 | + "slices" | |
| 9 | + "strings" | |
| 10 | + "testing" | |
| 11 | + | |
| 12 | + "codey/internal/llm" | |
| 13 | + "codey/internal/serve" | |
| 14 | + ) | |
| 15 | + | |
| 16 | + // t3VersionPattern is the regular expression T3 Code parses `--version` with. | |
| 17 | + // It is copied here rather than described, because a version line that does | |
| 18 | + // not match it reads as a binary that is installed and broken. | |
| 19 | + var t3VersionPattern = regexp.MustCompile(`\bv?(\d+\.\d+\.\d+)\b`) | |
| 20 | + | |
| 21 | + func TestVersionMatchesWhatTheClientLooksFor(t *testing.T) { | |
| 22 | + var out bytes.Buffer | |
| 23 | + printVersion(&out) | |
| 24 | + | |
| 25 | + m := t3VersionPattern.FindStringSubmatch(out.String()) | |
| 26 | + if m == nil { | |
| 27 | + t.Fatalf("no version in %q", out.String()) | |
| 28 | + } | |
| 29 | + if !strings.Contains(out.String(), "codey") { | |
| 30 | + t.Errorf("the line should say whose version it is: %q", out.String()) | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + func TestScanArgsReadsEveryShapeTheClientSends(t *testing.T) { | |
| 35 | + // The five launches T3 Code's Grok driver produces, one per runtime mode. | |
| 36 | + cases := []struct { | |
| 37 | + name string | |
| 38 | + args []string | |
| 39 | + want serve.PermissionMode | |
| 40 | + }{ | |
| 41 | + {"default", []string{"agent", "stdio"}, serve.ModeAsk}, | |
| 42 | + {"approval required", []string{"--permission-mode", "default", "agent", "stdio"}, serve.ModeAsk}, | |
| 43 | + {"accept edits", []string{"--permission-mode", "acceptEdits", "agent", "stdio"}, serve.ModeAcceptEdits}, | |
| 44 | + {"auto", []string{"--permission-mode", "auto", "agent", "stdio"}, serve.ModeAuto}, | |
| 45 | + {"full access", []string{"agent", "--always-approve", "stdio"}, serve.ModeFullAccess}, | |
| 46 | + } | |
| 47 | + | |
| 48 | + for _, tc := range cases { | |
| 49 | + t.Run(tc.name, func(t *testing.T) { | |
| 50 | + inv := scanArgs(tc.args) | |
| 51 | + if inv.Kind != invAgent { | |
| 52 | + t.Fatalf("kind = %v, want an agent run", inv.Kind) | |
| 53 | + } | |
| 54 | + if inv.Permission != tc.want { | |
| 55 | + t.Errorf("permission = %q, want %q", inv.Permission, tc.want) | |
| 56 | + } | |
| 57 | + if len(inv.Rest) != 0 { | |
| 58 | + t.Errorf("leftover args %v — flag parsing would choke on them", inv.Rest) | |
| 59 | + } | |
| 60 | + }) | |
| 61 | + } | |
| 62 | + } | |
| 63 | + | |
| 64 | + func TestScanArgsKeepsOurOwnFlagsFromTheLaunchArguments(t *testing.T) { | |
| 65 | + // The launch arguments are a free-text box in a settings panel: whatever | |
| 66 | + // the user typed arrives mixed in with the subcommand. | |
| 67 | + inv := scanArgs([]string{"--permission-mode", "auto", "agent", "stdio", "-effort", "high", "-no-web"}) | |
| 68 | + | |
| 69 | + if inv.Kind != invAgent || inv.Permission != serve.ModeAuto { | |
| 70 | + t.Fatalf("invocation = %+v", inv) | |
| 71 | + } | |
| 72 | + want := []string{"-effort", "high", "-no-web"} | |
| 73 | + if !slices.Equal(inv.Rest, want) { | |
| 74 | + t.Errorf("rest = %v, want %v", inv.Rest, want) | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + func TestScanArgsTakesThePermissionModeJoinedByAnEquals(t *testing.T) { | |
| 79 | + if got := scanArgs([]string{"--permission-mode=acceptEdits", "agent", "stdio"}); got.Permission != serve.ModeAcceptEdits { | |
| 80 | + t.Errorf("permission = %q", got.Permission) | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + func TestAnUnknownPermissionModeIsTheStrictOne(t *testing.T) { | |
| 85 | + // A mode we have never heard of is not a reason to stop asking. | |
| 86 | + if got := scanArgs([]string{"--permission-mode", "yolo-supreme", "agent", "stdio"}); got.Permission != serve.ModeAsk { | |
| 87 | + t.Errorf("permission = %q, want %q", got.Permission, serve.ModeAsk) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + func TestTheProbesAreRecognisedOnlyOnTheirOwn(t *testing.T) { | |
| 92 | + if got := scanArgs([]string{"--version"}).Kind; got != invVersion { | |
| 93 | + t.Errorf("--version kind = %v", got) | |
| 94 | + } | |
| 95 | + if got := scanArgs([]string{"models"}).Kind; got != invModels { | |
| 96 | + t.Errorf("models kind = %v", got) | |
| 97 | + } | |
| 98 | + // A directory that happens to be called models is a directory. | |
| 99 | + inv := scanArgs([]string{"-C", "models"}) | |
| 100 | + if inv.Kind != invNormal { | |
| 101 | + t.Errorf("kind = %v, want a normal run", inv.Kind) | |
| 102 | + } | |
| 103 | + if !slices.Equal(inv.Rest, []string{"-C", "models"}) { | |
| 104 | + t.Errorf("a normal run must get its line back untouched: %v", inv.Rest) | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + func TestHalfAnAgentInvocationIsNotOne(t *testing.T) { | |
| 109 | + // "agent" without "stdio" is somebody's file name, not a subcommand. | |
| 110 | + for _, args := range [][]string{{"agent"}, {"stdio"}, {"-p", "explain the agent"}} { | |
| 111 | + if got := scanArgs(args).Kind; got != invNormal { | |
| 112 | + t.Errorf("scanArgs(%v) = %v, want a normal run", args, got) | |
| 113 | + } | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + // testClient has a credential and an endpoint that refuses to list anything, | |
| 118 | + // which is the interesting case: the probe still has to answer. | |
| 119 | + func testClient(t *testing.T) *llm.Client { | |
| 120 | + t.Helper() | |
| 121 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 122 | + w.WriteHeader(http.StatusInternalServerError) | |
| 123 | + })) | |
| 124 | + t.Cleanup(srv.Close) | |
| 125 | + return llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k"), llm.WithModel("codey-test-model")) | |
| 126 | + } | |
| 127 | + | |
| 128 | + func noCredentialClient(t *testing.T) *llm.Client { | |
| 129 | + t.Helper() | |
| 130 | + c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL)) | |
| 131 | + c.ClearCredentialsForTest() | |
| 132 | + return c | |
| 133 | + } | |
| 134 | + | |
| 135 | + // t3ModelBullet is how T3 Code reads a line of the model listing, and | |
| 136 | + // t3LoggedIn is how it reads the login state off the same output. | |
| 137 | + var ( | |
| 138 | + t3ModelBullet = regexp.MustCompile(`^\s*[*-]\s+(\S+)(.*)$`) | |
| 139 | + t3LoggedIn = regexp.MustCompile(`(?i)you are logged in`) | |
| 140 | + t3LoggedOut = regexp.MustCompile(`(?i)not authenticated|not logged in`) | |
| 141 | + ) | |
| 142 | + | |
| 143 | + // parseLikeT3 is a transliteration of the client's parser, so the test fails | |
| 144 | + // for the same reason the provider card would. | |
| 145 | + func parseLikeT3(out string) (loggedIn bool, models []string, defaultModel string) { | |
| 146 | + loggedIn = t3LoggedIn.MatchString(out) && !t3LoggedOut.MatchString(out) | |
| 147 | + for _, line := range strings.Split(out, "\n") { | |
| 148 | + m := t3ModelBullet.FindStringSubmatch(line) | |
| 149 | + if m == nil { | |
| 150 | + continue | |
| 151 | + } | |
| 152 | + models = append(models, m[1]) | |
| 153 | + if strings.Contains(strings.ToLower(m[2]), "(default)") { | |
| 154 | + defaultModel = m[1] | |
| 155 | + } | |
| 156 | + } | |
| 157 | + return | |
| 158 | + } | |
| 159 | + | |
| 160 | + func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) { | |
| 161 | + // No endpoint is reachable here, so this exercises the fallback: the one | |
| 162 | + // model we were configured with, and a login state that is still true. | |
| 163 | + var out bytes.Buffer | |
| 164 | + printModels(&out, testClient(t)) | |
| 165 | + | |
| 166 | + loggedIn, models, def := parseLikeT3(out.String()) | |
| 167 | + if !loggedIn { | |
| 168 | + t.Errorf("the client would read this as logged out:\n%s", out.String()) | |
| 169 | + } | |
| 170 | + if len(models) != 1 || def != models[0] { | |
| 171 | + t.Errorf("models = %v, default = %q, from:\n%s", models, def, out.String()) | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + func TestLoggedOutSaysSoRatherThanFailing(t *testing.T) { | |
| 176 | + var out bytes.Buffer | |
| 177 | + printModels(&out, noCredentialClient(t)) | |
| 178 | + | |
| 179 | + if loggedIn, _, _ := parseLikeT3(out.String()); loggedIn { | |
| 180 | + t.Errorf("a client with no credential reported itself logged in:\n%s", out.String()) | |
| 181 | + } | |
| 182 | + if !t3LoggedOut.MatchString(out.String()) { | |
| 183 | + t.Errorf("the client would read this as unknown rather than logged out:\n%s", out.String()) | |
| 184 | + } | |
| 185 | + } | |
| @@ -21,6 +21,7 @@ import ( | ||
| 21 | 21 | "codey/internal/config" |
| 22 | 22 | "codey/internal/llm" |
| 23 | 23 | "codey/internal/metrics" |
| 24 | + "codey/internal/serve" | |
| 24 | 25 | "codey/internal/skills" |
| 25 | 26 | "codey/internal/statedir" |
| 26 | 27 | "codey/internal/subagent" |
| @@ -85,7 +86,18 @@ func run() error { | ||
| 85 | 86 | noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+" and ./"+skills.Dirname) |
| 86 | 87 | noAgents := flag.Bool("no-agents", false, "do not offer the subagent tools, so the agent works alone") |
| 87 | 88 | noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename) |
| 88 | - flag.Parse() | |
| 89 | + | |
| 90 | + // Read the line before the flag package does. Three invocations belong to | |
| 91 | + // a program driving Codey rather than to a person — see agentcmd.go — and | |
| 92 | + // two of them are not spelled in a grammar flag parsing can read. | |
| 93 | + inv := scanArgs(os.Args[1:]) | |
| 94 | + if inv.Kind == invVersion { | |
| 95 | + printVersion(os.Stdout) | |
| 96 | + return nil | |
| 97 | + } | |
| 98 | + if err := flag.CommandLine.Parse(inv.Rest); err != nil { | |
| 99 | + return err | |
| 100 | + } | |
| 89 | 101 | |
| 90 | 102 | root, err := filepath.Abs(*workdir) |
| 91 | 103 | if err != nil { |
| @@ -114,6 +126,10 @@ func run() error { | ||
| 114 | 126 | llm.WithAuthToken(*authToken), |
| 115 | 127 | } |
| 116 | 128 | client := llm.New(opts...) |
| 129 | + // clientOpts is the list the live client was actually built from, kept so | |
| 130 | + // a second client on another model can be built the same way. The Claude | |
| 131 | + // Code branch below is the only thing that adds to it. | |
| 132 | + clientOpts := opts | |
| 117 | 133 | |
| 118 | 134 | home, err := os.UserHomeDir() |
| 119 | 135 | if err != nil { |
| @@ -136,7 +152,8 @@ func run() error { | ||
| 136 | 152 | } |
| 137 | 153 | if src != nil { |
| 138 | 154 | ccSource = src |
| 139 | - client = llm.New(append(opts, llm.WithTokenSource(src.Token))...) | |
| 155 | + clientOpts = append(opts, llm.WithTokenSource(src.Token)) | |
| 156 | + client = llm.New(clientOpts...) | |
| 140 | 157 | // Not expected to ever trigger: resolveClaudeCode already checked |
| 141 | 158 | // the endpoint before we agreed to borrow this credential, but |
| 142 | 159 | // that check ran against the client we had before this rebuild. |
| @@ -150,6 +167,15 @@ func run() error { | ||
| 150 | 167 | systemPrefix = agent.ClaudeCodeIdentity |
| 151 | 168 | } |
| 152 | 169 | } |
| 170 | + // The login-and-models probe answers here, where a borrowed Claude Code | |
| 171 | + // credential already counts as being logged in, and before the check | |
| 172 | + // below: "not logged in" is the useful answer to that question, not an | |
| 173 | + // error exit that tells the asker nothing. | |
| 174 | + if inv.Kind == invModels { | |
| 175 | + printModels(os.Stdout, client) | |
| 176 | + return nil | |
| 177 | + } | |
| 178 | + | |
| 153 | 179 | if !client.HasCredentials() { |
| 154 | 180 | return errNoCredentials |
| 155 | 181 | } |
| @@ -343,6 +369,28 @@ func run() error { | ||
| 343 | 369 | SystemPrefix: systemPrefix, |
| 344 | 370 | } |
| 345 | 371 | |
| 372 | + // An agent run hands the whole thing over to the protocol and never comes | |
| 373 | + // back. It goes here, after everything a session needs has been built and | |
| 374 | + // before the front ends that assume a person: no subagent supervisor, | |
| 375 | + // because their reports need somewhere to be shown, and no question tool, | |
| 376 | + // because the protocol has no box to put a question in. | |
| 377 | + // | |
| 378 | + // Nothing above this line has written to stdout. Nothing below it may. | |
| 379 | + if inv.Kind == invAgent { | |
| 380 | + if acceptAll || inv.Permission == serve.ModeFullAccess { | |
| 381 | + inv.Permission = serve.ModeFullAccess | |
| 382 | + notice("no approval prompts. Use a throwaway clone or a container.") | |
| 383 | + } | |
| 384 | + return serveACP(context.Background(), acpDeps{ | |
| 385 | + Permission: inv.Permission, | |
| 386 | + Client: client, | |
| 387 | + ClientOpts: clientOpts, | |
| 388 | + Log: log, | |
| 389 | + Base: base, | |
| 390 | + Tools: toolsIn, | |
| 391 | + }) | |
| 392 | + } | |
| 393 | + | |
| 346 | 394 | // Subagents are a TUI feature: their reports come back as messages the |
| 347 | 395 | // front end puts into the conversation, and the line-based REPL has |
| 348 | 396 | // nowhere to show four agents working at once. Offering the tools where |
| @@ -1,5 +1,33 @@ | ||
| 1 | 1 | # Codey inside T3 Code — implementation plan |
| 2 | 2 | |
| 3 | + > **Status: phases 1 and 2 implemented**, with six departures from what is | |
| 4 | + > written below. | |
| 5 | + > | |
| 6 | + > Approvals moved from phase 2 into phase 1. A nil `Approve` means the agent | |
| 7 | + > may do anything, so "text only, approvals later" was not a smaller first | |
| 8 | + > step — it was shipping the gate open. `Event.ID` moved forward for the same | |
| 9 | + > kind of reason: the translator needed tool call ids from its first line, so | |
| 10 | + > pairing by arrival order was never written. | |
| 11 | + > | |
| 12 | + > `approval.Describe` was exported rather than duplicated. The tool-call title | |
| 13 | + > a client shows and the summary an approval box shows are the same sentence, | |
| 14 | + > and two vocabularies for one call would be two things to keep in step. | |
| 15 | + > | |
| 16 | + > The coalescer has no timer. Text goes out on size, on elapsed time checked at | |
| 17 | + > the next delta, and on any event that is not text — which covers the case the | |
| 18 | + > timer was for, a sentence written just before a long command, and leaves the | |
| 19 | + > tests free of sleeping. | |
| 20 | + > | |
| 21 | + > Two things were added that the plan did not ask for. `initialize._meta.modelState` | |
| 22 | + > carries the model list, because T3 Code's health check shakes hands and stops | |
| 23 | + > without opening a session, and a picker only offered at `session/new` never | |
| 24 | + > reaches the provider card. And images work: `promptCapabilities.image` is | |
| 25 | + > advertised and image blocks are converted, which was phase 3 in the plan and | |
| 26 | + > turned out to be ten lines. | |
| 27 | + > | |
| 28 | + > Phase 3 remains open. See the `## Inside another editor` section of README.md | |
| 29 | + > for what shipped. | |
| 30 | + | |
| 3 | 31 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | 32 | |
| 5 | 33 | **Goal:** Run Codey as an agent inside T3 Code without touching T3 Code's |
| @@ -0,0 +1,348 @@ | ||
| 1 | + package acp | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bufio" | |
| 5 | + "bytes" | |
| 6 | + "context" | |
| 7 | + "encoding/json" | |
| 8 | + "errors" | |
| 9 | + "io" | |
| 10 | + "strconv" | |
| 11 | + "sync" | |
| 12 | + "sync/atomic" | |
| 13 | + ) | |
| 14 | + | |
| 15 | + // Handler is the meaning behind the methods: one implementation per agent. | |
| 16 | + // | |
| 17 | + // Every method may be called concurrently, and Prompt in particular is | |
| 18 | + // expected to run for minutes while Cancel arrives on another goroutine. A | |
| 19 | + // handler that serializes its own state is a handler that works; this package | |
| 20 | + // deliberately does no locking on its behalf, because only the handler knows | |
| 21 | + // what a session is. | |
| 22 | + type Handler interface { | |
| 23 | + Initialize(context.Context, InitializeRequest) (InitializeResponse, error) | |
| 24 | + // Authenticate must not fail for an unknown method id. Clients send one | |
| 25 | + // unconditionally and do not catch the error, so a refusal here is a | |
| 26 | + // session that never starts rather than a login prompt. | |
| 27 | + Authenticate(context.Context, AuthenticateRequest) error | |
| 28 | + NewSession(context.Context, NewSessionRequest) (NewSessionResponse, error) | |
| 29 | + Prompt(context.Context, PromptRequest) (PromptResponse, error) | |
| 30 | + // Cancel is a notification: there is nothing to answer and nothing to | |
| 31 | + // fail. It must return promptly — the turn it cancels reports the outcome | |
| 32 | + // itself, by ending with StopCancelled. | |
| 33 | + Cancel(context.Context, CancelParams) | |
| 34 | + } | |
| 35 | + | |
| 36 | + // Extender is the optional half of [Handler]: methods outside the core set. | |
| 37 | + // A handler that does not implement it answers CodeMethodNotFound to anything | |
| 38 | + // unrecognised, which is the correct answer and not an error. | |
| 39 | + type Extender interface { | |
| 40 | + Extra(ctx context.Context, method string, params json.RawMessage) (any, error) | |
| 41 | + } | |
| 42 | + | |
| 43 | + // ErrClosed is what a pending [Conn.Request] fails with when the connection | |
| 44 | + // ends before the answer arrives. | |
| 45 | + var ErrClosed = errors.New("acp: connection closed") | |
| 46 | + | |
| 47 | + // Conn is one ACP connection. | |
| 48 | + type Conn struct { | |
| 49 | + h Handler | |
| 50 | + r io.Reader | |
| 51 | + w io.Writer | |
| 52 | + | |
| 53 | + // wmu serializes writes. Every frame is marshalled whole and written in | |
| 54 | + // one call, so interleaving is the only thing to guard against. | |
| 55 | + wmu sync.Mutex | |
| 56 | + | |
| 57 | + nextID atomic.Int64 | |
| 58 | + | |
| 59 | + pmu sync.Mutex | |
| 60 | + pending map[string]chan *Message | |
| 61 | + | |
| 62 | + closeOnce sync.Once | |
| 63 | + done chan struct{} | |
| 64 | + | |
| 65 | + // wg counts handler goroutines, so Serve can wait for them to finish | |
| 66 | + // writing before it returns and the streams go away underneath them. | |
| 67 | + wg sync.WaitGroup | |
| 68 | + } | |
| 69 | + | |
| 70 | + // NewConn wires a handler to a pair of streams. Nothing is read or written | |
| 71 | + // until [Conn.Serve] runs. | |
| 72 | + func NewConn(r io.Reader, w io.Writer, h Handler) *Conn { | |
| 73 | + return &Conn{ | |
| 74 | + h: h, | |
| 75 | + r: r, | |
| 76 | + w: w, | |
| 77 | + pending: make(map[string]chan *Message), | |
| 78 | + done: make(chan struct{}), | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + // Serve reads until the stream ends, dispatching as it goes. A clean EOF is | |
| 83 | + // how a client says goodbye, so it returns nil; anything else is the error | |
| 84 | + // that stopped it. | |
| 85 | + // | |
| 86 | + // Cancelling ctx stops the handlers but not the read itself: a blocking read | |
| 87 | + // on an arbitrary io.Reader cannot be interrupted portably. Closing the reader | |
| 88 | + // is what ends Serve. | |
| 89 | + func (c *Conn) Serve(ctx context.Context) error { | |
| 90 | + ctx, cancel := context.WithCancel(ctx) | |
| 91 | + | |
| 92 | + err := c.loop(ctx) | |
| 93 | + | |
| 94 | + // Order matters. Cancel first so a turn in flight stops, then release | |
| 95 | + // anyone blocked waiting for an answer that is no longer coming, then wait | |
| 96 | + // for the handlers to finish with the streams. | |
| 97 | + cancel() | |
| 98 | + c.close() | |
| 99 | + c.wg.Wait() | |
| 100 | + return err | |
| 101 | + } | |
| 102 | + | |
| 103 | + func (c *Conn) loop(ctx context.Context) error { | |
| 104 | + // ReadBytes rather than a Scanner: a prompt can carry a base64 image, and | |
| 105 | + // a line-length limit chosen here would be a decode failure nobody could | |
| 106 | + // explain. | |
| 107 | + br := bufio.NewReaderSize(c.r, 64*1024) | |
| 108 | + for { | |
| 109 | + line, err := br.ReadBytes('\n') | |
| 110 | + if len(bytes.TrimSpace(line)) > 0 { | |
| 111 | + c.handleLine(ctx, line) | |
| 112 | + } | |
| 113 | + switch { | |
| 114 | + case errors.Is(err, io.EOF): | |
| 115 | + return nil | |
| 116 | + case err != nil: | |
| 117 | + return err | |
| 118 | + } | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + func (c *Conn) handleLine(ctx context.Context, line []byte) { | |
| 123 | + var m Message | |
| 124 | + if err := json.Unmarshal(line, &m); err != nil { | |
| 125 | + // A frame we cannot parse might still have a recoverable id, in which | |
| 126 | + // case the peer is waiting and deserves an answer. If not, there is | |
| 127 | + // nobody to tell and the loop carries on: one bad line is not a reason | |
| 128 | + // to end a session. | |
| 129 | + if id := salvageID(line); id != nil { | |
| 130 | + c.respondError(id, Errorf(CodeParse, "invalid JSON")) | |
| 131 | + } | |
| 132 | + return | |
| 133 | + } | |
| 134 | + | |
| 135 | + switch { | |
| 136 | + case m.ID != nil && m.Method == "": | |
| 137 | + c.deliver(&m) | |
| 138 | + case m.ID != nil: | |
| 139 | + c.wg.Add(1) | |
| 140 | + go func() { | |
| 141 | + defer c.wg.Done() | |
| 142 | + c.serveRequest(ctx, &m) | |
| 143 | + }() | |
| 144 | + case m.Method != "": | |
| 145 | + c.wg.Add(1) | |
| 146 | + go func() { | |
| 147 | + defer c.wg.Done() | |
| 148 | + c.serveNotification(ctx, &m) | |
| 149 | + }() | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + // salvageID pulls the id out of a line that did not parse as a whole, so a | |
| 154 | + // peer blocked on a request gets an error instead of a hang. | |
| 155 | + // | |
| 156 | + // It streams tokens rather than unmarshalling, because the interesting case is | |
| 157 | + // a frame that was cut off: the id is usually intact and everything after it | |
| 158 | + // is not, and a whole-value decode fails on exactly those lines. | |
| 159 | + func salvageID(line []byte) *json.RawMessage { | |
| 160 | + dec := json.NewDecoder(bytes.NewReader(line)) | |
| 161 | + if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { | |
| 162 | + return nil | |
| 163 | + } | |
| 164 | + for { | |
| 165 | + tok, err := dec.Token() | |
| 166 | + if err != nil { | |
| 167 | + return nil | |
| 168 | + } | |
| 169 | + key, ok := tok.(string) | |
| 170 | + if !ok { | |
| 171 | + return nil // the closing brace, or something that is not an object | |
| 172 | + } | |
| 173 | + var raw json.RawMessage | |
| 174 | + if err := dec.Decode(&raw); err != nil { | |
| 175 | + return nil | |
| 176 | + } | |
| 177 | + if key == "id" { | |
| 178 | + return &raw | |
| 179 | + } | |
| 180 | + } | |
| 181 | + } | |
| 182 | + | |
| 183 | + func (c *Conn) serveRequest(ctx context.Context, m *Message) { | |
| 184 | + result, err := c.call(ctx, m.Method, m.Params) | |
| 185 | + if err != nil { | |
| 186 | + c.respondError(m.ID, asError(err)) | |
| 187 | + return | |
| 188 | + } | |
| 189 | + c.respond(m.ID, result) | |
| 190 | + } | |
| 191 | + | |
| 192 | + func (c *Conn) serveNotification(ctx context.Context, m *Message) { | |
| 193 | + if m.Method != MethodCancel { | |
| 194 | + // Unknown notifications are ignored by design: that is what makes it | |
| 195 | + // safe for a client to send one we have not heard of. | |
| 196 | + return | |
| 197 | + } | |
| 198 | + var params CancelParams | |
| 199 | + if err := decodeParams(m.Params, ¶ms); err != nil { | |
| 200 | + return | |
| 201 | + } | |
| 202 | + c.h.Cancel(ctx, params) | |
| 203 | + } | |
| 204 | + | |
| 205 | + // call dispatches one request to the handler. The returned value is whatever | |
| 206 | + // goes in the result field; nil becomes an empty object, because a JSON-RPC | |
| 207 | + // response with no result at all is not a success. | |
| 208 | + func (c *Conn) call(ctx context.Context, method string, params json.RawMessage) (any, error) { | |
| 209 | + switch method { | |
| 210 | + case MethodInitialize: | |
| 211 | + var req InitializeRequest | |
| 212 | + if err := decodeParams(params, &req); err != nil { | |
| 213 | + return nil, err | |
| 214 | + } | |
| 215 | + return c.h.Initialize(ctx, req) | |
| 216 | + | |
| 217 | + case MethodAuthenticate: | |
| 218 | + var req AuthenticateRequest | |
| 219 | + if err := decodeParams(params, &req); err != nil { | |
| 220 | + return nil, err | |
| 221 | + } | |
| 222 | + return nil, c.h.Authenticate(ctx, req) | |
| 223 | + | |
| 224 | + case MethodNewSession: | |
| 225 | + var req NewSessionRequest | |
| 226 | + if err := decodeParams(params, &req); err != nil { | |
| 227 | + return nil, err | |
| 228 | + } | |
| 229 | + return c.h.NewSession(ctx, req) | |
| 230 | + | |
| 231 | + case MethodPrompt: | |
| 232 | + var req PromptRequest | |
| 233 | + if err := decodeParams(params, &req); err != nil { | |
| 234 | + return nil, err | |
| 235 | + } | |
| 236 | + return c.h.Prompt(ctx, req) | |
| 237 | + | |
| 238 | + default: | |
| 239 | + if ex, ok := c.h.(Extender); ok { | |
| 240 | + return ex.Extra(ctx, method, params) | |
| 241 | + } | |
| 242 | + return nil, Errorf(CodeMethodNotFound, "unknown method %q", method) | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + // Notify sends a notification. It never has an id, which is what stops the | |
| 247 | + // peer from waiting for an answer to it. | |
| 248 | + func (c *Conn) Notify(ctx context.Context, method string, params any) error { | |
| 249 | + raw, err := json.Marshal(params) | |
| 250 | + if err != nil { | |
| 251 | + return err | |
| 252 | + } | |
| 253 | + return c.write(&Message{JSONRPC: jsonrpcVersion, Method: method, Params: raw}) | |
| 254 | + } | |
| 255 | + | |
| 256 | + // Request calls the peer and waits for the answer, decoding it into out when | |
| 257 | + // out is non-nil. It returns ctx.Err() if the caller gives up first and | |
| 258 | + // [ErrClosed] if the connection ends before the peer replies — a permission | |
| 259 | + // request outliving its connection has to fail rather than hang the turn. | |
| 260 | + func (c *Conn) Request(ctx context.Context, method string, params, out any) error { | |
| 261 | + raw, err := json.Marshal(params) | |
| 262 | + if err != nil { | |
| 263 | + return err | |
| 264 | + } | |
| 265 | + | |
| 266 | + id := json.RawMessage(strconv.FormatInt(c.nextID.Add(1), 10)) | |
| 267 | + key := string(id) | |
| 268 | + ch := make(chan *Message, 1) | |
| 269 | + | |
| 270 | + c.pmu.Lock() | |
| 271 | + c.pending[key] = ch | |
| 272 | + c.pmu.Unlock() | |
| 273 | + defer func() { | |
| 274 | + c.pmu.Lock() | |
| 275 | + delete(c.pending, key) | |
| 276 | + c.pmu.Unlock() | |
| 277 | + }() | |
| 278 | + | |
| 279 | + if err := c.write(&Message{JSONRPC: jsonrpcVersion, ID: &id, Method: method, Params: raw}); err != nil { | |
| 280 | + return err | |
| 281 | + } | |
| 282 | + | |
| 283 | + select { | |
| 284 | + case <-ctx.Done(): | |
| 285 | + return ctx.Err() | |
| 286 | + case <-c.done: | |
| 287 | + return ErrClosed | |
| 288 | + case reply := <-ch: | |
| 289 | + if reply.Error != nil { | |
| 290 | + return reply.Error | |
| 291 | + } | |
| 292 | + if out == nil || len(reply.Result) == 0 { | |
| 293 | + return nil | |
| 294 | + } | |
| 295 | + return json.Unmarshal(reply.Result, out) | |
| 296 | + } | |
| 297 | + } | |
| 298 | + | |
| 299 | + func (c *Conn) deliver(m *Message) { | |
| 300 | + c.pmu.Lock() | |
| 301 | + ch := c.pending[string(*m.ID)] | |
| 302 | + c.pmu.Unlock() | |
| 303 | + if ch != nil { | |
| 304 | + // Buffered, and the entry is removed by the waiter, so this cannot | |
| 305 | + // block and cannot be delivered twice. | |
| 306 | + select { | |
| 307 | + case ch <- m: | |
| 308 | + default: | |
| 309 | + } | |
| 310 | + } | |
| 311 | + } | |
| 312 | + | |
| 313 | + func (c *Conn) respond(id *json.RawMessage, result any) { | |
| 314 | + if result == nil { | |
| 315 | + result = struct{}{} | |
| 316 | + } | |
| 317 | + raw, err := json.Marshal(result) | |
| 318 | + if err != nil { | |
| 319 | + c.respondError(id, asError(err)) | |
| 320 | + return | |
| 321 | + } | |
| 322 | + _ = c.write(&Message{JSONRPC: jsonrpcVersion, ID: id, Result: raw}) | |
| 323 | + } | |
| 324 | + | |
| 325 | + func (c *Conn) respondError(id *json.RawMessage, e *Error) { | |
| 326 | + if id == nil { | |
| 327 | + null := json.RawMessage("null") | |
| 328 | + id = &null | |
| 329 | + } | |
| 330 | + _ = c.write(&Message{JSONRPC: jsonrpcVersion, ID: id, Error: e}) | |
| 331 | + } | |
| 332 | + | |
| 333 | + func (c *Conn) write(m *Message) error { | |
| 334 | + b, err := json.Marshal(m) | |
| 335 | + if err != nil { | |
| 336 | + return err | |
| 337 | + } | |
| 338 | + b = append(b, '\n') | |
| 339 | + | |
| 340 | + c.wmu.Lock() | |
| 341 | + defer c.wmu.Unlock() | |
| 342 | + _, err = c.w.Write(b) | |
| 343 | + return err | |
| 344 | + } | |
| 345 | + | |
| 346 | + func (c *Conn) close() { | |
| 347 | + c.closeOnce.Do(func() { close(c.done) }) | |
| 348 | + } | |
| @@ -0,0 +1,382 @@ | ||
| 1 | + package acp | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bufio" | |
| 5 | + "bytes" | |
| 6 | + "context" | |
| 7 | + "encoding/json" | |
| 8 | + "errors" | |
| 9 | + "io" | |
| 10 | + "strings" | |
| 11 | + "sync" | |
| 12 | + "testing" | |
| 13 | + "time" | |
| 14 | + ) | |
| 15 | + | |
| 16 | + // stubHandler answers the core methods with whatever the test set on it. | |
| 17 | + type stubHandler struct { | |
| 18 | + initialize func(InitializeRequest) (InitializeResponse, error) | |
| 19 | + authFail error | |
| 20 | + newSession func(NewSessionRequest) (NewSessionResponse, error) | |
| 21 | + prompt func(context.Context, PromptRequest) (PromptResponse, error) | |
| 22 | + | |
| 23 | + mu sync.Mutex | |
| 24 | + cancelled []string | |
| 25 | + } | |
| 26 | + | |
| 27 | + func (s *stubHandler) Initialize(_ context.Context, req InitializeRequest) (InitializeResponse, error) { | |
| 28 | + if s.initialize != nil { | |
| 29 | + return s.initialize(req) | |
| 30 | + } | |
| 31 | + return InitializeResponse{ProtocolVersion: ProtocolVersion, AuthMethods: []AuthMethod{}}, nil | |
| 32 | + } | |
| 33 | + | |
| 34 | + func (s *stubHandler) Authenticate(context.Context, AuthenticateRequest) error { return s.authFail } | |
| 35 | + | |
| 36 | + func (s *stubHandler) NewSession(_ context.Context, req NewSessionRequest) (NewSessionResponse, error) { | |
| 37 | + if s.newSession != nil { | |
| 38 | + return s.newSession(req) | |
| 39 | + } | |
| 40 | + return NewSessionResponse{SessionID: "s1"}, nil | |
| 41 | + } | |
| 42 | + | |
| 43 | + func (s *stubHandler) Prompt(ctx context.Context, req PromptRequest) (PromptResponse, error) { | |
| 44 | + if s.prompt != nil { | |
| 45 | + return s.prompt(ctx, req) | |
| 46 | + } | |
| 47 | + return PromptResponse{StopReason: StopEndTurn}, nil | |
| 48 | + } | |
| 49 | + | |
| 50 | + func (s *stubHandler) Cancel(_ context.Context, p CancelParams) { | |
| 51 | + s.mu.Lock() | |
| 52 | + defer s.mu.Unlock() | |
| 53 | + s.cancelled = append(s.cancelled, p.SessionID) | |
| 54 | + } | |
| 55 | + | |
| 56 | + // peer is the far end of a connection: a pipe to write requests into and a | |
| 57 | + // reader that hands back one decoded frame at a time. | |
| 58 | + type peer struct { | |
| 59 | + t *testing.T | |
| 60 | + in *io.PipeWriter | |
| 61 | + out *bufio.Reader | |
| 62 | + conn *Conn | |
| 63 | + done chan error | |
| 64 | + } | |
| 65 | + | |
| 66 | + func newPeer(t *testing.T, h Handler) *peer { | |
| 67 | + t.Helper() | |
| 68 | + toAgent, fromPeer := io.Pipe() | |
| 69 | + fromAgent, toPeer := io.Pipe() | |
| 70 | + | |
| 71 | + c := NewConn(toAgent, toPeer, h) | |
| 72 | + p := &peer{t: t, in: fromPeer, out: bufio.NewReader(fromAgent), conn: c, done: make(chan error, 1)} | |
| 73 | + | |
| 74 | + go func() { p.done <- c.Serve(context.Background()) }() | |
| 75 | + t.Cleanup(func() { | |
| 76 | + _ = fromPeer.Close() | |
| 77 | + select { | |
| 78 | + case <-p.done: | |
| 79 | + case <-time.After(2 * time.Second): | |
| 80 | + t.Error("Serve did not return after the stream closed") | |
| 81 | + } | |
| 82 | + }) | |
| 83 | + return p | |
| 84 | + } | |
| 85 | + | |
| 86 | + func (p *peer) send(line string) { | |
| 87 | + p.t.Helper() | |
| 88 | + if _, err := io.WriteString(p.in, line+"\n"); err != nil { | |
| 89 | + p.t.Fatalf("writing to the agent: %v", err) | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + // readRaw returns the next line exactly as it went over the wire, because some | |
| 94 | + // of what matters here — an absent key — does not survive decoding. | |
| 95 | + func (p *peer) readRaw() string { | |
| 96 | + p.t.Helper() | |
| 97 | + type result struct { | |
| 98 | + line string | |
| 99 | + err error | |
| 100 | + } | |
| 101 | + ch := make(chan result, 1) | |
| 102 | + go func() { | |
| 103 | + line, err := p.out.ReadString('\n') | |
| 104 | + ch <- result{line, err} | |
| 105 | + }() | |
| 106 | + select { | |
| 107 | + case r := <-ch: | |
| 108 | + if r.err != nil { | |
| 109 | + p.t.Fatalf("reading from the agent: %v", r.err) | |
| 110 | + } | |
| 111 | + return strings.TrimSuffix(r.line, "\n") | |
| 112 | + case <-time.After(2 * time.Second): | |
| 113 | + p.t.Fatal("timed out waiting for a frame from the agent") | |
| 114 | + return "" | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + func (p *peer) read() Message { | |
| 119 | + p.t.Helper() | |
| 120 | + var m Message | |
| 121 | + line := p.readRaw() | |
| 122 | + if err := json.Unmarshal([]byte(line), &m); err != nil { | |
| 123 | + p.t.Fatalf("undecodable frame %q: %v", line, err) | |
| 124 | + } | |
| 125 | + return m | |
| 126 | + } | |
| 127 | + | |
| 128 | + func TestEveryFrameCarriesTheProtocolVersion(t *testing.T) { | |
| 129 | + p := newPeer(t, &stubHandler{}) | |
| 130 | + | |
| 131 | + p.send(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}`) | |
| 132 | + if got := p.read().JSONRPC; got != "2.0" { | |
| 133 | + t.Errorf("response jsonrpc = %q, want 2.0", got) | |
| 134 | + } | |
| 135 | + | |
| 136 | + go func() { | |
| 137 | + _ = p.conn.Notify(context.Background(), MethodSessionUpdate, SessionNotification{SessionID: "s1"}) | |
| 138 | + }() | |
| 139 | + if got := p.read().JSONRPC; got != "2.0" { | |
| 140 | + t.Errorf("notification jsonrpc = %q, want 2.0", got) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + func TestANotificationHasNoIDKeyAtAll(t *testing.T) { | |
| 145 | + p := newPeer(t, &stubHandler{}) | |
| 146 | + | |
| 147 | + go func() { | |
| 148 | + _ = p.conn.Notify(context.Background(), MethodSessionUpdate, SessionNotification{ | |
| 149 | + SessionID: "s1", | |
| 150 | + Update: Update{SessionUpdate: UpdateAgentMessageChunk, Content: TextContent("hi")}, | |
| 151 | + }) | |
| 152 | + }() | |
| 153 | + | |
| 154 | + // A decoded map would show the difference between absent and null as the | |
| 155 | + // same missing key, and null is the thing that breaks the client. | |
| 156 | + line := p.readRaw() | |
| 157 | + if strings.Contains(line, `"id"`) { | |
| 158 | + t.Errorf("notification carries an id: %s", line) | |
| 159 | + } | |
| 160 | + if !strings.Contains(line, `"sessionUpdate":"agent_message_chunk"`) { | |
| 161 | + t.Errorf("notification lost its tag: %s", line) | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 165 | + func TestUnknownMethodIsMethodNotFound(t *testing.T) { | |
| 166 | + p := newPeer(t, &stubHandler{}) | |
| 167 | + | |
| 168 | + p.send(`{"jsonrpc":"2.0","id":"abc","method":"session/set_model","params":{}}`) | |
| 169 | + m := p.read() | |
| 170 | + | |
| 171 | + if m.ID == nil || string(*m.ID) != `"abc"` { | |
| 172 | + t.Errorf("id did not come back byte for byte: %v", m.ID) | |
| 173 | + } | |
| 174 | + if m.Error == nil || m.Error.Code != CodeMethodNotFound { | |
| 175 | + t.Fatalf("want a method-not-found error, got %+v", m.Error) | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + // extendedHandler answers anything the core set does not. | |
| 180 | + type extendedHandler struct { | |
| 181 | + stubHandler | |
| 182 | + seen string | |
| 183 | + } | |
| 184 | + | |
| 185 | + func (e *extendedHandler) Extra(_ context.Context, method string, _ json.RawMessage) (any, error) { | |
| 186 | + e.seen = method | |
| 187 | + return map[string]any{}, nil | |
| 188 | + } | |
| 189 | + | |
| 190 | + func TestAnExtenderAnswersTheRest(t *testing.T) { | |
| 191 | + h := &extendedHandler{} | |
| 192 | + p := newPeer(t, h) | |
| 193 | + | |
| 194 | + p.send(`{"jsonrpc":"2.0","id":7,"method":"session/set_model","params":{"modelId":"x"}}`) | |
| 195 | + m := p.read() | |
| 196 | + | |
| 197 | + if m.Error != nil { | |
| 198 | + t.Fatalf("unexpected error: %v", m.Error) | |
| 199 | + } | |
| 200 | + if h.seen != "session/set_model" { | |
| 201 | + t.Errorf("Extra saw %q", h.seen) | |
| 202 | + } | |
| 203 | + if string(m.Result) != "{}" { | |
| 204 | + t.Errorf("result = %s, want {}", m.Result) | |
| 205 | + } | |
| 206 | + } | |
| 207 | + | |
| 208 | + func TestAHandlerErrorBecomesInternalAndTheLoopSurvives(t *testing.T) { | |
| 209 | + h := &stubHandler{initialize: func(InitializeRequest) (InitializeResponse, error) { | |
| 210 | + return InitializeResponse{}, errors.New("no") | |
| 211 | + }} | |
| 212 | + p := newPeer(t, h) | |
| 213 | + | |
| 214 | + p.send(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) | |
| 215 | + if m := p.read(); m.Error == nil || m.Error.Code != CodeInternal { | |
| 216 | + t.Fatalf("want an internal error, got %+v", m.Error) | |
| 217 | + } | |
| 218 | + | |
| 219 | + // The connection is still usable, which is the point. | |
| 220 | + p.send(`{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp"}}`) | |
| 221 | + if m := p.read(); m.Error != nil { | |
| 222 | + t.Fatalf("the connection did not survive the error: %v", m.Error) | |
| 223 | + } | |
| 224 | + } | |
| 225 | + | |
| 226 | + func TestBadParamsAreInvalidParamsNotInternal(t *testing.T) { | |
| 227 | + p := newPeer(t, &stubHandler{}) | |
| 228 | + | |
| 229 | + p.send(`{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":42}}`) | |
| 230 | + if m := p.read(); m.Error == nil || m.Error.Code != CodeInvalidParams { | |
| 231 | + t.Fatalf("want invalid params, got %+v", m.Error) | |
| 232 | + } | |
| 233 | + } | |
| 234 | + | |
| 235 | + func TestMalformedLinesAreAnsweredWhenTheyCanBeAndSkippedWhenTheyCannot(t *testing.T) { | |
| 236 | + p := newPeer(t, &stubHandler{}) | |
| 237 | + | |
| 238 | + // Unparseable as a whole, but the id is recoverable: the peer is waiting. | |
| 239 | + p.send(`{"jsonrpc":"2.0","id":3,"method":"initialize","params":{`) | |
| 240 | + if m := p.read(); m.Error == nil || m.Error.Code != CodeParse { | |
| 241 | + t.Fatalf("want a parse error, got %+v", m.Error) | |
| 242 | + } | |
| 243 | + | |
| 244 | + // Nothing to answer, and nothing to do but carry on. | |
| 245 | + p.send(`not json at all`) | |
| 246 | + p.send(`{"jsonrpc":"2.0","id":4,"method":"initialize","params":{}}`) | |
| 247 | + if m := p.read(); m.Error != nil { | |
| 248 | + t.Fatalf("the loop did not survive a junk line: %v", m.Error) | |
| 249 | + } | |
| 250 | + } | |
| 251 | + | |
| 252 | + func TestRequestWaitsForTheMatchingResponse(t *testing.T) { | |
| 253 | + p := newPeer(t, &stubHandler{}) | |
| 254 | + | |
| 255 | + var out RequestPermissionResult | |
| 256 | + errc := make(chan error, 1) | |
| 257 | + go func() { | |
| 258 | + errc <- p.conn.Request(context.Background(), MethodRequestPermission, | |
| 259 | + RequestPermissionParams{SessionID: "s1"}, &out) | |
| 260 | + }() | |
| 261 | + | |
| 262 | + req := p.read() | |
| 263 | + if req.Method != MethodRequestPermission { | |
| 264 | + t.Fatalf("method = %q", req.Method) | |
| 265 | + } | |
| 266 | + if req.ID == nil { | |
| 267 | + t.Fatal("an outbound request must carry an id") | |
| 268 | + } | |
| 269 | + p.send(`{"jsonrpc":"2.0","id":` + string(*req.ID) + `,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}`) | |
| 270 | + | |
| 271 | + if err := <-errc; err != nil { | |
| 272 | + t.Fatalf("Request: %v", err) | |
| 273 | + } | |
| 274 | + if out.Outcome.OptionID != OptAllowOnce { | |
| 275 | + t.Errorf("outcome = %+v", out.Outcome) | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + func TestRequestGivesUpWithItsContext(t *testing.T) { | |
| 280 | + p := newPeer(t, &stubHandler{}) | |
| 281 | + | |
| 282 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 283 | + errc := make(chan error, 1) | |
| 284 | + go func() { | |
| 285 | + errc <- p.conn.Request(ctx, MethodRequestPermission, RequestPermissionParams{}, nil) | |
| 286 | + }() | |
| 287 | + p.read() // let it go out before cancelling | |
| 288 | + cancel() | |
| 289 | + | |
| 290 | + select { | |
| 291 | + case err := <-errc: | |
| 292 | + if !errors.Is(err, context.Canceled) { | |
| 293 | + t.Fatalf("want context.Canceled, got %v", err) | |
| 294 | + } | |
| 295 | + case <-time.After(2 * time.Second): | |
| 296 | + t.Fatal("Request did not return when its context was cancelled") | |
| 297 | + } | |
| 298 | + } | |
| 299 | + | |
| 300 | + func TestRequestFailsWhenTheConnectionEnds(t *testing.T) { | |
| 301 | + toAgent, fromPeer := io.Pipe() | |
| 302 | + fromAgent, toPeer := io.Pipe() | |
| 303 | + // Drained so the outbound request cannot block on an unread pipe. | |
| 304 | + go func() { _, _ = io.Copy(io.Discard, fromAgent) }() | |
| 305 | + | |
| 306 | + c := NewConn(toAgent, toPeer, &stubHandler{}) | |
| 307 | + served := make(chan error, 1) | |
| 308 | + go func() { served <- c.Serve(context.Background()) }() | |
| 309 | + | |
| 310 | + errc := make(chan error, 1) | |
| 311 | + go func() { | |
| 312 | + errc <- c.Request(context.Background(), MethodRequestPermission, RequestPermissionParams{}, nil) | |
| 313 | + }() | |
| 314 | + | |
| 315 | + time.Sleep(20 * time.Millisecond) | |
| 316 | + _ = fromPeer.Close() | |
| 317 | + | |
| 318 | + select { | |
| 319 | + case err := <-errc: | |
| 320 | + if !errors.Is(err, ErrClosed) { | |
| 321 | + t.Fatalf("want ErrClosed, got %v", err) | |
| 322 | + } | |
| 323 | + case <-time.After(2 * time.Second): | |
| 324 | + t.Fatal("a pending request outlived its connection") | |
| 325 | + } | |
| 326 | + if err := <-served; err != nil { | |
| 327 | + t.Errorf("Serve after a clean close: %v", err) | |
| 328 | + } | |
| 329 | + } | |
| 330 | + | |
| 331 | + func TestCancelArrivesWhileAPromptIsStillRunning(t *testing.T) { | |
| 332 | + release := make(chan struct{}) | |
| 333 | + h := &stubHandler{} | |
| 334 | + h.prompt = func(ctx context.Context, _ PromptRequest) (PromptResponse, error) { | |
| 335 | + <-release | |
| 336 | + return PromptResponse{StopReason: StopCancelled}, nil | |
| 337 | + } | |
| 338 | + p := newPeer(t, h) | |
| 339 | + | |
| 340 | + p.send(`{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"sessionId":"s1","prompt":[]}}`) | |
| 341 | + p.send(`{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"s1"}}`) | |
| 342 | + | |
| 343 | + // The cancel must land while the prompt is still blocked. Polling rather | |
| 344 | + // than sleeping a fixed time keeps the test honest on a loaded machine. | |
| 345 | + deadline := time.Now().Add(2 * time.Second) | |
| 346 | + for { | |
| 347 | + h.mu.Lock() | |
| 348 | + got := len(h.cancelled) | |
| 349 | + h.mu.Unlock() | |
| 350 | + if got == 1 { | |
| 351 | + break | |
| 352 | + } | |
| 353 | + if time.Now().After(deadline) { | |
| 354 | + t.Fatal("the cancel was queued behind the prompt") | |
| 355 | + } | |
| 356 | + time.Sleep(time.Millisecond) | |
| 357 | + } | |
| 358 | + | |
| 359 | + close(release) | |
| 360 | + if m := p.read(); m.Error != nil { | |
| 361 | + t.Fatalf("prompt failed: %v", m.Error) | |
| 362 | + } | |
| 363 | + } | |
| 364 | + | |
| 365 | + func TestCleanEOFIsNotAnError(t *testing.T) { | |
| 366 | + c := NewConn(strings.NewReader(""), &bytes.Buffer{}, &stubHandler{}) | |
| 367 | + if err := c.Serve(context.Background()); err != nil { | |
| 368 | + t.Errorf("Serve on an empty stream: %v", err) | |
| 369 | + } | |
| 370 | + } | |
| 371 | + | |
| 372 | + func TestATrailingLineWithoutANewlineIsStillHandled(t *testing.T) { | |
| 373 | + // A peer that exits right after writing may not terminate its last line. | |
| 374 | + var out bytes.Buffer | |
| 375 | + c := NewConn(strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`), &out, &stubHandler{}) | |
| 376 | + if err := c.Serve(context.Background()); err != nil { | |
| 377 | + t.Fatalf("Serve: %v", err) | |
| 378 | + } | |
| 379 | + if !strings.Contains(out.String(), `"protocolVersion":1`) { | |
| 380 | + t.Errorf("the last line was dropped: %q", out.String()) | |
| 381 | + } | |
| 382 | + } | |
| @@ -0,0 +1,308 @@ | ||
| 1 | + package acp | |
| 2 | + | |
| 3 | + import "encoding/json" | |
| 4 | + | |
| 5 | + // ProtocolVersion is the ACP revision this package speaks. T3 Code sends 1 in | |
| 6 | + // its initialize request and every other client we know of does too; a client | |
| 7 | + // that asks for something else still gets 1 back, because answering with a | |
| 8 | + // version we do not implement would be a lie the handshake cannot recover | |
| 9 | + // from. | |
| 10 | + const ProtocolVersion = 1 | |
| 11 | + | |
| 12 | + // The method names, spelled once. A typo in a string literal is a method the | |
| 13 | + // peer never hears about and an error nobody can grep for. | |
| 14 | + const ( | |
| 15 | + MethodInitialize = "initialize" | |
| 16 | + MethodAuthenticate = "authenticate" | |
| 17 | + MethodNewSession = "session/new" | |
| 18 | + MethodPrompt = "session/prompt" | |
| 19 | + MethodCancel = "session/cancel" | |
| 20 | + MethodSessionUpdate = "session/update" | |
| 21 | + MethodRequestPermission = "session/request_permission" | |
| 22 | + ) | |
| 23 | + | |
| 24 | + // Implementation names a peer: the agent to the client, or the client to the | |
| 25 | + // agent. | |
| 26 | + type Implementation struct { | |
| 27 | + Name string `json:"name"` | |
| 28 | + Title string `json:"title,omitempty"` | |
| 29 | + Version string `json:"version"` | |
| 30 | + } | |
| 31 | + | |
| 32 | + // InitializeRequest is the first thing a client sends. | |
| 33 | + // | |
| 34 | + // ClientCapabilities is kept raw. We advertise no filesystem or terminal | |
| 35 | + // capability of our own, so there is nothing in there we act on, and decoding | |
| 36 | + // a structure we ignore is a decode that can fail for no reason. | |
| 37 | + type InitializeRequest struct { | |
| 38 | + ProtocolVersion int `json:"protocolVersion"` | |
| 39 | + ClientCapabilities json.RawMessage `json:"clientCapabilities,omitempty"` | |
| 40 | + ClientInfo *Implementation `json:"clientInfo,omitempty"` | |
| 41 | + } | |
| 42 | + | |
| 43 | + // InitializeResponse answers it. Only ProtocolVersion is required by the | |
| 44 | + // protocol; everything else is how the client decides what to ask us for | |
| 45 | + // later, so leaving a field out is the way to say "do not ask". | |
| 46 | + type InitializeResponse struct { | |
| 47 | + ProtocolVersion int `json:"protocolVersion"` | |
| 48 | + AgentInfo *Implementation `json:"agentInfo,omitempty"` | |
| 49 | + AgentCapabilities *AgentCapabilities `json:"agentCapabilities,omitempty"` | |
| 50 | + // AuthMethods is serialized even when empty: a client that sees the key | |
| 51 | + // knows we answered the question, where a missing key means "unstated". | |
| 52 | + AuthMethods []AuthMethod `json:"authMethods"` | |
| 53 | + // Meta is the protocol's escape hatch, and the one thing we put in it is | |
| 54 | + // ModelStateMeta. A client probing which models an agent has does not want | |
| 55 | + // to open a session to find out, and answering here is what lets its model | |
| 56 | + // picker be filled from the endpoint rather than from parsing our own | |
| 57 | + // command-line output. | |
| 58 | + Meta map[string]any `json:"_meta,omitempty"` | |
| 59 | + } | |
| 60 | + | |
| 61 | + // ModelStateMeta is the key under InitializeResponse.Meta that carries a | |
| 62 | + // [SessionModelState]. | |
| 63 | + const ModelStateMeta = "modelState" | |
| 64 | + | |
| 65 | + // AgentCapabilities is what the agent can be asked to do beyond one prompt. | |
| 66 | + type AgentCapabilities struct { | |
| 67 | + LoadSession bool `json:"loadSession,omitempty"` | |
| 68 | + PromptCapabilities *PromptCapabilities `json:"promptCapabilities,omitempty"` | |
| 69 | + } | |
| 70 | + | |
| 71 | + // PromptCapabilities is what a prompt may contain. | |
| 72 | + type PromptCapabilities struct { | |
| 73 | + Image bool `json:"image,omitempty"` | |
| 74 | + Audio bool `json:"audio,omitempty"` | |
| 75 | + EmbeddedContext bool `json:"embeddedContext,omitempty"` | |
| 76 | + } | |
| 77 | + | |
| 78 | + // AuthMethod is one way of logging in that the agent offers. We offer none — | |
| 79 | + // Codey takes its credential from the environment — so this exists to make the | |
| 80 | + // empty list well typed rather than to be constructed. | |
| 81 | + type AuthMethod struct { | |
| 82 | + ID string `json:"id"` | |
| 83 | + Name string `json:"name"` | |
| 84 | + Description string `json:"description,omitempty"` | |
| 85 | + } | |
| 86 | + | |
| 87 | + // AuthenticateRequest asks the agent to log in with one of the methods it | |
| 88 | + // advertised. Clients send it unconditionally, including a method id we never | |
| 89 | + // offered, so the id is informational. | |
| 90 | + type AuthenticateRequest struct { | |
| 91 | + MethodID string `json:"methodId"` | |
| 92 | + } | |
| 93 | + | |
| 94 | + // NewSessionRequest opens a conversation rooted at a directory. | |
| 95 | + // | |
| 96 | + // AdditionalDirectories is not in every published copy of the schema but T3 | |
| 97 | + // Code sends it, and McpServers is accepted and ignored: an agent that refuses | |
| 98 | + // to decode a field it does not use is an agent that breaks on the client's | |
| 99 | + // next release. | |
| 100 | + type NewSessionRequest struct { | |
| 101 | + Cwd string `json:"cwd"` | |
| 102 | + McpServers []json.RawMessage `json:"mcpServers,omitempty"` | |
| 103 | + AdditionalDirectories []string `json:"additionalDirectories,omitempty"` | |
| 104 | + } | |
| 105 | + | |
| 106 | + // NewSessionResponse hands back the id every later message carries. | |
| 107 | + type NewSessionResponse struct { | |
| 108 | + SessionID string `json:"sessionId"` | |
| 109 | + Models *SessionModelState `json:"models,omitempty"` | |
| 110 | + } | |
| 111 | + | |
| 112 | + // SessionModelState is the model picker: what is selected, and what else there | |
| 113 | + // is. A client that gets this uses it in preference to anything the agent | |
| 114 | + // printed on a command line. | |
| 115 | + type SessionModelState struct { | |
| 116 | + CurrentModelID string `json:"currentModelId"` | |
| 117 | + AvailableModels []ModelInfo `json:"availableModels"` | |
| 118 | + } | |
| 119 | + | |
| 120 | + // ModelInfo is one entry in that picker. | |
| 121 | + type ModelInfo struct { | |
| 122 | + ModelID string `json:"modelId"` | |
| 123 | + Name string `json:"name"` | |
| 124 | + Description string `json:"description,omitempty"` | |
| 125 | + } | |
| 126 | + | |
| 127 | + // SetModelRequest selects a model mid-session. | |
| 128 | + type SetModelRequest struct { | |
| 129 | + SessionID string `json:"sessionId"` | |
| 130 | + ModelID string `json:"modelId"` | |
| 131 | + } | |
| 132 | + | |
| 133 | + // PromptRequest is one user turn. | |
| 134 | + type PromptRequest struct { | |
| 135 | + SessionID string `json:"sessionId"` | |
| 136 | + Prompt []ContentBlock `json:"prompt"` | |
| 137 | + } | |
| 138 | + | |
| 139 | + // ContentBlock is a piece of a message. The union is decoded into one struct | |
| 140 | + // because Go has no sum types and the alternative — a custom unmarshaller per | |
| 141 | + // variant — buys nothing when the only variant we read is text. | |
| 142 | + type ContentBlock struct { | |
| 143 | + Type string `json:"type"` | |
| 144 | + Text string `json:"text,omitempty"` | |
| 145 | + Data string `json:"data,omitempty"` | |
| 146 | + MimeType string `json:"mimeType,omitempty"` | |
| 147 | + URI string `json:"uri,omitempty"` | |
| 148 | + Name string `json:"name,omitempty"` | |
| 149 | + } | |
| 150 | + | |
| 151 | + // The stop reasons a prompt can end with. | |
| 152 | + const ( | |
| 153 | + StopEndTurn = "end_turn" | |
| 154 | + StopCancelled = "cancelled" | |
| 155 | + StopRefusal = "refusal" | |
| 156 | + StopMaxTokens = "max_tokens" | |
| 157 | + StopMaxTurnRequests = "max_turn_requests" | |
| 158 | + ) | |
| 159 | + | |
| 160 | + // PromptResponse ends the turn. StopReason is the only field the clients we | |
| 161 | + // know of read. | |
| 162 | + type PromptResponse struct { | |
| 163 | + StopReason string `json:"stopReason"` | |
| 164 | + } | |
| 165 | + | |
| 166 | + // CancelParams asks for the turn in flight to stop. It is a notification, so | |
| 167 | + // there is nothing to answer: the acknowledgement is the prompt coming back | |
| 168 | + // with StopCancelled. | |
| 169 | + type CancelParams struct { | |
| 170 | + SessionID string `json:"sessionId"` | |
| 171 | + } | |
| 172 | + | |
| 173 | + // SessionNotification is everything the agent says while a turn runs. One | |
| 174 | + // struct with a tagged Update, because that is how the wire is shaped. | |
| 175 | + type SessionNotification struct { | |
| 176 | + SessionID string `json:"sessionId"` | |
| 177 | + Update Update `json:"update"` | |
| 178 | + } | |
| 179 | + | |
| 180 | + // The sessionUpdate discriminators we emit. | |
| 181 | + const ( | |
| 182 | + UpdateAgentMessageChunk = "agent_message_chunk" | |
| 183 | + UpdateAgentThoughtChunk = "agent_thought_chunk" | |
| 184 | + UpdateToolCall = "tool_call" | |
| 185 | + UpdateToolCallUpdate = "tool_call_update" | |
| 186 | + ) | |
| 187 | + | |
| 188 | + // Update is the tagged body of a session notification. Every field beyond | |
| 189 | + // SessionUpdate is optional, and which ones are read depends on the tag. | |
| 190 | + // | |
| 191 | + // Content is `any` because the wire reuses one key for two shapes: on a chunk | |
| 192 | + // it is a single content block, on a tool call it is a list of tool call | |
| 193 | + // content. Updates are only ever written, never decoded, so carrying the union | |
| 194 | + // as an interface costs nothing and spells out that the shape follows the tag. | |
| 195 | + type Update struct { | |
| 196 | + SessionUpdate string `json:"sessionUpdate"` | |
| 197 | + Content any `json:"content,omitempty"` | |
| 198 | + | |
| 199 | + // Set on the tool-call variants. ToolCallID is what pairs an update with | |
| 200 | + // the call it belongs to, so it is the one field that is never optional | |
| 201 | + // there. | |
| 202 | + ToolCallID string `json:"toolCallId,omitempty"` | |
| 203 | + Title string `json:"title,omitempty"` | |
| 204 | + Kind string `json:"kind,omitempty"` | |
| 205 | + Status string `json:"status,omitempty"` | |
| 206 | + RawInput json.RawMessage `json:"rawInput,omitempty"` | |
| 207 | + } | |
| 208 | + | |
| 209 | + // The tool kinds ACP defines. A client uses them to pick an icon and to decide | |
| 210 | + // what to fold away, so a wrong one is cosmetic and a missing one is fine. | |
| 211 | + const ( | |
| 212 | + KindRead = "read" | |
| 213 | + KindEdit = "edit" | |
| 214 | + KindDelete = "delete" | |
| 215 | + KindMove = "move" | |
| 216 | + KindSearch = "search" | |
| 217 | + KindExecute = "execute" | |
| 218 | + KindThink = "think" | |
| 219 | + KindFetch = "fetch" | |
| 220 | + KindOther = "other" | |
| 221 | + ) | |
| 222 | + | |
| 223 | + // The tool call statuses. | |
| 224 | + const ( | |
| 225 | + StatusPending = "pending" | |
| 226 | + StatusInProgress = "in_progress" | |
| 227 | + StatusCompleted = "completed" | |
| 228 | + StatusFailed = "failed" | |
| 229 | + ) | |
| 230 | + | |
| 231 | + // ToolCallContent is what a tool produced: text, or a diff a client can render | |
| 232 | + // side by side. | |
| 233 | + type ToolCallContent struct { | |
| 234 | + Type string `json:"type"` | |
| 235 | + Content *ContentBlock `json:"content,omitempty"` | |
| 236 | + | |
| 237 | + // Set when Type is "diff". | |
| 238 | + Path string `json:"path,omitempty"` | |
| 239 | + OldText string `json:"oldText,omitempty"` | |
| 240 | + NewText string `json:"newText,omitempty"` | |
| 241 | + } | |
| 242 | + | |
| 243 | + // TextContent is the content block a chunk carries, which is always text in | |
| 244 | + // what Codey emits. | |
| 245 | + func TextContent(text string) *ContentBlock { | |
| 246 | + return &ContentBlock{Type: "text", Text: text} | |
| 247 | + } | |
| 248 | + | |
| 249 | + // ToolCallText wraps tool output as tool call content. | |
| 250 | + func ToolCallText(text string) ToolCallContent { | |
| 251 | + return ToolCallContent{Type: "content", Content: TextContent(text)} | |
| 252 | + } | |
| 253 | + | |
| 254 | + // RequestPermissionParams asks the client to decide about one tool call. | |
| 255 | + type RequestPermissionParams struct { | |
| 256 | + SessionID string `json:"sessionId"` | |
| 257 | + ToolCall PermissionToolCall `json:"toolCall"` | |
| 258 | + Options []PermissionOption `json:"options"` | |
| 259 | + } | |
| 260 | + | |
| 261 | + // PermissionToolCall describes what is waiting on the answer. | |
| 262 | + type PermissionToolCall struct { | |
| 263 | + ToolCallID string `json:"toolCallId"` | |
| 264 | + Title string `json:"title,omitempty"` | |
| 265 | + Kind string `json:"kind,omitempty"` | |
| 266 | + Status string `json:"status,omitempty"` | |
| 267 | + RawInput json.RawMessage `json:"rawInput,omitempty"` | |
| 268 | + } | |
| 269 | + | |
| 270 | + // PermissionOption is one answer the client may give. | |
| 271 | + type PermissionOption struct { | |
| 272 | + OptionID string `json:"optionId"` | |
| 273 | + Name string `json:"name"` | |
| 274 | + Kind string `json:"kind"` | |
| 275 | + } | |
| 276 | + | |
| 277 | + // The option kinds, and the option ids we use. | |
| 278 | + // | |
| 279 | + // The ids are not ours to invent freely. Cursor's adapter in T3 Code replies | |
| 280 | + // with these three literals regardless of what the agent offered, so choosing | |
| 281 | + // them here is what lets the same binary sit in a Cursor-shaped slot as well | |
| 282 | + // as a Grok-shaped one. | |
| 283 | + const ( | |
| 284 | + KindAllowOnce = "allow_once" | |
| 285 | + KindAllowAlways = "allow_always" | |
| 286 | + KindRejectOnce = "reject_once" | |
| 287 | + | |
| 288 | + OptAllowOnce = "allow-once" | |
| 289 | + OptAllowAlways = "allow-always" | |
| 290 | + OptRejectOnce = "reject-once" | |
| 291 | + ) | |
| 292 | + | |
| 293 | + // RequestPermissionResult is the answer. | |
| 294 | + type RequestPermissionResult struct { | |
| 295 | + Outcome PermissionOutcome `json:"outcome"` | |
| 296 | + } | |
| 297 | + | |
| 298 | + // PermissionOutcome is either a selection or a cancellation. | |
| 299 | + type PermissionOutcome struct { | |
| 300 | + Outcome string `json:"outcome"` | |
| 301 | + OptionID string `json:"optionId,omitempty"` | |
| 302 | + } | |
| 303 | + | |
| 304 | + // The outcome tags. | |
| 305 | + const ( | |
| 306 | + OutcomeSelected = "selected" | |
| 307 | + OutcomeCancelled = "cancelled" | |
| 308 | + ) | |
| @@ -0,0 +1,87 @@ | ||
| 1 | + // Package acp speaks the Agent Client Protocol: JSON-RPC 2.0 framed as one | |
| 2 | + // compact object per line, over a pair of streams that are usually this | |
| 3 | + // process's stdin and stdout. | |
| 4 | + // | |
| 5 | + // It is a peer, not a client or a server. The other end calls initialize, | |
| 6 | + // opens sessions and sends prompts; this end calls back to ask permission for | |
| 7 | + // a tool. Both directions run over the same connection at the same time, which | |
| 8 | + // is why the read loop dispatches every inbound message onto its own goroutine | |
| 9 | + // — a permission request answered on the read loop would be a deadlock with | |
| 10 | + // the peer doing the same thing. | |
| 11 | + // | |
| 12 | + // The package knows nothing about agents, models or tools. It is the wire and | |
| 13 | + // the vocabulary, and [Handler] is where somebody else's meaning attaches. | |
| 14 | + package acp | |
| 15 | + | |
| 16 | + import ( | |
| 17 | + "encoding/json" | |
| 18 | + "fmt" | |
| 19 | + ) | |
| 20 | + | |
| 21 | + // jsonrpcVersion is required on every frame in both directions. The clients | |
| 22 | + // that matter validate it as a literal, so omitting it is not a shortcut, it | |
| 23 | + // is a decode error at the far end. | |
| 24 | + const jsonrpcVersion = "2.0" | |
| 25 | + | |
| 26 | + // Message is one frame. The four payload fields are mutually exclusive in | |
| 27 | + // practice — a request has Method, a response has Result or Error — but they | |
| 28 | + // share a struct because that is how they share a line. | |
| 29 | + type Message struct { | |
| 30 | + JSONRPC string `json:"jsonrpc"` | |
| 31 | + // ID is kept raw so a string id and a numeric one both come back exactly | |
| 32 | + // as they were sent. A response whose id does not match byte for byte is a | |
| 33 | + // response the peer cannot pair up. | |
| 34 | + ID *json.RawMessage `json:"id,omitempty"` | |
| 35 | + Method string `json:"method,omitempty"` | |
| 36 | + Params json.RawMessage `json:"params,omitempty"` | |
| 37 | + Result json.RawMessage `json:"result,omitempty"` | |
| 38 | + Error *Error `json:"error,omitempty"` | |
| 39 | + } | |
| 40 | + | |
| 41 | + // The JSON-RPC error codes this package produces. | |
| 42 | + const ( | |
| 43 | + CodeParse = -32700 | |
| 44 | + CodeInvalidRequest = -32600 | |
| 45 | + CodeMethodNotFound = -32601 | |
| 46 | + CodeInvalidParams = -32602 | |
| 47 | + CodeInternal = -32603 | |
| 48 | + ) | |
| 49 | + | |
| 50 | + // Error is a JSON-RPC error object, and also a Go error: a [Handler] that | |
| 51 | + // returns one chooses the code the peer sees, and anything else becomes | |
| 52 | + // CodeInternal. | |
| 53 | + type Error struct { | |
| 54 | + Code int `json:"code"` | |
| 55 | + Message string `json:"message"` | |
| 56 | + Data any `json:"data,omitempty"` | |
| 57 | + } | |
| 58 | + | |
| 59 | + func (e *Error) Error() string { return fmt.Sprintf("acp: %s (%d)", e.Message, e.Code) } | |
| 60 | + | |
| 61 | + // Errorf builds an [Error] with a formatted message. | |
| 62 | + func Errorf(code int, format string, args ...any) *Error { | |
| 63 | + return &Error{Code: code, Message: fmt.Sprintf(format, args...)} | |
| 64 | + } | |
| 65 | + | |
| 66 | + // asError turns whatever a handler returned into something the peer can be | |
| 67 | + // told. An error that already chose its code keeps it. | |
| 68 | + func asError(err error) *Error { | |
| 69 | + if e, ok := err.(*Error); ok { | |
| 70 | + return e | |
| 71 | + } | |
| 72 | + return &Error{Code: CodeInternal, Message: err.Error()} | |
| 73 | + } | |
| 74 | + | |
| 75 | + // decodeParams unmarshals a request's params, reporting a bad shape as | |
| 76 | + // CodeInvalidParams rather than as a server fault. Absent params decode as the | |
| 77 | + // zero value: a method whose arguments are all optional may be called with | |
| 78 | + // none. | |
| 79 | + func decodeParams(raw json.RawMessage, v any) error { | |
| 80 | + if len(raw) == 0 || string(raw) == "null" { | |
| 81 | + return nil | |
| 82 | + } | |
| 83 | + if err := json.Unmarshal(raw, v); err != nil { | |
| 84 | + return Errorf(CodeInvalidParams, "cannot decode params: %v", err) | |
| 85 | + } | |
| 86 | + return nil | |
| 87 | + } | |
| @@ -178,10 +178,16 @@ const ( | ||
| 178 | 178 | // Event is what the loop reports to an observer. It carries plain data only — |
| 179 | 179 | // no terminal concerns — so the eval runner can consume the same stream. |
| 180 | 180 | type Event struct { |
| 181 | - Kind EventKind | |
| 182 | - Turn int | |
| 183 | - Text string | |
| 184 | - Tool string | |
| 181 | + Kind EventKind | |
| 182 | + Turn int | |
| 183 | + Text string | |
| 184 | + Tool string | |
| 185 | + // ID pairs a tool_start with the tool_result that answers it: the model's | |
| 186 | + // own tool_use id, echoed on both. Dispatch is sequential, so a front end | |
| 187 | + // can get away with pairing by arrival order — but a protocol that keys | |
| 188 | + // tool calls by id, as ACP does, needs the id rather than a promise about | |
| 189 | + // the order things arrive in. | |
| 190 | + ID string | |
| 185 | 191 | Input json.RawMessage |
| 186 | 192 | Result string |
| 187 | 193 | IsError bool |
| @@ -488,7 +494,7 @@ func (a *Agent) send(ctx context.Context, turn int) (*llm.Response, error) { | ||
| 488 | 494 | func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock) ([]llm.ContentBlock, error) { |
| 489 | 495 | results := make([]llm.ContentBlock, 0, len(calls)) |
| 490 | 496 | for _, call := range calls { |
| 491 | - a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: call.Name, Input: call.Input}) | |
| 497 | + a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: call.Name, ID: call.ID, Input: call.Input}) | |
| 492 | 498 | |
| 493 | 499 | out, err := a.authorizedExecute(ctx, call) |
| 494 | 500 | if ctx.Err() != nil { |
| @@ -513,7 +519,7 @@ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock | ||
| 513 | 519 | } |
| 514 | 520 | |
| 515 | 521 | results = append(results, llm.ToolResultBlock(call.ID, payload, isErr)) |
| 516 | - a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, Result: payload, IsError: isErr}) | |
| 522 | + a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, ID: call.ID, Result: payload, IsError: isErr}) | |
| 517 | 523 | } |
| 518 | 524 | return results, nil |
| 519 | 525 | } |
| @@ -533,7 +539,7 @@ func (a *Agent) emitServerTools(turn int, resp *llm.Response) { | ||
| 533 | 539 | switch b.Type { |
| 534 | 540 | case llm.BlockServerToolUse: |
| 535 | 541 | names[b.ID] = b.Name |
| 536 | - a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: b.Name, Input: b.Input}) | |
| 542 | + a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: b.Name, ID: b.ID, Input: b.Input}) | |
| 537 | 543 | case llm.BlockWebSearchToolResult: |
| 538 | 544 | name, ok := names[b.ToolUseID] |
| 539 | 545 | if !ok { |
| @@ -542,7 +548,7 @@ func (a *Agent) emitServerTools(turn int, resp *llm.Response) { | ||
| 542 | 548 | name = llm.WebSearchToolName |
| 543 | 549 | } |
| 544 | 550 | summary, isErr := summarizeWebSearchResult(b) |
| 545 | - a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: name, Result: summary, IsError: isErr}) | |
| 551 | + a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: name, ID: b.ToolUseID, Result: summary, IsError: isErr}) | |
| 546 | 552 | } |
| 547 | 553 | } |
| 548 | 554 | } |
| @@ -90,7 +90,7 @@ func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.R | ||
| 90 | 90 | if p == nil || p.YOLO || readOnly[tool] { |
| 91 | 91 | return nil |
| 92 | 92 | } |
| 93 | - req := describe(tool, input) | |
| 93 | + req := Describe(tool, input) | |
| 94 | 94 | req.Agent = agent |
| 95 | 95 | |
| 96 | 96 | p.mu.Lock() |
| @@ -159,8 +159,13 @@ func article(word string) string { | ||
| 159 | 159 | return "a" |
| 160 | 160 | } |
| 161 | 161 | |
| 162 | - // describe turns a tool call into something a human can decide on in one read. | |
| 163 | - func describe(tool string, input json.RawMessage) Request { | |
| 162 | + // Describe turns a tool call into something a human can decide on in one read. | |
| 163 | + // | |
| 164 | + // It is exported because the summary is not only for a prompt box: a front end | |
| 165 | + // that shows the agent's tool calls as they happen wants the same one-line | |
| 166 | + // description, and two vocabularies for the same call would be two things to | |
| 167 | + // keep in step. | |
| 168 | + func Describe(tool string, input json.RawMessage) Request { | |
| 164 | 169 | var args struct { |
| 165 | 170 | Command string `json:"command"` |
| 166 | 171 | Path string `json:"path"` |
| @@ -139,7 +139,7 @@ func TestRequestSummaries(t *testing.T) { | ||
| 139 | 139 | } |
| 140 | 140 | for _, tc := range tests { |
| 141 | 141 | t.Run(tc.tool, func(t *testing.T) { |
| 142 | - got := describe(tc.tool, json.RawMessage(tc.input)) | |
| 142 | + got := Describe(tc.tool, json.RawMessage(tc.input)) | |
| 143 | 143 | for _, w := range tc.want { |
| 144 | 144 | if !strings.Contains(got.Summary, w) { |
| 145 | 145 | t.Errorf("summary %q missing %q", got.Summary, w) |
| @@ -189,7 +189,7 @@ func TestApproveAsTagsTheRequestWithTheAgent(t *testing.T) { | ||
| 189 | 189 | // every time — so "always allow this exact one" would be a promise about a |
| 190 | 190 | // string nobody will ever repeat. The tool-wide answer is the one on offer. |
| 191 | 191 | func TestSpawnAgentIsDescribedByPresetAndIsNotRememberedByTask(t *testing.T) { |
| 192 | - req := describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find the approval policy","model":"claude-haiku-4-5"}`)) | |
| 192 | + req := Describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find the approval policy","model":"claude-haiku-4-5"}`)) | |
| 193 | 193 | |
| 194 | 194 | if !strings.Contains(req.Summary, "an explore agent") { |
| 195 | 195 | t.Errorf("summary %q does not name the preset, with the article that fits it", req.Summary) |
| @@ -208,7 +208,7 @@ func TestSpawnAgentIsDescribedByPresetAndIsNotRememberedByTask(t *testing.T) { | ||
| 208 | 208 | // A spawn with no model named runs on the session's, and the prompt should say |
| 209 | 209 | // that rather than invent an id. |
| 210 | 210 | func TestSpawnAgentWithoutAModelSaysSo(t *testing.T) { |
| 211 | - req := describe("spawn_agent", json.RawMessage(`{"preset":"plan","task":"design it"}`)) | |
| 211 | + req := Describe("spawn_agent", json.RawMessage(`{"preset":"plan","task":"design it"}`)) | |
| 212 | 212 | if !strings.Contains(req.Summary, "a plan agent") { |
| 213 | 213 | t.Errorf("summary %q does not name the preset", req.Summary) |
| 214 | 214 | } |
| @@ -220,13 +220,13 @@ func TestSpawnAgentWithoutAModelSaysSo(t *testing.T) { | ||
| 220 | 220 | // What a spawn will spend is the model and the effort together, so the box |
| 221 | 221 | // that asks about it names both. |
| 222 | 222 | func TestSpawnApprovalNamesTheEffortWhenThereIsOne(t *testing.T) { |
| 223 | - with := describe("spawn_agent", json.RawMessage( | |
| 223 | + with := Describe("spawn_agent", json.RawMessage( | |
| 224 | 224 | `{"preset":"explore","task":"find it","model":"claude-opus-5","effort":"low"}`)) |
| 225 | 225 | if !strings.Contains(with.Summary, "low effort") { |
| 226 | 226 | t.Errorf("summary %q does not say what effort it will run at", with.Summary) |
| 227 | 227 | } |
| 228 | 228 | |
| 229 | - without := describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find it"}`)) | |
| 229 | + without := Describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find it"}`)) | |
| 230 | 230 | if strings.Contains(without.Summary, "effort") { |
| 231 | 231 | t.Errorf("summary %q invents an effort level nobody asked for", without.Summary) |
| 232 | 232 | } |
| @@ -242,7 +242,7 @@ func TestWebToolsPromptAndShowWhatLeaves(t *testing.T) { | ||
| 242 | 242 | } |
| 243 | 243 | } |
| 244 | 244 | |
| 245 | - search := describe("web_search", json.RawMessage(`{"query":"go 1.26 release notes"}`)) | |
| 245 | + search := Describe("web_search", json.RawMessage(`{"query":"go 1.26 release notes"}`)) | |
| 246 | 246 | if search.Detail != "go 1.26 release notes" { |
| 247 | 247 | t.Errorf("the query is not the detail: %q", search.Detail) |
| 248 | 248 | } |
| @@ -253,7 +253,7 @@ func TestWebToolsPromptAndShowWhatLeaves(t *testing.T) { | ||
| 253 | 253 | t.Error("a search that differs every call should not be rememberable") |
| 254 | 254 | } |
| 255 | 255 | |
| 256 | - fetch := describe("web_fetch", json.RawMessage(`{"url":"https://go.dev/doc/go1.26"}`)) | |
| 256 | + fetch := Describe("web_fetch", json.RawMessage(`{"url":"https://go.dev/doc/go1.26"}`)) | |
| 257 | 257 | if fetch.Detail != "https://go.dev/doc/go1.26" { |
| 258 | 258 | t.Errorf("the URL is not the detail: %q", fetch.Detail) |
| 259 | 259 | } |
| @@ -0,0 +1,289 @@ | ||
| 1 | + package serve | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "encoding/json" | |
| 5 | + "slices" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "codey/internal/acp" | |
| 10 | + ) | |
| 11 | + | |
| 12 | + // The handshake T3 Code actually sends, byte for byte. | |
| 13 | + // | |
| 14 | + // These are not paraphrases of the protocol. They are the literal payloads | |
| 15 | + // built in T3 Code's ACP session runtime, and the assertions below are its | |
| 16 | + // schema's required fields. When a nightly of theirs breaks us, this is the | |
| 17 | + // test that says where — which is worth more than a test written against what | |
| 18 | + // the specification says ought to happen. | |
| 19 | + // | |
| 20 | + // Source: apps/server/src/provider/acp/AcpSessionRuntime.ts, at t3code | |
| 21 | + // 9accc567. initialize at :696-705 with capabilities from :566-578, | |
| 22 | + // authenticate at :710-718, session/new at :836-849, session/prompt at | |
| 23 | + // :1005-1014. | |
| 24 | + const ( | |
| 25 | + t3Initialize = `{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"clientInfo":{"name":"t3-code","version":"0.0.0"}}` | |
| 26 | + t3Authenticate = `{"methodId":"cached_token"}` | |
| 27 | + ) | |
| 28 | + | |
| 29 | + // raw sends a method with params already encoded, so nothing between the test | |
| 30 | + // and the wire can quietly fix a payload up. | |
| 31 | + func (c *testClient) raw(method, params string) json.RawMessage { | |
| 32 | + c.t.Helper() | |
| 33 | + c.nextID++ | |
| 34 | + id := json.RawMessage(itoa(c.nextID)) | |
| 35 | + c.write(acpMessage{JSONRPC: "2.0", ID: &id, Method: method, Params: json.RawMessage(params)}) | |
| 36 | + | |
| 37 | + m := c.pump(string(id)) | |
| 38 | + if m.Error != nil { | |
| 39 | + c.t.Fatalf("%s: %v", method, m.Error) | |
| 40 | + } | |
| 41 | + return m.Result | |
| 42 | + } | |
| 43 | + | |
| 44 | + // acpMessage mirrors the wire frame so this file can write one by hand. | |
| 45 | + type acpMessage struct { | |
| 46 | + JSONRPC string `json:"jsonrpc"` | |
| 47 | + ID *json.RawMessage `json:"id,omitempty"` | |
| 48 | + Method string `json:"method,omitempty"` | |
| 49 | + Params json.RawMessage `json:"params,omitempty"` | |
| 50 | + } | |
| 51 | + | |
| 52 | + func field(t *testing.T, raw json.RawMessage, name string) any { | |
| 53 | + t.Helper() | |
| 54 | + var m map[string]any | |
| 55 | + if err := json.Unmarshal(raw, &m); err != nil { | |
| 56 | + t.Fatalf("result is not an object: %s", raw) | |
| 57 | + } | |
| 58 | + v, ok := m[name] | |
| 59 | + if !ok { | |
| 60 | + t.Fatalf("result has no %q: %s", name, raw) | |
| 61 | + } | |
| 62 | + return v | |
| 63 | + } | |
| 64 | + | |
| 65 | + func TestTheHandshakeT3CodeActuallySends(t *testing.T) { | |
| 66 | + f := newFakeAPI(t, msg("end_turn", textBlock("hello from codey"))) | |
| 67 | + c := newTestServer(t, f, Options{}) | |
| 68 | + | |
| 69 | + // initialize — the client requires protocolVersion, a number. | |
| 70 | + init := c.raw("initialize", t3Initialize) | |
| 71 | + if v, ok := field(t, init, "protocolVersion").(float64); !ok || int(v) != 1 { | |
| 72 | + t.Fatalf("protocolVersion = %v, want 1", field(t, init, "protocolVersion")) | |
| 73 | + } | |
| 74 | + | |
| 75 | + // authenticate — sent unconditionally with a method id we never offered, | |
| 76 | + // and its failure is not caught on the other end. | |
| 77 | + c.raw("authenticate", t3Authenticate) | |
| 78 | + | |
| 79 | + // session/new — only sessionId is consumed, and it must be a string. | |
| 80 | + root := t.TempDir() | |
| 81 | + newSession := c.raw("session/new", `{"cwd":`+quote(root)+`,"mcpServers":[]}`) | |
| 82 | + id, ok := field(t, newSession, "sessionId").(string) | |
| 83 | + if !ok || id == "" { | |
| 84 | + t.Fatalf("sessionId = %v", field(t, newSession, "sessionId")) | |
| 85 | + } | |
| 86 | + | |
| 87 | + // session/prompt — only stopReason is read, and it has to be one of five. | |
| 88 | + prompt := c.raw("session/prompt", `{"sessionId":`+quote(id)+`,"prompt":[{"type":"text","text":"say hello"}]}`) | |
| 89 | + stop, ok := field(t, prompt, "stopReason").(string) | |
| 90 | + if !ok { | |
| 91 | + t.Fatalf("stopReason = %v", field(t, prompt, "stopReason")) | |
| 92 | + } | |
| 93 | + valid := []string{"end_turn", "max_tokens", "max_turn_requests", "refusal", "cancelled"} | |
| 94 | + if !slices.Contains(valid, stop) { | |
| 95 | + t.Fatalf("stopReason %q is not one the client's schema allows", stop) | |
| 96 | + } | |
| 97 | + | |
| 98 | + // session/cancel is a notification: no id, nothing to answer. | |
| 99 | + c.write(acpMessage{JSONRPC: "2.0", Method: "session/cancel", Params: json.RawMessage(`{"sessionId":` + quote(id) + `}`)}) | |
| 100 | + } | |
| 101 | + | |
| 102 | + func TestEveryNotificationDecodesAgainstTheClientSchema(t *testing.T) { | |
| 103 | + f := newFakeAPI(t, | |
| 104 | + msg("tool_use", toolUseBlock("t1", "write_file", `{"path":"note.txt","content":"hi"}`)), | |
| 105 | + msg("end_turn", textBlock("done")), | |
| 106 | + ) | |
| 107 | + c := newTestServer(t, f, Options{Permission: ModeFullAccess}) | |
| 108 | + | |
| 109 | + id := c.handshake(t.TempDir()) | |
| 110 | + c.prompt(id, "write a note") | |
| 111 | + | |
| 112 | + updates := c.sessionUpdates(id) | |
| 113 | + if len(updates) == 0 { | |
| 114 | + t.Fatal("the turn produced no updates at all") | |
| 115 | + } | |
| 116 | + | |
| 117 | + // The variants the client switches on. Anything else it drops silently, | |
| 118 | + // which is a worse failure than a loud one: the row simply never appears. | |
| 119 | + known := []string{ | |
| 120 | + "agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update", | |
| 121 | + "plan", "current_mode_update", "config_option_update", "available_commands_update", | |
| 122 | + } | |
| 123 | + | |
| 124 | + var sawToolCall, sawToolUpdate bool | |
| 125 | + for _, u := range updates { | |
| 126 | + if !slices.Contains(known, u.SessionUpdate) { | |
| 127 | + t.Errorf("sessionUpdate %q is not one the client handles", u.SessionUpdate) | |
| 128 | + } | |
| 129 | + switch u.SessionUpdate { | |
| 130 | + case "agent_message_chunk", "agent_thought_chunk": | |
| 131 | + // The client reads content.type == "text" and content.text. | |
| 132 | + raw, _ := json.Marshal(u.Content) | |
| 133 | + var content struct { | |
| 134 | + Type string `json:"type"` | |
| 135 | + Text string `json:"text"` | |
| 136 | + } | |
| 137 | + if err := json.Unmarshal(raw, &content); err != nil || content.Type != "text" { | |
| 138 | + t.Errorf("chunk content is not a text block: %s", raw) | |
| 139 | + } | |
| 140 | + case "tool_call": | |
| 141 | + sawToolCall = true | |
| 142 | + // title and toolCallId are the two required fields. | |
| 143 | + if u.ToolCallID == "" || u.Title == "" { | |
| 144 | + t.Errorf("tool_call is missing a required field: %+v", u) | |
| 145 | + } | |
| 146 | + if !slices.Contains([]string{"read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other"}, u.Kind) { | |
| 147 | + t.Errorf("tool kind %q is not in the client's vocabulary", u.Kind) | |
| 148 | + } | |
| 149 | + if !slices.Contains([]string{"pending", "in_progress", "completed", "failed"}, u.Status) { | |
| 150 | + t.Errorf("tool status %q is not in the client's vocabulary", u.Status) | |
| 151 | + } | |
| 152 | + case "tool_call_update": | |
| 153 | + sawToolUpdate = true | |
| 154 | + if u.ToolCallID == "" { | |
| 155 | + t.Error("tool_call_update with no toolCallId cannot be matched to its call") | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + if !sawToolCall || !sawToolUpdate { | |
| 161 | + t.Errorf("a turn that ran a tool produced no tool rows: %+v", updates) | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 165 | + func TestAPermissionRequestMatchesTheClientSchema(t *testing.T) { | |
| 166 | + f := newFakeAPI(t, | |
| 167 | + msg("tool_use", toolUseBlock("t1", "write_file", `{"path":"note.txt","content":"hi"}`)), | |
| 168 | + msg("end_turn", textBlock("done")), | |
| 169 | + ) | |
| 170 | + c := newTestServer(t, f, Options{Permission: ModeAsk}) | |
| 171 | + | |
| 172 | + c.onPermission = func(acp.RequestPermissionParams) acp.RequestPermissionResult { | |
| 173 | + return acp.RequestPermissionResult{ | |
| 174 | + Outcome: acp.PermissionOutcome{Outcome: acp.OutcomeSelected, OptionID: acp.OptAllowOnce}, | |
| 175 | + } | |
| 176 | + } | |
| 177 | + | |
| 178 | + id := c.handshake(t.TempDir()) | |
| 179 | + c.prompt(id, "write a note") | |
| 180 | + | |
| 181 | + c.mu.Lock() | |
| 182 | + seen := c.permission | |
| 183 | + c.mu.Unlock() | |
| 184 | + if seen == nil { | |
| 185 | + t.Fatal("no permission request was sent") | |
| 186 | + } | |
| 187 | + | |
| 188 | + var params struct { | |
| 189 | + SessionID string `json:"sessionId"` | |
| 190 | + ToolCall struct { | |
| 191 | + ToolCallID string `json:"toolCallId"` | |
| 192 | + } `json:"toolCall"` | |
| 193 | + Options []struct { | |
| 194 | + OptionID string `json:"optionId"` | |
| 195 | + Name string `json:"name"` | |
| 196 | + Kind string `json:"kind"` | |
| 197 | + } `json:"options"` | |
| 198 | + } | |
| 199 | + if err := json.Unmarshal(seen, ¶ms); err != nil { | |
| 200 | + t.Fatalf("the request does not decode: %v\n%s", err, seen) | |
| 201 | + } | |
| 202 | + if params.SessionID != id || params.ToolCall.ToolCallID == "" { | |
| 203 | + t.Errorf("params = %s", seen) | |
| 204 | + } | |
| 205 | + if len(params.Options) == 0 { | |
| 206 | + t.Fatal("a permission request with no options cannot be answered") | |
| 207 | + } | |
| 208 | + | |
| 209 | + // The option kinds are a closed set, and the ids have to include the ones | |
| 210 | + // a client may reply with regardless of what we offered. | |
| 211 | + var ids []string | |
| 212 | + for _, o := range params.Options { | |
| 213 | + if o.Name == "" { | |
| 214 | + t.Errorf("an option with no name cannot be shown: %+v", o) | |
| 215 | + } | |
| 216 | + if !slices.Contains([]string{"allow_once", "allow_always", "reject_once", "reject_always"}, o.Kind) { | |
| 217 | + t.Errorf("option kind %q is not in the client's vocabulary", o.Kind) | |
| 218 | + } | |
| 219 | + ids = append(ids, o.OptionID) | |
| 220 | + } | |
| 221 | + for _, want := range []string{"allow-once", "allow-always", "reject-once"} { | |
| 222 | + if !slices.Contains(ids, want) { | |
| 223 | + t.Errorf("option id %q is missing; a client that replies with it would not be understood. got %v", want, ids) | |
| 224 | + } | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 228 | + // quote is strconv.Quote for the handful of JSON strings built by hand here. | |
| 229 | + func quote(s string) string { | |
| 230 | + b, _ := json.Marshal(s) | |
| 231 | + return string(b) | |
| 232 | + } | |
| 233 | + | |
| 234 | + // stray is a guard: nothing but protocol frames may reach the stream. | |
| 235 | + func TestNothingButFramesReachTheStream(t *testing.T) { | |
| 236 | + f := newFakeAPI(t, msg("end_turn", textBlock("quiet"))) | |
| 237 | + c := newTestServer(t, f, Options{}) | |
| 238 | + | |
| 239 | + id := c.handshake(t.TempDir()) | |
| 240 | + c.prompt(id, "say something") | |
| 241 | + | |
| 242 | + for _, line := range c.lines() { | |
| 243 | + if strings.TrimSpace(line) == "" { | |
| 244 | + continue | |
| 245 | + } | |
| 246 | + var probe map[string]any | |
| 247 | + if err := json.Unmarshal([]byte(line), &probe); err != nil { | |
| 248 | + t.Fatalf("a non-JSON line reached the stream and would desynchronise the parser: %q", line) | |
| 249 | + } | |
| 250 | + if probe["jsonrpc"] != "2.0" { | |
| 251 | + t.Errorf("a frame without jsonrpc 2.0: %q", line) | |
| 252 | + } | |
| 253 | + } | |
| 254 | + } | |
| 255 | + | |
| 256 | + func TestTheProbeCanReadTheModelsFromTheHandshakeAlone(t *testing.T) { | |
| 257 | + // A health check spawns the agent, shakes hands and stops. T3 Code reads | |
| 258 | + // initialize._meta.modelState on exactly that path, so a model list only | |
| 259 | + // offered at session/new never reaches the provider card. | |
| 260 | + c := newTestServer(t, newFakeAPI(t), Options{ | |
| 261 | + Models: []acp.ModelInfo{{ModelID: "codey-opus-5", Name: "Codey Opus 5"}}, | |
| 262 | + DefaultModel: "codey-opus-5", | |
| 263 | + }) | |
| 264 | + | |
| 265 | + init := c.raw("initialize", t3Initialize) | |
| 266 | + | |
| 267 | + var resp struct { | |
| 268 | + Meta struct { | |
| 269 | + ModelState struct { | |
| 270 | + CurrentModelID string `json:"currentModelId"` | |
| 271 | + AvailableModels []struct { | |
| 272 | + ModelID string `json:"modelId"` | |
| 273 | + Name string `json:"name"` | |
| 274 | + } `json:"availableModels"` | |
| 275 | + } `json:"modelState"` | |
| 276 | + } `json:"_meta"` | |
| 277 | + } | |
| 278 | + if err := json.Unmarshal(init, &resp); err != nil { | |
| 279 | + t.Fatalf("initialize result: %v", err) | |
| 280 | + } | |
| 281 | + | |
| 282 | + state := resp.Meta.ModelState | |
| 283 | + if state.CurrentModelID != "codey-opus-5" || len(state.AvailableModels) != 1 { | |
| 284 | + t.Fatalf("_meta.modelState = %+v, from %s", state, init) | |
| 285 | + } | |
| 286 | + if state.AvailableModels[0].Name != "Codey Opus 5" { | |
| 287 | + t.Errorf("model name = %q", state.AvailableModels[0].Name) | |
| 288 | + } | |
| 289 | + } | |
| @@ -0,0 +1,359 @@ | ||
| 1 | + // Package serve runs Codey as an agent another program drives, speaking the | |
| 2 | + // Agent Client Protocol over stdin and stdout. | |
| 3 | + // | |
| 4 | + // It is a front end, the same way the TUI and the one-shot `-p` mode are: it | |
| 5 | + // owns no agent behaviour and makes no decisions the other two do not. What it | |
| 6 | + // adds is a translation. The agent reports what it is doing through | |
| 7 | + // [agent.Agent.Observe] and asks permission through [agent.Agent.Approve]; | |
| 8 | + // this package turns the first into `session/update` notifications and the | |
| 9 | + // second into a `session/request_permission` round trip, and turns the | |
| 10 | + // client's prompts back into turns. | |
| 11 | + // | |
| 12 | + // One process serves many sessions. A client that opens three threads gets | |
| 13 | + // three conversations here, each with its own history, its own tool set rooted | |
| 14 | + // where it asked, and its own record of what the user has permanently allowed | |
| 15 | + // — because "always allow this" is a statement about one conversation, not | |
| 16 | + // about the process. | |
| 17 | + package serve | |
| 18 | + | |
| 19 | + import ( | |
| 20 | + "context" | |
| 21 | + "encoding/json" | |
| 22 | + "errors" | |
| 23 | + "fmt" | |
| 24 | + "io" | |
| 25 | + "strings" | |
| 26 | + "sync" | |
| 27 | + | |
| 28 | + "codey/internal/acp" | |
| 29 | + "codey/internal/agent" | |
| 30 | + "codey/internal/approval" | |
| 31 | + "codey/internal/llm" | |
| 32 | + ) | |
| 33 | + | |
| 34 | + // PermissionMode is how tool calls are gated, chosen on the command line by | |
| 35 | + // whoever launched us. It is a floor, not a policy: the client can still be | |
| 36 | + // asked about anything the mode does not settle. | |
| 37 | + type PermissionMode string | |
| 38 | + | |
| 39 | + const ( | |
| 40 | + // ModeAsk asks about every mutating call. The default, and the only mode | |
| 41 | + // that is safe against a tree you care about. | |
| 42 | + ModeAsk PermissionMode = "default" | |
| 43 | + // ModeAcceptEdits lets file edits through and still asks before a command | |
| 44 | + // runs. An edit is reviewable after the fact; a command is not. | |
| 45 | + ModeAcceptEdits PermissionMode = "acceptEdits" | |
| 46 | + // ModeAuto asks only about calls that cannot be pinned down — an opaque | |
| 47 | + // shell command, a search whose query is different every time. Everything | |
| 48 | + // whose exact shape could have been remembered is allowed. | |
| 49 | + ModeAuto PermissionMode = "auto" | |
| 50 | + // ModeFullAccess never asks. Only against a throwaway clone or inside a | |
| 51 | + // container. | |
| 52 | + ModeFullAccess PermissionMode = "full-access" | |
| 53 | + ) | |
| 54 | + | |
| 55 | + // allows reports whether the mode settles this call without asking. | |
| 56 | + func (m PermissionMode) allows(req approval.Request) bool { | |
| 57 | + switch m { | |
| 58 | + case ModeFullAccess: | |
| 59 | + return true | |
| 60 | + case ModeAuto: | |
| 61 | + return req.Rememberable | |
| 62 | + case ModeAcceptEdits: | |
| 63 | + return req.Tool == "edit_file" || req.Tool == "write_file" | |
| 64 | + default: | |
| 65 | + return false | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + // NewAgentFunc builds the agent for one session. | |
| 70 | + // | |
| 71 | + // root is the directory the client asked to work in, which is not necessarily | |
| 72 | + // the one this process was started in — a client that manages worktrees hands | |
| 73 | + // out a different one per thread. model is empty for the default. state is the | |
| 74 | + // conversation, passed in rather than created here so that selecting another | |
| 75 | + // model can rebuild the agent around a history that survives the switch. | |
| 76 | + type NewAgentFunc func(root, model string, state *agent.State) (*agent.Agent, error) | |
| 77 | + | |
| 78 | + // Options configures a [Server]. | |
| 79 | + type Options struct { | |
| 80 | + // NewAgent is required. | |
| 81 | + NewAgent NewAgentFunc | |
| 82 | + // Version is reported in the initialize handshake. | |
| 83 | + Version string | |
| 84 | + // Permission is the approval floor for every session. | |
| 85 | + Permission PermissionMode | |
| 86 | + // Models is the picker the client shows, and DefaultModel is what is | |
| 87 | + // selected in it. Both may be empty, in which case the client is told | |
| 88 | + // nothing and uses whatever the agent was configured with. | |
| 89 | + Models []acp.ModelInfo | |
| 90 | + DefaultModel string | |
| 91 | + } | |
| 92 | + | |
| 93 | + // Server is the ACP end of the process. | |
| 94 | + type Server struct { | |
| 95 | + opts Options | |
| 96 | + conn *acp.Conn | |
| 97 | + | |
| 98 | + mu sync.Mutex | |
| 99 | + sessions map[string]*session | |
| 100 | + } | |
| 101 | + | |
| 102 | + // New builds a server. Nothing runs until [Server.Run]. | |
| 103 | + func New(opts Options) *Server { | |
| 104 | + return &Server{opts: opts, sessions: make(map[string]*session)} | |
| 105 | + } | |
| 106 | + | |
| 107 | + // Run serves one connection until the stream ends. A clean end of input is how | |
| 108 | + // a client says goodbye, and it returns nil. | |
| 109 | + func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error { | |
| 110 | + s.conn = acp.NewConn(r, w, s) | |
| 111 | + return s.conn.Serve(ctx) | |
| 112 | + } | |
| 113 | + | |
| 114 | + // Initialize answers the handshake. | |
| 115 | + // | |
| 116 | + // The protocol version we answer with is ours, not the client's: agreeing to a | |
| 117 | + // revision we do not implement would be a handshake that succeeds and a | |
| 118 | + // session that misbehaves later, which is harder to diagnose than a refusal. | |
| 119 | + func (s *Server) Initialize(_ context.Context, _ acp.InitializeRequest) (acp.InitializeResponse, error) { | |
| 120 | + resp := acp.InitializeResponse{ | |
| 121 | + ProtocolVersion: acp.ProtocolVersion, | |
| 122 | + AgentInfo: &acp.Implementation{Name: "codey", Title: "Codey", Version: s.opts.Version}, | |
| 123 | + AgentCapabilities: &acp.AgentCapabilities{ | |
| 124 | + // Images go through: a screenshot is the fastest way to say what | |
| 125 | + // is wrong with a layout. Session loading does not, yet — and not | |
| 126 | + // advertising it is what stops a client from asking. | |
| 127 | + PromptCapabilities: &acp.PromptCapabilities{Image: true}, | |
| 128 | + }, | |
| 129 | + AuthMethods: []acp.AuthMethod{}, | |
| 130 | + } | |
| 131 | + | |
| 132 | + // The model list, for a client that is only probing. A health check | |
| 133 | + // spawns the agent, shakes hands and stops; answering here is what puts | |
| 134 | + // the models on its provider card without it having to open a session it | |
| 135 | + // will not use. | |
| 136 | + if len(s.opts.Models) > 0 { | |
| 137 | + resp.Meta = map[string]any{acp.ModelStateMeta: s.modelState()} | |
| 138 | + } | |
| 139 | + return resp, nil | |
| 140 | + } | |
| 141 | + | |
| 142 | + func (s *Server) modelState() *acp.SessionModelState { | |
| 143 | + if len(s.opts.Models) == 0 { | |
| 144 | + return nil | |
| 145 | + } | |
| 146 | + return &acp.SessionModelState{ | |
| 147 | + CurrentModelID: s.opts.DefaultModel, | |
| 148 | + AvailableModels: s.opts.Models, | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 152 | + // Authenticate always succeeds. | |
| 153 | + // | |
| 154 | + // Codey takes its credential from the environment before this process starts, | |
| 155 | + // so there is nothing to log in to and no method id worth checking. Clients | |
| 156 | + // send this unconditionally and do not catch a failure, so an error here would | |
| 157 | + // be a session that never opens, over a question we were never asking. | |
| 158 | + func (s *Server) Authenticate(context.Context, acp.AuthenticateRequest) error { return nil } | |
| 159 | + | |
| 160 | + // NewSession opens a conversation rooted where the client asked. | |
| 161 | + func (s *Server) NewSession(_ context.Context, req acp.NewSessionRequest) (acp.NewSessionResponse, error) { | |
| 162 | + if req.Cwd == "" { | |
| 163 | + return acp.NewSessionResponse{}, acp.Errorf(acp.CodeInvalidParams, "session/new needs a cwd") | |
| 164 | + } | |
| 165 | + | |
| 166 | + sess := &session{ | |
| 167 | + id: newSessionID(), | |
| 168 | + root: req.Cwd, | |
| 169 | + state: agent.NewState(), | |
| 170 | + model: s.opts.DefaultModel, | |
| 171 | + } | |
| 172 | + sess.emit = newEmitter(notifier(s.conn, sess.id)) | |
| 173 | + sess.policy = approval.New(sess.promptFor(s.conn, s.opts.Permission)) | |
| 174 | + sess.policy.YOLO = s.opts.Permission == ModeFullAccess | |
| 175 | + | |
| 176 | + if err := s.build(sess); err != nil { | |
| 177 | + return acp.NewSessionResponse{}, err | |
| 178 | + } | |
| 179 | + | |
| 180 | + s.mu.Lock() | |
| 181 | + s.sessions[sess.id] = sess | |
| 182 | + s.mu.Unlock() | |
| 183 | + | |
| 184 | + resp := acp.NewSessionResponse{SessionID: sess.id} | |
| 185 | + if state := s.modelState(); state != nil { | |
| 186 | + state.CurrentModelID = sess.model | |
| 187 | + resp.Models = state | |
| 188 | + } | |
| 189 | + return resp, nil | |
| 190 | + } | |
| 191 | + | |
| 192 | + // build attaches an agent to a session, or replaces the one it has. The caller | |
| 193 | + // holds whatever lock the session needs. | |
| 194 | + func (s *Server) build(sess *session) error { | |
| 195 | + a, err := s.opts.NewAgent(sess.root, sess.model, sess.state) | |
| 196 | + if err != nil { | |
| 197 | + return acp.Errorf(acp.CodeInternal, "cannot start an agent in %s: %v", sess.root, err) | |
| 198 | + } | |
| 199 | + a.Approve = sess.policy | |
| 200 | + a.Observe = sess.emit.handle | |
| 201 | + sess.agent = a | |
| 202 | + return nil | |
| 203 | + } | |
| 204 | + | |
| 205 | + // Prompt runs one turn. | |
| 206 | + func (s *Server) Prompt(ctx context.Context, req acp.PromptRequest) (acp.PromptResponse, error) { | |
| 207 | + sess, err := s.lookup(req.SessionID) | |
| 208 | + if err != nil { | |
| 209 | + return acp.PromptResponse{}, err | |
| 210 | + } | |
| 211 | + | |
| 212 | + blocks := promptBlocks(req.Prompt) | |
| 213 | + if len(blocks) == 0 { | |
| 214 | + return acp.PromptResponse{StopReason: acp.StopEndTurn}, nil | |
| 215 | + } | |
| 216 | + | |
| 217 | + sess.mu.Lock() | |
| 218 | + defer sess.mu.Unlock() | |
| 219 | + | |
| 220 | + turnCtx := sess.begin(ctx) | |
| 221 | + defer sess.end() | |
| 222 | + | |
| 223 | + _, runErr := sess.agent.RunBlocks(turnCtx, blocks) | |
| 224 | + | |
| 225 | + // Whatever happened, say the last of what was written before reporting how | |
| 226 | + // the turn ended. A buffer dropped on the way out is a sentence the user | |
| 227 | + // never sees. | |
| 228 | + sess.emit.Flush() | |
| 229 | + | |
| 230 | + switch { | |
| 231 | + case sess.wasCancelled(), errors.Is(runErr, context.Canceled): | |
| 232 | + // Not an error: the session is intact and the next message can correct | |
| 233 | + // course. That is the whole point of a stop button. | |
| 234 | + return acp.PromptResponse{StopReason: acp.StopCancelled}, nil | |
| 235 | + | |
| 236 | + case errors.Is(runErr, agent.ErrMaxTurns): | |
| 237 | + sess.emit.chunk(acp.UpdateAgentMessageChunk, runErr.Error()) | |
| 238 | + return acp.PromptResponse{StopReason: acp.StopMaxTurnRequests}, nil | |
| 239 | + | |
| 240 | + case runErr != nil: | |
| 241 | + // The failure goes in the transcript rather than into a protocol error | |
| 242 | + // alone, because a protocol error is a line in the client's log and | |
| 243 | + // this is something the user has to read to know what to do next. | |
| 244 | + sess.emit.chunk(acp.UpdateAgentMessageChunk, "codey: "+runErr.Error()) | |
| 245 | + return acp.PromptResponse{StopReason: acp.StopRefusal}, nil | |
| 246 | + } | |
| 247 | + | |
| 248 | + return acp.PromptResponse{StopReason: acp.StopEndTurn}, nil | |
| 249 | + } | |
| 250 | + | |
| 251 | + // Cancel stops the turn in flight. An unknown session is not an error worth | |
| 252 | + // reporting: a stop arriving after the session closed asks for something that | |
| 253 | + // already happened. | |
| 254 | + func (s *Server) Cancel(_ context.Context, params acp.CancelParams) { | |
| 255 | + if sess, err := s.lookup(params.SessionID); err == nil { | |
| 256 | + sess.stop() | |
| 257 | + } | |
| 258 | + } | |
| 259 | + | |
| 260 | + // Extra answers the methods outside the core set. | |
| 261 | + func (s *Server) Extra(_ context.Context, method string, params json.RawMessage) (any, error) { | |
| 262 | + switch method { | |
| 263 | + case "session/set_model": | |
| 264 | + var req acp.SetModelRequest | |
| 265 | + if err := json.Unmarshal(params, &req); err != nil { | |
| 266 | + return nil, acp.Errorf(acp.CodeInvalidParams, "cannot decode params: %v", err) | |
| 267 | + } | |
| 268 | + sess, err := s.lookup(req.SessionID) | |
| 269 | + if err != nil { | |
| 270 | + return nil, err | |
| 271 | + } | |
| 272 | + sess.mu.Lock() | |
| 273 | + defer sess.mu.Unlock() | |
| 274 | + if req.ModelID == sess.model { | |
| 275 | + return struct{}{}, nil | |
| 276 | + } | |
| 277 | + sess.model = req.ModelID | |
| 278 | + // The conversation comes along: only the agent around it is replaced. | |
| 279 | + if err := s.build(sess); err != nil { | |
| 280 | + return nil, err | |
| 281 | + } | |
| 282 | + return struct{}{}, nil | |
| 283 | + | |
| 284 | + case "session/set_config_option": | |
| 285 | + // Accepted and ignored. Some clients send one unconditionally on every | |
| 286 | + // model selection, and refusing an option we never advertised would | |
| 287 | + // fail a turn over a setting we do not have. | |
| 288 | + return struct{}{}, nil | |
| 289 | + | |
| 290 | + default: | |
| 291 | + return nil, acp.Errorf(acp.CodeMethodNotFound, "unknown method %q", method) | |
| 292 | + } | |
| 293 | + } | |
| 294 | + | |
| 295 | + func (s *Server) lookup(id string) (*session, error) { | |
| 296 | + s.mu.Lock() | |
| 297 | + defer s.mu.Unlock() | |
| 298 | + sess, ok := s.sessions[id] | |
| 299 | + if !ok { | |
| 300 | + return nil, acp.Errorf(acp.CodeInvalidParams, "no such session %q", id) | |
| 301 | + } | |
| 302 | + return sess, nil | |
| 303 | + } | |
| 304 | + | |
| 305 | + // promptBlocks turns the client's content blocks into the agent's. | |
| 306 | + // | |
| 307 | + // Text and images pass through. Anything else is named rather than dropped: a | |
| 308 | + // client that attached a file we cannot decode has told the user it sent | |
| 309 | + // something, and a model that never hears about it will answer a question | |
| 310 | + // nobody asked. | |
| 311 | + func promptBlocks(blocks []acp.ContentBlock) []llm.ContentBlock { | |
| 312 | + var out []llm.ContentBlock | |
| 313 | + var text strings.Builder | |
| 314 | + | |
| 315 | + for _, b := range blocks { | |
| 316 | + switch b.Type { | |
| 317 | + case "text": | |
| 318 | + if b.Text != "" { | |
| 319 | + if text.Len() > 0 { | |
| 320 | + text.WriteString("\n") | |
| 321 | + } | |
| 322 | + text.WriteString(b.Text) | |
| 323 | + } | |
| 324 | + case "image": | |
| 325 | + if b.Data != "" && b.MimeType != "" { | |
| 326 | + // Text so far goes in first, so the image lands where it was | |
| 327 | + // written rather than at the end of the message. | |
| 328 | + if t := strings.TrimSpace(text.String()); t != "" { | |
| 329 | + out = append(out, llm.TextBlock(t)) | |
| 330 | + text.Reset() | |
| 331 | + } | |
| 332 | + out = append(out, llm.ImageBlock(b.MimeType, b.Data)) | |
| 333 | + } | |
| 334 | + default: | |
| 335 | + if name := attachmentName(b); name != "" { | |
| 336 | + if text.Len() > 0 { | |
| 337 | + text.WriteString("\n") | |
| 338 | + } | |
| 339 | + fmt.Fprintf(&text, "[attached %s: %s]", b.Type, name) | |
| 340 | + } | |
| 341 | + } | |
| 342 | + } | |
| 343 | + | |
| 344 | + if t := strings.TrimSpace(text.String()); t != "" { | |
| 345 | + out = append(out, llm.TextBlock(t)) | |
| 346 | + } | |
| 347 | + return out | |
| 348 | + } | |
| 349 | + | |
| 350 | + func attachmentName(b acp.ContentBlock) string { | |
| 351 | + switch { | |
| 352 | + case b.Name != "": | |
| 353 | + return b.Name | |
| 354 | + case b.URI != "": | |
| 355 | + return b.URI | |
| 356 | + default: | |
| 357 | + return "" | |
| 358 | + } | |
| 359 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.