Conflicts resolved by keeping both sides: master's hooks, diagnostics and image tool results alongside the branch's ACP invocation parsing and tool event IDs. The README gets both new sections, Hooks and Inside another editor. A policy test added on master still called the unexported describe, which the branch renamed to Describe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 26 changed files with 5976 additions and 28 deletions.
| @@ -1302,6 +1302,75 @@ the only one where pressing enter through the screens does something: the row | ||
| 1302 | 1302 | names the exact path before you answer, and the notice afterwards names the |
| 1303 | 1303 | file that was written. Uninstalling is `rm -r ~/.agents/skills/herdr`. |
| 1304 | 1304 | |
| 1305 | + ## Inside another editor | |
| 1306 | + | |
| 1307 | + Codey can be the agent behind somebody else's UI. It speaks the | |
| 1308 | + [Agent Client Protocol](https://agentclientprotocol.com) — JSON-RPC over stdin | |
| 1309 | + and stdout — so a control plane that drives Codex, Claude Code or Cursor can | |
| 1310 | + drive this instead: | |
| 1311 | + | |
| 1312 | + ```bash | |
| 1313 | + codey agent stdio | |
| 1314 | + ``` | |
| 1315 | + | |
| 1316 | + Nothing but protocol frames goes to stdout in that mode; diagnostics go to | |
| 1317 | + stderr. The connection ends when stdin does. | |
| 1318 | + | |
| 1319 | + ### T3 Code | |
| 1320 | + | |
| 1321 | + [T3 Code](https://t3.codes) compiles its providers in, so there is no Codey | |
| 1322 | + entry to select. There is, however, a binary path on every provider, and its | |
| 1323 | + Grok provider is a plain ACP agent — which means Codey can take that slot with | |
| 1324 | + no change to their source. In **Settings → Providers**, enable Grok, set | |
| 1325 | + **Binary path** to your `codey`, and rename the instance: | |
| 1326 | + | |
| 1327 | + ```json | |
| 1328 | + "providerInstances": { | |
| 1329 | + "codey": { | |
| 1330 | + "driver": "grok", | |
| 1331 | + "displayName": "Codey", | |
| 1332 | + "enabled": true, | |
| 1333 | + "config": { "binaryPath": "/path/to/codey" }, | |
| 1334 | + "environment": [ | |
| 1335 | + { "name": "ANTHROPIC_API_KEY", "value": "sk-ant-...", "sensitive": true } | |
| 1336 | + ] | |
| 1337 | + } | |
| 1338 | + } | |
| 1339 | + ``` | |
| 1340 | + | |
| 1341 | + That file is `~/.t3/userdata/settings.json`; the settings panel writes the same | |
| 1342 | + thing. The per-instance environment is how Codey gets a credential without | |
| 1343 | + inheriting whatever the desktop app was launched with — and without it, the | |
| 1344 | + provider card will say Codey is installed and not logged in, which is true. | |
| 1345 | + | |
| 1346 | + Threads stream text and reasoning, fill a tool row in as the command talks, | |
| 1347 | + raise an approval prompt you answer in T3 Code's own panel, and stop when you | |
| 1348 | + press stop. The model picker is filled from the endpoint Codey is pointed at. | |
| 1349 | + | |
| 1350 | + [Subagents](#subagents) work too. Each one gets a row of its own that opens | |
| 1351 | + when the child starts, fills in as it works, and closes with its report, and | |
| 1352 | + the reports reach the parent as messages exactly as they do in the TUI. A turn | |
| 1353 | + stays open while its children are still working — their output belongs inside | |
| 1354 | + the turn that started them, and a thread that looked finished while four agents | |
| 1355 | + were still running would be lying. `-no-agents` in the launch arguments turns | |
| 1356 | + them off. | |
| 1357 | + | |
| 1358 | + What does not work is resuming a thread after a restart: Codey does not | |
| 1359 | + advertise session loading, so T3 Code will not offer it. | |
| 1360 | + | |
| 1361 | + The card will say Grok and show the xAI glyph. The display name and accent | |
| 1362 | + colour are yours to set; the icon is not. That is the price of occupying a slot | |
| 1363 | + instead of adding one. | |
| 1364 | + | |
| 1365 | + Two invocations exist only for that provider card: `codey --version`, and | |
| 1366 | + `codey models`, which prints whether there is a credential and what the | |
| 1367 | + endpoint serves. Both are shaped for the regular expressions on the other end | |
| 1368 | + rather than for a person. | |
| 1369 | + | |
| 1370 | + `docs/superpowers/specs/2026-09-19-t3code-provider-design.md` is the research | |
| 1371 | + this is built on: what every T3 Code provider requires of a binary, and why the | |
| 1372 | + Grok slot is the one that fits. | |
| 1373 | + | |
| 1305 | 1374 | ## Evals |
| 1306 | 1375 | |
| 1307 | 1376 | ```bash |
| @@ -1385,12 +1454,14 @@ internal/herdr reporting agent state to the herdr pane Codey is running in | ||
| 1385 | 1454 | internal/attach pictures pasted into a prompt: clipboard, files, markers |
| 1386 | 1455 | internal/metrics JSONL event log |
| 1387 | 1456 | internal/ui line-based terminal rendering |
| 1457 | + internal/acp Agent Client Protocol: the wire, and its vocabulary | |
| 1458 | + internal/serve Codey as an agent another program drives, over ACP | |
| 1388 | 1459 | internal/tui full-screen Bubble Tea front end, and the setup wizard |
| 1389 | 1460 | evals task suite and runner |
| 1390 | 1461 | ``` |
| 1391 | 1462 | |
| 1392 | 1463 | Two rules hold the shape: `agent` never imports `ui` or `tui`, so the eval |
| 1393 | - runner can drive the loop headless; and every tool returns `(string, error)` | |
| 1464 | + runner and the ACP front end can drive the loop headless; and every tool returns `(string, error)` | |
| 1394 | 1465 | where the error is text the model reads, not an exception Codey swallows. |
| 1395 | 1466 | A failed edit is a normal event in the transcript. |
| 1396 | 1467 | |
| @@ -0,0 +1,301 @@ | ||
| 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/subagent" | |
| 32 | + "codey/internal/tools" | |
| 33 | + ) | |
| 34 | + | |
| 35 | + // invKind is which of the four things this command line is. | |
| 36 | + type invKind int | |
| 37 | + | |
| 38 | + const ( | |
| 39 | + invNormal invKind = iota | |
| 40 | + invVersion | |
| 41 | + invModels | |
| 42 | + invAgent | |
| 43 | + ) | |
| 44 | + | |
| 45 | + // invocation is the command line, read. | |
| 46 | + type invocation struct { | |
| 47 | + Kind invKind | |
| 48 | + // Permission is the approval floor an agent run was launched with. | |
| 49 | + Permission serve.PermissionMode | |
| 50 | + // Rest is what is left for ordinary flag parsing once the words that | |
| 51 | + // belong to the subcommand are taken out. | |
| 52 | + Rest []string | |
| 53 | + } | |
| 54 | + | |
| 55 | + // scanArgs reads the command line before the flag package sees it. | |
| 56 | + // | |
| 57 | + // It has to, for two reasons. `--version` is not one of our flags, and flag | |
| 58 | + // parsing would exit(2) on it rather than answer. And the agent invocation | |
| 59 | + // splits its flags around the subcommand — `--permission-mode default agent | |
| 60 | + // stdio`, but `agent --always-approve stdio` — which no flag package parses, | |
| 61 | + // because it is not one grammar but a client's habit. | |
| 62 | + // | |
| 63 | + // Everything unrecognised is handed on rather than rejected. The launch | |
| 64 | + // arguments are a free-text field in somebody's settings panel, and the flags | |
| 65 | + // Codey does understand should keep working when they arrive through it. | |
| 66 | + func scanArgs(args []string) invocation { | |
| 67 | + // The probes are exact: the client runs the binary with this one word and | |
| 68 | + // nothing else. Requiring that keeps `-C models` from being mistaken for | |
| 69 | + // a request to list them. | |
| 70 | + if len(args) == 1 { | |
| 71 | + switch args[0] { | |
| 72 | + case "--version", "-version": | |
| 73 | + return invocation{Kind: invVersion} | |
| 74 | + case "models": | |
| 75 | + return invocation{Kind: invModels} | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + var ( | |
| 80 | + sawAgent, sawStdio, always bool | |
| 81 | + mode string | |
| 82 | + rest []string | |
| 83 | + ) | |
| 84 | + for i := 0; i < len(args); i++ { | |
| 85 | + a := args[i] | |
| 86 | + switch { | |
| 87 | + case a == "agent": | |
| 88 | + sawAgent = true | |
| 89 | + case a == "stdio": | |
| 90 | + sawStdio = true | |
| 91 | + case a == "--always-approve", a == "-always-approve": | |
| 92 | + always = true | |
| 93 | + case a == "--permission-mode", a == "-permission-mode": | |
| 94 | + if i+1 < len(args) { | |
| 95 | + mode = args[i+1] | |
| 96 | + i++ | |
| 97 | + } | |
| 98 | + case strings.HasPrefix(a, "--permission-mode="): | |
| 99 | + mode = strings.TrimPrefix(a, "--permission-mode=") | |
| 100 | + case strings.HasPrefix(a, "-permission-mode="): | |
| 101 | + mode = strings.TrimPrefix(a, "-permission-mode=") | |
| 102 | + default: | |
| 103 | + rest = append(rest, a) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + if !sawAgent || !sawStdio { | |
| 108 | + // Not an agent run after all, so nothing was ours to consume: hand the | |
| 109 | + // line back exactly as it came. | |
| 110 | + return invocation{Kind: invNormal, Rest: args} | |
| 111 | + } | |
| 112 | + return invocation{Kind: invAgent, Permission: permissionMode(mode, always), Rest: rest} | |
| 113 | + } | |
| 114 | + | |
| 115 | + // permissionMode maps the client's vocabulary onto ours. An unrecognised mode | |
| 116 | + // is the strictest one: a name we do not know is not a reason to stop asking. | |
| 117 | + func permissionMode(mode string, always bool) serve.PermissionMode { | |
| 118 | + if always { | |
| 119 | + return serve.ModeFullAccess | |
| 120 | + } | |
| 121 | + switch mode { | |
| 122 | + case "acceptEdits": | |
| 123 | + return serve.ModeAcceptEdits | |
| 124 | + case "auto": | |
| 125 | + return serve.ModeAuto | |
| 126 | + case "bypassPermissions", "full-access": | |
| 127 | + return serve.ModeFullAccess | |
| 128 | + default: | |
| 129 | + return serve.ModeAsk | |
| 130 | + } | |
| 131 | + } | |
| 132 | + | |
| 133 | + // printVersion answers the version probe. | |
| 134 | + // | |
| 135 | + // The line has to contain something matching \bv?(\d+\.\d+\.\d+)\b, because | |
| 136 | + // that is the regular expression on the other end. Anything else reads as an | |
| 137 | + // installed binary that cannot run. | |
| 138 | + func printVersion(w io.Writer) { | |
| 139 | + fmt.Fprintf(w, "codey %s\n", version) | |
| 140 | + } | |
| 141 | + | |
| 142 | + // modelsTimeout bounds the listing. The probe that runs this has its own, | |
| 143 | + // shorter patience, and a listing that has not arrived by then is better | |
| 144 | + // reported as the one model we know about than not reported at all. | |
| 145 | + const modelsTimeout = 8 * time.Second | |
| 146 | + | |
| 147 | + // printModels answers the model-and-login probe. | |
| 148 | + // | |
| 149 | + // The format is prose because the reader is a regular expression: a line | |
| 150 | + // saying whether we are logged in, then one bullet per model with the default | |
| 151 | + // marked. It exits zero even when it has nothing good to say — a non-zero exit | |
| 152 | + // makes the client discard the whole listing, including the part that was | |
| 153 | + // true. | |
| 154 | + func printModels(w io.Writer, client *llm.Client) { | |
| 155 | + if !client.HasCredentials() { | |
| 156 | + fmt.Fprintln(w, "You are not logged in. Set ANTHROPIC_API_KEY, or log in with Claude Code.") | |
| 157 | + return | |
| 158 | + } | |
| 159 | + fmt.Fprintf(w, "You are logged in to %s.\n", client.BaseURL) | |
| 160 | + | |
| 161 | + ctx, cancel := context.WithTimeout(context.Background(), modelsTimeout) | |
| 162 | + defer cancel() | |
| 163 | + | |
| 164 | + models, err := client.ListModels(ctx) | |
| 165 | + if err != nil || len(models) == 0 { | |
| 166 | + // An endpoint that does not answer still serves the model we were | |
| 167 | + // configured with, and offering that beats offering nothing. | |
| 168 | + fmt.Fprintf(w, "* %s (default)\n", client.Model()) | |
| 169 | + return | |
| 170 | + } | |
| 171 | + | |
| 172 | + for _, m := range sortedModels(models) { | |
| 173 | + if m.ID == client.Model() { | |
| 174 | + fmt.Fprintf(w, "* %s (default)\n", m.ID) | |
| 175 | + continue | |
| 176 | + } | |
| 177 | + fmt.Fprintf(w, "- %s\n", m.ID) | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + // sortedModels puts the newest first, which is the order a picker should show | |
| 182 | + // and not the order every endpoint answers in. | |
| 183 | + func sortedModels(models []llm.Model) []llm.Model { | |
| 184 | + out := append([]llm.Model(nil), models...) | |
| 185 | + sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) | |
| 186 | + return out | |
| 187 | + } | |
| 188 | + | |
| 189 | + // acpDeps is what an agent run borrows from the ordinary startup path. Nothing | |
| 190 | + // here is built for this mode: it is the same client, the same event log, the | |
| 191 | + // same options and the same tool factory the TUI gets, which is the point — | |
| 192 | + // an agent driven through a pipe should be able to do exactly what an agent | |
| 193 | + // driven through a keyboard can. | |
| 194 | + type acpDeps struct { | |
| 195 | + Permission serve.PermissionMode | |
| 196 | + Client *llm.Client | |
| 197 | + // NoAgents leaves the subagent tools out, so the agent works alone. | |
| 198 | + NoAgents bool | |
| 199 | + // ClientOpts rebuilds the client for another model. The model lives on the | |
| 200 | + // client rather than on the agent, so a session that selects one needs a | |
| 201 | + // client of its own. | |
| 202 | + ClientOpts []llm.Option | |
| 203 | + Log *metrics.Logger | |
| 204 | + Base agent.Options | |
| 205 | + // Tools is main's factory: a root in, a tool-set builder out. | |
| 206 | + Tools func(root string) func(*agent.State, *metrics.Logger) *tools.Registry | |
| 207 | + } | |
| 208 | + | |
| 209 | + // serveACP runs the agent over stdin and stdout until the client hangs up. | |
| 210 | + // | |
| 211 | + // Nothing else in this process may write to stdout from here on. The stream is | |
| 212 | + // the protocol, and one stray line desynchronises a parser that has no way to | |
| 213 | + // resynchronise. | |
| 214 | + func serveACP(ctx context.Context, deps acpDeps) error { | |
| 215 | + found := listModels(ctx, deps.Client) | |
| 216 | + | |
| 217 | + // What a subagent runs under is its parent's settings without the model | |
| 218 | + // listing: a child never chooses a model for a child of its own, and a | |
| 219 | + // paragraph about a choice it will not make is one it pays for on every | |
| 220 | + // request. The parent does get the listing, because it is the one that | |
| 221 | + // chooses. | |
| 222 | + subBase := deps.Base | |
| 223 | + base := deps.Base | |
| 224 | + base.Models = found | |
| 225 | + | |
| 226 | + srv := serve.New(serve.Options{ | |
| 227 | + Version: version, | |
| 228 | + Permission: deps.Permission, | |
| 229 | + Models: offeredModels(found), | |
| 230 | + DefaultModel: deps.Client.Model(), | |
| 231 | + | |
| 232 | + NewAgent: func(req serve.AgentRequest) (serve.AgentSet, error) { | |
| 233 | + client := deps.Client | |
| 234 | + if req.Model != "" && req.Model != client.Model() { | |
| 235 | + client = llm.New(append(deps.ClientOpts, llm.WithModel(req.Model))...) | |
| 236 | + } | |
| 237 | + | |
| 238 | + // One factory for the session and for every child of it: an agent | |
| 239 | + // that could reach further than its parent would be a way around | |
| 240 | + // the parent's limits. | |
| 241 | + newTools := deps.Tools(req.Root) | |
| 242 | + reg := newTools(req.State, deps.Log) | |
| 243 | + | |
| 244 | + set := serve.AgentSet{} | |
| 245 | + if !deps.NoAgents { | |
| 246 | + sup := subagent.New(ctx, subagent.Options{ | |
| 247 | + Client: client, | |
| 248 | + Log: deps.Log, | |
| 249 | + Policy: req.Approve, | |
| 250 | + Base: subBase, | |
| 251 | + Tools: newTools, | |
| 252 | + }) | |
| 253 | + reg.Register(sup.Tools()...) | |
| 254 | + set.Agents = sup | |
| 255 | + } | |
| 256 | + | |
| 257 | + set.Agent = agent.New(client, reg, req.State, deps.Log, base) | |
| 258 | + return set, nil | |
| 259 | + }, | |
| 260 | + }) | |
| 261 | + | |
| 262 | + deps.Log.Log(metrics.Event{Type: "acp_start", Extra: map[string]any{ | |
| 263 | + "permission": string(deps.Permission), "model": deps.Client.Model(), | |
| 264 | + }}) | |
| 265 | + | |
| 266 | + return srv.Run(ctx, os.Stdin, os.Stdout) | |
| 267 | + } | |
| 268 | + | |
| 269 | + // listModels asks the endpoint what it serves. An endpoint that will not say | |
| 270 | + // leaves the list empty, which is the honest answer: no list beats a made-up | |
| 271 | + // one, and both the picker and the agent's own model choices degrade to the | |
| 272 | + // model this process was configured with. | |
| 273 | + func listModels(ctx context.Context, client *llm.Client) []llm.Model { | |
| 274 | + listCtx, cancel := context.WithTimeout(ctx, modelsTimeout) | |
| 275 | + defer cancel() | |
| 276 | + | |
| 277 | + found, err := client.ListModels(listCtx) | |
| 278 | + if err != nil { | |
| 279 | + return nil | |
| 280 | + } | |
| 281 | + return sortedModels(found) | |
| 282 | + } | |
| 283 | + | |
| 284 | + // offeredModels is that list as the client's model picker wants it. | |
| 285 | + func offeredModels(found []llm.Model) []acp.ModelInfo { | |
| 286 | + if len(found) == 0 { | |
| 287 | + return nil | |
| 288 | + } | |
| 289 | + out := make([]acp.ModelInfo, 0, len(found)) | |
| 290 | + for _, m := range found { | |
| 291 | + out = append(out, acp.ModelInfo{ModelID: m.ID, Name: displayName(m)}) | |
| 292 | + } | |
| 293 | + return out | |
| 294 | + } | |
| 295 | + | |
| 296 | + func displayName(m llm.Model) string { | |
| 297 | + if m.DisplayName != "" { | |
| 298 | + return m.DisplayName | |
| 299 | + } | |
| 300 | + return m.ID | |
| 301 | + } | |
| @@ -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 | + } | |
| @@ -24,6 +24,7 @@ import ( | ||
| 24 | 24 | "codey/internal/hooks" |
| 25 | 25 | "codey/internal/llm" |
| 26 | 26 | "codey/internal/metrics" |
| 27 | + "codey/internal/serve" | |
| 27 | 28 | "codey/internal/skills" |
| 28 | 29 | "codey/internal/statedir" |
| 29 | 30 | "codey/internal/subagent" |
| @@ -90,7 +91,18 @@ func run() error { | ||
| 90 | 91 | noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename) |
| 91 | 92 | noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr") |
| 92 | 93 | noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it") |
| 93 | - flag.Parse() | |
| 94 | + | |
| 95 | + // Read the line before the flag package does. Three invocations belong to | |
| 96 | + // a program driving Codey rather than to a person — see agentcmd.go — and | |
| 97 | + // two of them are not spelled in a grammar flag parsing can read. | |
| 98 | + inv := scanArgs(os.Args[1:]) | |
| 99 | + if inv.Kind == invVersion { | |
| 100 | + printVersion(os.Stdout) | |
| 101 | + return nil | |
| 102 | + } | |
| 103 | + if err := flag.CommandLine.Parse(inv.Rest); err != nil { | |
| 104 | + return err | |
| 105 | + } | |
| 94 | 106 | |
| 95 | 107 | root, err := filepath.Abs(*workdir) |
| 96 | 108 | if err != nil { |
| @@ -122,6 +134,10 @@ func run() error { | ||
| 122 | 134 | llm.WithAuthToken(*authToken), |
| 123 | 135 | } |
| 124 | 136 | client := llm.New(opts...) |
| 137 | + // clientOpts is the list the live client was actually built from, kept so | |
| 138 | + // a second client on another model can be built the same way. The Claude | |
| 139 | + // Code branch below is the only thing that adds to it. | |
| 140 | + clientOpts := opts | |
| 125 | 141 | |
| 126 | 142 | home, err := os.UserHomeDir() |
| 127 | 143 | if err != nil { |
| @@ -144,7 +160,8 @@ func run() error { | ||
| 144 | 160 | } |
| 145 | 161 | if src != nil { |
| 146 | 162 | ccSource = src |
| 147 | - client = llm.New(append(opts, llm.WithTokenSource(src.Token))...) | |
| 163 | + clientOpts = append(opts, llm.WithTokenSource(src.Token)) | |
| 164 | + client = llm.New(clientOpts...) | |
| 148 | 165 | // Not expected to ever trigger: resolveClaudeCode already checked |
| 149 | 166 | // the endpoint before we agreed to borrow this credential, but |
| 150 | 167 | // that check ran against the client we had before this rebuild. |
| @@ -158,6 +175,15 @@ func run() error { | ||
| 158 | 175 | systemPrefix = agent.ClaudeCodeIdentity |
| 159 | 176 | } |
| 160 | 177 | } |
| 178 | + // The login-and-models probe answers here, where a borrowed Claude Code | |
| 179 | + // credential already counts as being logged in, and before the check | |
| 180 | + // below: "not logged in" is the useful answer to that question, not an | |
| 181 | + // error exit that tells the asker nothing. | |
| 182 | + if inv.Kind == invModels { | |
| 183 | + printModels(os.Stdout, client) | |
| 184 | + return nil | |
| 185 | + } | |
| 186 | + | |
| 161 | 187 | if !client.HasCredentials() { |
| 162 | 188 | return errNoCredentials |
| 163 | 189 | } |
| @@ -422,6 +448,29 @@ func run() error { | ||
| 422 | 448 | SystemPrefix: systemPrefix, |
| 423 | 449 | } |
| 424 | 450 | |
| 451 | + // An agent run hands the whole thing over to the protocol and never comes | |
| 452 | + // back. It goes here, after everything a session needs has been built and | |
| 453 | + // before the front ends that assume a person: no subagent supervisor, | |
| 454 | + // because their reports need somewhere to be shown, and no question tool, | |
| 455 | + // because the protocol has no box to put a question in. | |
| 456 | + // | |
| 457 | + // Nothing above this line has written to stdout. Nothing below it may. | |
| 458 | + if inv.Kind == invAgent { | |
| 459 | + if acceptAll || inv.Permission == serve.ModeFullAccess { | |
| 460 | + inv.Permission = serve.ModeFullAccess | |
| 461 | + notice("no approval prompts. Use a throwaway clone or a container.") | |
| 462 | + } | |
| 463 | + return serveACP(context.Background(), acpDeps{ | |
| 464 | + Permission: inv.Permission, | |
| 465 | + NoAgents: *noAgents, | |
| 466 | + Client: client, | |
| 467 | + ClientOpts: clientOpts, | |
| 468 | + Log: log, | |
| 469 | + Base: base, | |
| 470 | + Tools: toolsIn, | |
| 471 | + }) | |
| 472 | + } | |
| 473 | + | |
| 425 | 474 | // Subagents are a TUI feature: their reports come back as messages the |
| 426 | 475 | // front end puts into the conversation, and the line-based REPL has |
| 427 | 476 | // nowhere to show four agents working at once. Offering the tools where |
| @@ -0,0 +1,492 @@ | ||
| 1 | + # Codey inside T3 Code — implementation plan | |
| 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 is now mostly done as well: reasoning streams as `agent_thought_chunk`, | |
| 29 | + > a tool's output streams into its row while the command is still running | |
| 30 | + > (which the plan did not list at all, and is the gap you notice first), and | |
| 31 | + > subagents each get a row that opens, fills and closes with their report. | |
| 32 | + > What is left of it is session resume, `plan` updates and slash commands. | |
| 33 | + > | |
| 34 | + > See the `## Inside another editor` section of README.md for what shipped. | |
| 35 | + | |
| 36 | + > **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. | |
| 37 | + | |
| 38 | + **Goal:** Run Codey as an agent inside T3 Code without touching T3 Code's | |
| 39 | + source. The user enables the Grok provider in **Settings → Providers**, sets | |
| 40 | + **Binary path** to the `codey` binary, and gets Codey threads with streaming | |
| 41 | + text, visible tool calls, working approvals and a stop button. | |
| 42 | + | |
| 43 | + **Architecture:** Codey grows a third front end alongside the TUI and `-p`. | |
| 44 | + `internal/acp` owns the wire — newline-delimited JSON-RPC 2.0 over stdin and | |
| 45 | + stdout, with the handful of ACP methods T3 Code actually exercises. | |
| 46 | + `internal/serve` is the bridge: one `*agent.Agent` per ACP session, its | |
| 47 | + `Observe` callback translated into `session/update` notifications and its | |
| 48 | + `Approve` gate translated into `session/request_permission` round trips. | |
| 49 | + `cmd/codey` grows an `agent stdio` verb plus the two shell probes T3 Code runs | |
| 50 | + before it will show the provider as ready. Nothing in `internal/agent`, | |
| 51 | + `internal/llm` or `internal/tui` changes in phase 1. | |
| 52 | + | |
| 53 | + **Tech Stack:** Go 1.26, stdlib only — `encoding/json`, `bufio`, `context`, | |
| 54 | + `sync`. No ACP library exists for Go and the surface is five methods; hand- | |
| 55 | + rolling it is smaller than a dependency. | |
| 56 | + | |
| 57 | + **Spec:** `docs/superpowers/specs/2026-09-19-t3code-provider-design.md` — read | |
| 58 | + it first. It carries the evidence for why the Grok slot and not Codex's | |
| 59 | + app-server, and the exact field-level contract quoted out of T3 Code's source. | |
| 60 | + | |
| 61 | + ## Global Constraints | |
| 62 | + | |
| 63 | + - Go module is `codey`; import paths are `codey/internal/...`. | |
| 64 | + - **Stdout belongs to the protocol.** In `agent stdio` mode nothing may write | |
| 65 | + to stdout except the JSON-RPC encoder — no `fmt.Println`, no metrics logger, | |
| 66 | + no stray `log`. Diagnostics go to stderr, which T3 Code drains and (on the | |
| 67 | + Grok path) discards. A single stray line desynchronises the parser and the | |
| 68 | + thread dies with a decode error. | |
| 69 | + - **`jsonrpc: "2.0"` is mandatory on every message we send.** T3 Code's ACP | |
| 70 | + codec declares it `Schema.Literal("2.0")` on requests, notifications and | |
| 71 | + responses (`packages/effect-acp/src/_internal/shared.ts:83-103`). This is the | |
| 72 | + opposite of their Codex path, where the field must be absent — do not carry | |
| 73 | + habits between the two. | |
| 74 | + - **A notification must have no `id` key.** Not `null`, absent. | |
| 75 | + - **Never leave an inbound request unanswered**, including methods we do not | |
| 76 | + implement: answer `-32601`. T3 Code answers our requests on its read loop, so | |
| 77 | + a hang on either side stalls the whole session. | |
| 78 | + - T3 Code's inbound notification queue is `sliding(32)` | |
| 79 | + (`packages/effect-acp/src/protocol.ts:89`). Coalesce text deltas rather than | |
| 80 | + emitting one notification per token. | |
| 81 | + - `internal/acp` imports no other `internal` package and knows nothing about | |
| 82 | + agents: it is a JSON-RPC peer with ACP's types. `internal/serve` may import | |
| 83 | + `internal/acp`, `internal/agent`, `internal/tools`, `internal/approval`; | |
| 84 | + nothing imports `internal/serve` back except `cmd/codey`. | |
| 85 | + - Every test is offline and in-process: a `net.Pipe` or an `io.Pipe` pair, no | |
| 86 | + subprocess, no network, no sleeping on wall-clock time. | |
| 87 | + - New exported identifiers get a doc comment in the existing house style. | |
| 88 | + - Run `gofmt -w` on every file you touch, and `go test ./...` before each task | |
| 89 | + is ticked off. | |
| 90 | + | |
| 91 | + --- | |
| 92 | + | |
| 93 | + ## Phase 1 — a text turn appears in T3 Code | |
| 94 | + | |
| 95 | + ### Task 1: `internal/acp` — types and the JSON-RPC peer | |
| 96 | + | |
| 97 | + **Files:** | |
| 98 | + - Create: `internal/acp/wire.go`, `internal/acp/types.go`, `internal/acp/conn.go`, `internal/acp/conn_test.go` | |
| 99 | + | |
| 100 | + **Interfaces:** | |
| 101 | + - Consumes: nothing. | |
| 102 | + - Produces: | |
| 103 | + ```go | |
| 104 | + // wire.go | |
| 105 | + type Message struct { | |
| 106 | + JSONRPC string `json:"jsonrpc"` | |
| 107 | + ID *json.RawMessage `json:"id,omitempty"` | |
| 108 | + Method string `json:"method,omitempty"` | |
| 109 | + Params json.RawMessage `json:"params,omitempty"` | |
| 110 | + Result json.RawMessage `json:"result,omitempty"` | |
| 111 | + Error *Error `json:"error,omitempty"` | |
| 112 | + } | |
| 113 | + type Error struct { Code int `json:"code"`; Message string `json:"message"`; Data any `json:"data,omitempty"` } | |
| 114 | + | |
| 115 | + const ( | |
| 116 | + CodeParse = -32700; CodeInvalidRequest = -32600 | |
| 117 | + CodeMethodNotFound = -32601; CodeInvalidParams = -32602; CodeInternal = -32603 | |
| 118 | + ) | |
| 119 | + | |
| 120 | + // conn.go | |
| 121 | + type Handler interface { | |
| 122 | + Initialize(context.Context, InitializeRequest) (InitializeResponse, error) | |
| 123 | + Authenticate(context.Context, AuthenticateRequest) error | |
| 124 | + NewSession(context.Context, NewSessionRequest) (NewSessionResponse, error) | |
| 125 | + Prompt(context.Context, PromptRequest) (PromptResponse, error) | |
| 126 | + Cancel(context.Context, CancelParams) | |
| 127 | + } | |
| 128 | + type Conn struct{ /* unexported */ } | |
| 129 | + func NewConn(r io.Reader, w io.Writer, h Handler) *Conn | |
| 130 | + func (c *Conn) Serve(ctx context.Context) error // returns nil on clean EOF | |
| 131 | + func (c *Conn) Notify(ctx context.Context, method string, params any) error | |
| 132 | + func (c *Conn) Request(ctx context.Context, method string, params any, out any) error | |
| 133 | + ``` | |
| 134 | + | |
| 135 | + `types.go` holds the ACP structs, spelled exactly as the spec quotes them: | |
| 136 | + `InitializeRequest{ProtocolVersion int, ClientCapabilities, ClientInfo}`, | |
| 137 | + `InitializeResponse{ProtocolVersion int, AgentInfo, AgentCapabilities, AuthMethods}`, | |
| 138 | + `AuthenticateRequest{MethodID string}`, `NewSessionRequest{Cwd string, | |
| 139 | + McpServers []json.RawMessage, AdditionalDirectories []string}`, | |
| 140 | + `NewSessionResponse{SessionID string, Models *ModelState}`, | |
| 141 | + `PromptRequest{SessionID string, Prompt []ContentBlock}`, | |
| 142 | + `PromptResponse{StopReason string}`, `SessionNotification{SessionID string, | |
| 143 | + Update Update}`, `RequestPermissionParams/Result`. | |
| 144 | + `ContentBlock` is a tagged struct (`Type`, `Text`, `Data`, `MimeType`, `Path`) | |
| 145 | + — decode leniently, we only read `text`. | |
| 146 | + | |
| 147 | + **Behaviour that the tests pin down:** | |
| 148 | + | |
| 149 | + Reader goroutine owns the decoder; **each inbound request is dispatched on its | |
| 150 | + own goroutine** so that a `session/cancel` notification arriving mid-prompt is | |
| 151 | + seen immediately. A single mutex serialises writes. Outbound request ids are | |
| 152 | + integers from a counter; replies are correlated through a `map[string]chan` | |
| 153 | + keyed by `string(id)`. | |
| 154 | + | |
| 155 | + - [ ] **Step 1: Write the failing tests** | |
| 156 | + | |
| 157 | + `conn_test.go` drives a `Conn` over an `io.Pipe` pair with a stub handler | |
| 158 | + and asserts: every outbound line carries `"jsonrpc":"2.0"`; a notification | |
| 159 | + line has no `id` key at all (assert on the raw bytes, not on a decoded map); | |
| 160 | + a request for an unknown method comes back as `-32601` with the same `id`; a | |
| 161 | + handler returning an error becomes `-32603` and does not kill the connection; | |
| 162 | + malformed JSON on a line is answered `-32700` where an id can be recovered | |
| 163 | + and skipped otherwise, and the loop survives; `Request` blocks until the | |
| 164 | + matching response arrives and returns the decoded result; `Request` with a | |
| 165 | + cancelled context returns `ctx.Err()` and drops the pending entry; a | |
| 166 | + `session/cancel` notification is delivered while a `session/prompt` handler | |
| 167 | + is still running; clean EOF on the reader returns `nil` from `Serve`. | |
| 168 | + | |
| 169 | + - [ ] **Step 2: Implement `wire.go` and `types.go`** | |
| 170 | + | |
| 171 | + Encoding is one compact JSON object per line. `ID` is kept as | |
| 172 | + `json.RawMessage` so a string id and a numeric id both round-trip byte-exact. | |
| 173 | + | |
| 174 | + - [ ] **Step 3: Implement `conn.go`** | |
| 175 | + | |
| 176 | + - [ ] **Step 4: Verify** | |
| 177 | + | |
| 178 | + ```bash | |
| 179 | + gofmt -w internal/acp && go test ./internal/acp/... | |
| 180 | + ``` | |
| 181 | + | |
| 182 | + --- | |
| 183 | + | |
| 184 | + ### Task 2: `internal/serve` — the session bridge, text only | |
| 185 | + | |
| 186 | + **Files:** | |
| 187 | + - Create: `internal/serve/serve.go`, `internal/serve/session.go`, `internal/serve/update.go`, `internal/serve/serve_test.go` | |
| 188 | + | |
| 189 | + **Interfaces:** | |
| 190 | + - Consumes: `internal/acp`, `internal/agent`, `internal/tools`, `internal/metrics`. | |
| 191 | + - Produces: | |
| 192 | + ```go | |
| 193 | + type Options struct { | |
| 194 | + NewAgent func(root string) (*agent.Agent, error) // one agent per session | |
| 195 | + Log *metrics.Logger | |
| 196 | + } | |
| 197 | + type Server struct{ /* unexported */ } | |
| 198 | + func New(opts Options) *Server | |
| 199 | + func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error | |
| 200 | + ``` | |
| 201 | + | |
| 202 | + `NewAgent` is a factory taking the session's `cwd`, mirroring the | |
| 203 | + `toolsIn(root)` factory `cmd/codey/main.go:286` already uses to rebuild a | |
| 204 | + tool set against a worktree. T3 Code hands us a cwd per `session/new` and it | |
| 205 | + is not necessarily the process's own. | |
| 206 | + | |
| 207 | + **Behaviour:** | |
| 208 | + | |
| 209 | + - `initialize` → `{protocolVersion: 1, agentInfo: {name: "codey", version}, | |
| 210 | + agentCapabilities: {promptCapabilities: {image: true}}, authMethods: []}`. | |
| 211 | + Accept any `protocolVersion` the client offers and answer with `1`; T3 Code | |
| 212 | + sends `1` today (`acp/AcpSessionRuntime.ts:696-705`). | |
| 213 | + - `authenticate` → `{}` for any `methodId`. T3 Code sends `cached_token` or | |
| 214 | + `xai.api_key` and does not catch a failure here, so this must never error. | |
| 215 | + - `session/new` → build an agent rooted at `params.cwd`, store it under a fresh | |
| 216 | + session id (`codey-` + 16 random hex), reply `{sessionId}`. Ignore | |
| 217 | + `mcpServers`. | |
| 218 | + - `session/prompt` → concatenate the text blocks, run the agent, stream, reply | |
| 219 | + `{stopReason: "end_turn"}`. `"cancelled"` if the session was cancelled, | |
| 220 | + `"refusal"` on a hard agent error after emitting the error text as a chunk. | |
| 221 | + - `session/cancel` → cancel that session's in-flight turn context. Idempotent, | |
| 222 | + and harmless when nothing is running. | |
| 223 | + | |
| 224 | + `update.go` owns the translation, and is the file worth unit-testing hardest: | |
| 225 | + | |
| 226 | + | `agent.Event` | `session/update` | | |
| 227 | + |---|---| | |
| 228 | + | `EventTextDelta` | `agent_message_chunk` with `content: {type: "text", text}` | | |
| 229 | + | `EventText` (non-streaming) | same, once | | |
| 230 | + | everything else | dropped in phase 1 | | |
| 231 | + | |
| 232 | + Deltas are coalesced: buffer until 50 ms have passed or 512 bytes have | |
| 233 | + accumulated, then flush one notification. This is what keeps us clear of T3 | |
| 234 | + Code's `sliding(32)` queue on a fast model. | |
| 235 | + | |
| 236 | + - [ ] **Step 1: Write the failing tests** | |
| 237 | + | |
| 238 | + Drive `Server.Run` over pipes with a fake `NewAgent` whose `Run` emits a | |
| 239 | + scripted `[]agent.Event`. Assert: the full handshake in order | |
| 240 | + (`initialize` → `authenticate` → `session/new` → `session/prompt`) produces a | |
| 241 | + session id and a `{stopReason:"end_turn"}`; the text arrives as | |
| 242 | + `session/update` notifications carrying the session id and reassembling to | |
| 243 | + the original string; deltas are coalesced rather than one-per-event; a | |
| 244 | + `session/prompt` for an unknown session id is an error, not a panic; a | |
| 245 | + `session/cancel` during a turn cancels the agent's context and the prompt | |
| 246 | + replies `{stopReason:"cancelled"}`; two sessions run concurrently without | |
| 247 | + their updates crossing over. | |
| 248 | + | |
| 249 | + - [ ] **Step 2: Implement `session.go`** — the session table, per-session | |
| 250 | + mutex, per-turn cancel func. | |
| 251 | + | |
| 252 | + - [ ] **Step 3: Implement `update.go`** — the event translation and the | |
| 253 | + coalescer. The coalescer takes a clock function so tests do not sleep. | |
| 254 | + | |
| 255 | + - [ ] **Step 4: Implement `serve.go`** — wire the handler methods onto the | |
| 256 | + session table. | |
| 257 | + | |
| 258 | + - [ ] **Step 5: Verify** | |
| 259 | + | |
| 260 | + ```bash | |
| 261 | + gofmt -w internal/serve && go test ./internal/serve/... | |
| 262 | + ``` | |
| 263 | + | |
| 264 | + --- | |
| 265 | + | |
| 266 | + ### Task 3: `cmd/codey` — the `agent stdio` verb and the two probes | |
| 267 | + | |
| 268 | + **Files:** | |
| 269 | + - Modify: `cmd/codey/main.go` | |
| 270 | + - Create: `cmd/codey/agentcmd.go`, `cmd/codey/agentcmd_test.go` | |
| 271 | + | |
| 272 | + **Behaviour:** | |
| 273 | + | |
| 274 | + Argument parsing has to be positional-agnostic, because T3 Code splits its | |
| 275 | + flags around the subcommand: `--permission-mode default agent stdio`, but | |
| 276 | + `agent --always-approve stdio` (`acp/GrokAcpSupport.ts:33-46`). Scan `os.Args` | |
| 277 | + for the bare words `agent` and `stdio` and for the three flags anywhere in the | |
| 278 | + line; ignore anything else rather than erroring, since `launchArgs` is a free | |
| 279 | + text field the user can put anything in. | |
| 280 | + | |
| 281 | + Three entry points: | |
| 282 | + | |
| 283 | + - `codey --version` → one line, `codey <semver>`, exit 0. Must match | |
| 284 | + `/\bv?(\d+\.\d+\.\d+)\b/` (`apps/server/src/provider/providerSnapshot.ts:114-119`). | |
| 285 | + Keep the existing `-v` verbose flag working; `--version` is new and distinct. | |
| 286 | + - `codey models` → exit 0, and print, in this order: | |
| 287 | + ``` | |
| 288 | + You are logged in as <endpoint or account>. | |
| 289 | + * codey-opus-5 (default) | |
| 290 | + - codey-sonnet-5 | |
| 291 | + ``` | |
| 292 | + T3 Code reads login state off `/you are logged in/i` and models off | |
| 293 | + `^\s*[*-]\s+(\S+)` bullet lines, with `(default)` marking the default | |
| 294 | + (`Layers/GrokProvider.ts:258-286`). Source the list from the same | |
| 295 | + `GET /v1/models` the TUI model picker uses; on failure print the configured | |
| 296 | + model alone and still exit 0 — a non-zero exit makes T3 Code discard the | |
| 297 | + whole listing. | |
| 298 | + - `codey agent [--permission-mode X] [--always-approve] stdio` → build the | |
| 299 | + `serve.Server` and run it on stdin/stdout. | |
| 300 | + | |
| 301 | + Permission modes map onto the existing `approval.Policy`: | |
| 302 | + | |
| 303 | + | T3 Code mode | Codey behaviour | | |
| 304 | + |---|---| | |
| 305 | + | `default` (or absent) | ask for every mutating tool | | |
| 306 | + | `acceptEdits` | auto-approve `edit`/`write`, ask for `bash` | | |
| 307 | + | `auto` | auto-approve all but ask for anything the policy calls non-rememberable | | |
| 308 | + | `--always-approve` | the equivalent of `-yolo` | | |
| 309 | + | |
| 310 | + - [ ] **Step 1: Write the failing tests** | |
| 311 | + | |
| 312 | + `agentcmd_test.go` tests the argument scanner as a pure function over | |
| 313 | + `[]string` — all five argv shapes T3 Code produces, plus junk from | |
| 314 | + `launchArgs`, plus `--version` and `models`. Then a test that asserts the | |
| 315 | + version line matches T3 Code's regex and the `models` output parses under a | |
| 316 | + Go transliteration of their bullet parser. | |
| 317 | + | |
| 318 | + - [ ] **Step 2: Implement the scanner and the three entry points** | |
| 319 | + | |
| 320 | + Take the branch as early in `main` as possible — before the metrics logger | |
| 321 | + opens, before the wizard, before anything can print to stdout. | |
| 322 | + | |
| 323 | + - [ ] **Step 3: Verify** | |
| 324 | + | |
| 325 | + ```bash | |
| 326 | + gofmt -w ./cmd/codey && go test ./... && go build -o /tmp/codey ./cmd/codey | |
| 327 | + /tmp/codey --version && /tmp/codey models | |
| 328 | + ``` | |
| 329 | + | |
| 330 | + --- | |
| 331 | + | |
| 332 | + ### Task 4: End-to-end against the real T3 Code | |
| 333 | + | |
| 334 | + **Files:** | |
| 335 | + - Create: `internal/serve/conformance_test.go` | |
| 336 | + | |
| 337 | + **Steps:** | |
| 338 | + | |
| 339 | + - [ ] **Step 1: A conformance test that replays T3 Code's exact handshake** | |
| 340 | + | |
| 341 | + Hard-code the literal JSON T3 Code sends — `initialize` with | |
| 342 | + `{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"clientInfo":{"name":"t3-code","version":"0.0.0"}}`, | |
| 343 | + then `authenticate` with `{"methodId":"cached_token"}`, then `session/new`, | |
| 344 | + then `session/prompt` — and assert our replies decode against the shapes in | |
| 345 | + the design doc. This file is the early-warning system for their churn: when | |
| 346 | + a nightly breaks us, this is what tells us where. | |
| 347 | + | |
| 348 | + - [ ] **Step 2: Wire it up by hand** | |
| 349 | + | |
| 350 | + In T3 Code: Settings → Providers → Grok → enable, Binary path = the built | |
| 351 | + `codey`, instance display name "Codey". Or edit `~/.t3/userdata/settings.json` | |
| 352 | + directly (restart the app afterwards): | |
| 353 | + | |
| 354 | + ```json | |
| 355 | + "providerInstances": { | |
| 356 | + "codey": { | |
| 357 | + "driver": "grok", | |
| 358 | + "displayName": "Codey", | |
| 359 | + "enabled": true, | |
| 360 | + "config": { "binaryPath": "/home/joni/Work/GO/coding-harness/codey" }, | |
| 361 | + "environment": [ | |
| 362 | + { "name": "ANTHROPIC_API_KEY", "value": "sk-ant-...", "sensitive": true } | |
| 363 | + ] | |
| 364 | + } | |
| 365 | + } | |
| 366 | + ``` | |
| 367 | + | |
| 368 | + The per-instance `environment` array is how Codey gets its credential without | |
| 369 | + inheriting whatever the desktop app was launched with. | |
| 370 | + | |
| 371 | + - [ ] **Step 3: Confirm the provider card and one turn** | |
| 372 | + | |
| 373 | + The card should read installed, authenticated, with our model list. Start a | |
| 374 | + thread, ask something that needs no tools, and watch the text stream in. | |
| 375 | + | |
| 376 | + - [ ] **Step 4: Capture the traffic for the next phase** | |
| 377 | + | |
| 378 | + T3 Code logs every provider event to | |
| 379 | + `~/.t3/userdata/logs/provider/events.<threadId>.log`, tagged `CANON:` for its | |
| 380 | + normalised model and `NTIVE:` for the raw wire. That file is the ground truth | |
| 381 | + for anything phase 2 gets wrong. | |
| 382 | + | |
| 383 | + --- | |
| 384 | + | |
| 385 | + ## Phase 2 — a first-class provider | |
| 386 | + | |
| 387 | + ### Task 5: Tool calls become visible | |
| 388 | + | |
| 389 | + **Files:** | |
| 390 | + - Modify: `internal/agent/loop.go` (add `ID` to `Event`), `internal/serve/update.go` | |
| 391 | + - Modify: `internal/agent/loop_test.go`, `internal/serve/serve_test.go` | |
| 392 | + | |
| 393 | + `agent.Event` carries `Tool string` but nothing pairing a start with its result | |
| 394 | + (`internal/agent/loop.go:180-191`). ACP keys `tool_call` and `tool_call_update` | |
| 395 | + by `toolCallId`. Pairing by order would work today because dispatch is | |
| 396 | + sequential, but it is a landmine the first time it is not. | |
| 397 | + | |
| 398 | + - [ ] **Step 1:** Add `ID string` to `agent.Event`, populated from the | |
| 399 | + `tool_use` block's id on `EventToolStart` and echoed on `EventToolResult`. | |
| 400 | + Existing observers ignore it; the TUI is unaffected. | |
| 401 | + - [ ] **Step 2:** Emit `tool_call` on start with | |
| 402 | + `{toolCallId, title, kind, status: "pending", rawInput}` and | |
| 403 | + `tool_call_update` on result with `status: "completed"|"failed"` and | |
| 404 | + `content: [{type: "content", content: {type: "text", text}}]`. | |
| 405 | + `kind` maps our tools onto ACP's vocabulary: `read`, `edit`, `execute`, | |
| 406 | + `search`, `fetch`, `think`, `other`. | |
| 407 | + - [ ] **Step 3:** Verify the tool rows render in T3 Code, and that a failed | |
| 408 | + tool shows as failed rather than silently completing. | |
| 409 | + | |
| 410 | + ### Task 6: Approvals | |
| 411 | + | |
| 412 | + **Files:** | |
| 413 | + - Modify: `internal/serve/serve.go`, `internal/serve/session.go` | |
| 414 | + | |
| 415 | + - [ ] **Step 1:** Implement an `approval.Prompt` that sends | |
| 416 | + `session/request_permission` with | |
| 417 | + `options: [{optionId:"allow-once", name:"Allow", kind:"allow_once"}, | |
| 418 | + {optionId:"allow-always", name:"Always allow", kind:"allow_always"}, | |
| 419 | + {optionId:"reject-once", name:"Reject", kind:"reject_once"}]` | |
| 420 | + and blocks on the reply. Use exactly those three ids: Grok echoes back one of | |
| 421 | + ours, but Cursor's adapter hardcodes those literals | |
| 422 | + (`acp/AcpAdapterSupport.ts:46-55`), so choosing them now means the same binary | |
| 423 | + works in a Cursor instance too. | |
| 424 | + - [ ] **Step 2:** Map the reply — `selected`/`allow-always` → | |
| 425 | + `approval.AlwaysThisCommand`, `selected`/`allow-once` → `Once`, | |
| 426 | + `selected`/`reject-once` → `Deny`, `cancelled` → abort the turn. | |
| 427 | + - [ ] **Step 3:** Respect the launch permission mode: in `--always-approve` no | |
| 428 | + request is sent at all. | |
| 429 | + - [ ] **Step 4:** Verify against T3 Code's approval panel, including the | |
| 430 | + interaction with its own permission-mode selector, and confirm that | |
| 431 | + cancelling a pending approval does not wedge the session. | |
| 432 | + | |
| 433 | + ### Task 7: Models, effort and the picker | |
| 434 | + | |
| 435 | + **Files:** | |
| 436 | + - Modify: `internal/serve/serve.go`, `cmd/codey/agentcmd.go` | |
| 437 | + | |
| 438 | + - [ ] **Step 1:** Return the model list in `session/new` as | |
| 439 | + `{models: {currentModelId, availableModels: [{modelId, name}]}}`. T3 Code's | |
| 440 | + Grok path reads it (`acp/GrokAcpSupport.ts:124-154`) and prefers it over the | |
| 441 | + `codey models` output. | |
| 442 | + - [ ] **Step 2:** Handle `session/set_model` / `session/set_config_option` by | |
| 443 | + switching the session's model, and reply `{}` to any config option we do not | |
| 444 | + recognise rather than erroring — Cursor's adapter sends one unconditionally. | |
| 445 | + - [ ] **Step 3:** Carry reasoning effort through `_meta.reasoningEffort` if it | |
| 446 | + is present on the selected model. | |
| 447 | + | |
| 448 | + --- | |
| 449 | + | |
| 450 | + ## Phase 3 — polish, in rough order of value | |
| 451 | + | |
| 452 | + - [ ] **Reasoning.** Surface thinking blocks from the loop as | |
| 453 | + `agent_thought_chunk`. Needs a new event kind in `internal/agent`; today the | |
| 454 | + loop never reports them to observers. | |
| 455 | + - [ ] **Diffs.** Emit `tool_call` content of kind `diff` with `path`, `oldText`, | |
| 456 | + `newText` from `edit`/`write`, so T3 Code's diff viewer lights up instead of | |
| 457 | + showing our text summary. | |
| 458 | + - [ ] **Resume.** Advertise `agentCapabilities.sessionCapabilities.resume` and | |
| 459 | + implement `session/load` on top of `agent.ResolveSession`, replaying the | |
| 460 | + history as `session/update`s. Until we advertise it, T3 Code will not ask | |
| 461 | + (`acp/AcpSessionRuntime.ts:726`), so this is purely additive. | |
| 462 | + - [ ] **Plans.** Map our todo-ish output onto the `plan` update, or skip it — | |
| 463 | + it is optional and we have no plan object today. | |
| 464 | + - [ ] **Slash commands.** `available_commands_update` can expose `/compact` and | |
| 465 | + the skills list in T3 Code's composer. | |
| 466 | + - [ ] **Usage.** Feed token counts somewhere visible; the Grok path has no | |
| 467 | + usage channel, so this may be the first thing that argues for the fork. | |
| 468 | + - [ ] **README.** A "Using Codey from T3 Code" section: the settings recipe, | |
| 469 | + what the Grok branding means, and the fact that it says Grok on the tin. | |
| 470 | + | |
| 471 | + --- | |
| 472 | + | |
| 473 | + ## How we will know it works | |
| 474 | + | |
| 475 | + 1. `go test ./...` green, including the conformance test. | |
| 476 | + 2. `codey --version` and `codey models` satisfy T3 Code's parsers — asserted in | |
| 477 | + Go, then observed on the provider card. | |
| 478 | + 3. A thread in T3 Code: text streams, tool rows appear and complete, an | |
| 479 | + approval prompt appears and is answerable, the stop button ends the turn, | |
| 480 | + and the thread survives a second prompt in the same session. | |
| 481 | + 4. `~/.t3/userdata/logs/provider/events.<threadId>.log` contains no decode | |
| 482 | + errors and no `warning` rows we did not intend. | |
| 483 | + | |
| 484 | + ## Out of scope | |
| 485 | + | |
| 486 | + - Any change to T3 Code's source. The fork — a `codey` driver in | |
| 487 | + `BUILT_IN_DRIVERS` — is discussed at the end of the design doc and stays a | |
| 488 | + later decision. Nothing in this plan is wasted if we take it: the same ACP | |
| 489 | + server is what such a driver would talk to. | |
| 490 | + - The Codex app-server path. Specified in the design doc's appendix as the | |
| 491 | + fallback, not built. | |
| 492 | + - MCP. T3 Code offers us an MCP server on `session/new`; we ignore it. | |
| @@ -0,0 +1,306 @@ | ||
| 1 | + # Codey as a T3 Code provider — design | |
| 2 | + | |
| 3 | + **Question:** can T3 Code drive Codey without patching T3 Code? If so, through | |
| 4 | + which of its provider slots, and what exactly does Codey have to say? | |
| 5 | + | |
| 6 | + **Answer:** yes. Speak ACP over stdio, put the binary path in the Grok | |
| 7 | + provider's settings, and T3 Code drives Codey with no source change on their | |
| 8 | + side. Everything below is evidence for that choice and the contract it | |
| 9 | + commits us to. | |
| 10 | + | |
| 11 | + Researched against `github.com/pingdotgg/t3code` at commit `9accc567` | |
| 12 | + (2026-09-19), which is the same generation as the 0.0.43 nightly installed on | |
| 13 | + this machine. Every file reference is into that tree. | |
| 14 | + | |
| 15 | + ## How T3 Code's provider layer is built | |
| 16 | + | |
| 17 | + T3 Code is a control plane, not an agent: it spawns somebody else's CLI and | |
| 18 | + renders what comes back. There are six drivers — Codex, Claude, Cursor, Grok, | |
| 19 | + OpenCode, Antigravity (`apps/server/src/provider/builtInDrivers.ts:50-56`). | |
| 20 | + | |
| 21 | + Three facts decide everything: | |
| 22 | + | |
| 23 | + **Drivers are compiled in.** `BUILT_IN_DRIVERS` is "the static set of | |
| 24 | + `ProviderDriver`s this build ships with" | |
| 25 | + (`apps/server/src/provider/builtInDrivers.ts:1-22`). There is no plugin | |
| 26 | + mechanism, no config-declared driver, no external process registry. Adding a | |
| 27 | + seventh driver means editing their repo and rebuilding the Electron app. | |
| 28 | + | |
| 29 | + **But driver kinds are an open slug, on purpose.** `ProviderDriverKind` is "an | |
| 30 | + **open** branded slug, not a closed literal union … The server hosts forks, | |
| 31 | + ships in PRs that add drivers, and users frequently roll between branches and | |
| 32 | + forks" (`packages/contracts/src/providerInstance.ts:16-33`). Settings naming a | |
| 33 | + driver this build does not know round-trip intact and surface as an | |
| 34 | + "unavailable" card. So a fork that adds a `codey` driver is a supported shape — | |
| 35 | + it is just not a shape that survives the auto-updater. | |
| 36 | + | |
| 37 | + **Each instance carries user-editable config, and there can be several per | |
| 38 | + driver.** `ProviderInstanceConfig` is | |
| 39 | + `{driver, displayName?, accentColor?, environment?, enabled?, config?}` | |
| 40 | + (`packages/contracts/src/providerInstance.ts:124-131`), keyed by a user-chosen | |
| 41 | + instance id, and every built-in driver sets `supportsMultipleInstances: true` | |
| 42 | + (e.g. `Drivers/GrokDriver.ts:60-61`). The per-driver `config` payload always | |
| 43 | + contains `binaryPath`, and usually `launchArgs` and `customModels` | |
| 44 | + (`packages/contracts/src/settings.ts:562-735`). This is the seam. On this | |
| 45 | + machine it is already visible in `~/.t3/userdata/settings.json`: | |
| 46 | + | |
| 47 | + ```json | |
| 48 | + "providerInstances": { | |
| 49 | + "codex": { "driver": "codex", "enabled": false, | |
| 50 | + "config": { "binaryPath": "codex", "homePath": "", "shadowHomePath": "", | |
| 51 | + "launchArgs": "", "customModels": [] } } | |
| 52 | + } | |
| 53 | + ``` | |
| 54 | + | |
| 55 | + `binaryPath` is used verbatim — nothing anywhere keys off the file's *name*. | |
| 56 | + Per-instance environment variables are editable in the same UI | |
| 57 | + (`docs/user/install.md:130-133`). | |
| 58 | + | |
| 59 | + So the plan the user proposed is the right one: don't add a driver, occupy one. | |
| 60 | + | |
| 61 | + ## The four candidate surfaces | |
| 62 | + | |
| 63 | + ### Claude — rejected | |
| 64 | + | |
| 65 | + T3 Code does not spawn `claude` itself. It hands the path to Anthropic's own | |
| 66 | + SDK: `"@anthropic-ai/claude-agent-sdk": "^0.3.276"` (`apps/server/package.json:26`), | |
| 67 | + `pathToClaudeCodeExecutable: claudeBinaryPath` (`Layers/ClaudeAdapter.ts:4916`). | |
| 68 | + The contract is therefore the *SDK's* CLI contract, which lives in a node | |
| 69 | + package T3 Code upgrades on its own schedule, and which we would have to | |
| 70 | + re-derive by experiment rather than read. | |
| 71 | + | |
| 72 | + Worse, the model picker is a baked-in manifest with version gates: | |
| 73 | + `provider/model-manifest.json` gives `claude-opus-5` `"minVersion": "2.1.219"`, | |
| 74 | + and `ClaudeModelCatalog.ts:148-158` refuses a model whose `minVersion` exceeds | |
| 75 | + what `claude --version` printed. Our own model names would never appear; | |
| 76 | + we would be advertising Anthropic slugs and lying about a Claude Code version. | |
| 77 | + | |
| 78 | + ### Codex — possible, expensive | |
| 79 | + | |
| 80 | + Spawned as `<binaryPath> app-server` plus the user's `launchArgs` | |
| 81 | + (`Layers/codexLaunchArgs.ts:12-15`), newline-delimited JSON-RPC over stdio, | |
| 82 | + no `jsonrpc` field, notifications must carry no `id` key at all | |
| 83 | + (`packages/effect-codex-app-server/src/protocol.ts:82-95, 459-474`). | |
| 84 | + | |
| 85 | + It has one genuinely attractive property the others lack: **models come from | |
| 86 | + the binary at runtime**. The probe pages through `model/list` and shows what it | |
| 87 | + is told (`Layers/CodexProvider.ts:325-341`), so Codey's own model names would | |
| 88 | + appear in T3 Code's picker. There is no version gate; the version is scraped | |
| 89 | + out of the `initialize` result's `userAgent` with `/\/([^\s]+)/` | |
| 90 | + (`Layers/CodexProvider.ts:422-424`). | |
| 91 | + | |
| 92 | + Against it: the protocol is enormous and strictly decoded. 99 client request | |
| 93 | + methods, 70 server notifications (`_generated/meta.gen.ts:6-191`), generated | |
| 94 | + from a pinned upstream ref and schema-checked per method, so a missing required | |
| 95 | + field is a hard decode error rather than a shrug. The minimum is bigger than it | |
| 96 | + looks — `thread/start` must return a fully populated `thread` object | |
| 97 | + (`cliVersion`, `sessionId`, `preview`, `ephemeral`, `createdAt`, `updatedAt`, | |
| 98 | + `source`, `status`, `turns`) of which T3 Code reads exactly three fields | |
| 99 | + (`_generated/schema.gen.ts:31819-31855`, consumed at | |
| 100 | + `Layers/CodexSessionRuntime.ts:2447-2453`). And `account/read` must answer | |
| 101 | + `requiresOpenaiAuth: false` with an account object or the probe stops | |
| 102 | + (`Layers/CodexProvider.ts:426-434`). | |
| 103 | + | |
| 104 | + It is implementable — the full method-by-method spec is in the appendix — but | |
| 105 | + it is a moving target owned by OpenAI and mirrored by T3 Code. | |
| 106 | + | |
| 107 | + ### OpenCode — rejected | |
| 108 | + | |
| 109 | + T3-managed OpenCode runs one HTTP server per thread with directory-scoped MCP | |
| 110 | + registration and per-directory persistent approval grants | |
| 111 | + (`docs/internals/providers.md:13-24`). We would be impersonating a server, not | |
| 112 | + a CLI, and inheriting its grant semantics. | |
| 113 | + | |
| 114 | + ### Grok — chosen | |
| 115 | + | |
| 116 | + Grok is an ACP agent. T3 Code spawns: | |
| 117 | + | |
| 118 | + ```ts | |
| 119 | + { command: grokSettings?.binaryPath || "grok", | |
| 120 | + args: [...grokAcpSpawnArgs(runtimeMode)], | |
| 121 | + cwd, | |
| 122 | + env: { ...environment, GROK_OAUTH2_REFERRER: "t3code" } } | |
| 123 | + ``` | |
| 124 | + (`acp/GrokAcpSupport.ts:48-63`), where `grokAcpSpawnArgs` is | |
| 125 | + `["agent", "stdio"]` by default and prefixes `["--permission-mode", "default" | |
| 126 | + | "acceptEdits" | "auto"]` or appends `--always-approve` for the four runtime | |
| 127 | + modes (`acp/GrokAcpSupport.ts:33-46`). | |
| 128 | + | |
| 129 | + That is the whole launch contract. The ACP side is four methods and one | |
| 130 | + notification, and ACP is an open, documented protocol rather than a vendor | |
| 131 | + mirror. Crucially, the initialize *result* has exactly one required field: | |
| 132 | + `protocolVersion`; `agentCapabilities`, `authMethods`, `agentInfo` and | |
| 133 | + `promptCapabilities` are all `optionalKey` | |
| 134 | + (`packages/effect-acp/src/_generated/schema.gen.ts:6574-6605`). Unknown | |
| 135 | + `sessionUpdate` discriminators hit `default: break` | |
| 136 | + (`acp/AcpRuntimeModel.ts:882-883`), so we can grow into the protocol instead of | |
| 137 | + having to land on it. | |
| 138 | + | |
| 139 | + The two shell probes that gate the provider card are regex-loose: | |
| 140 | + | |
| 141 | + - `grok --version` must exit 0 and print something matching | |
| 142 | + `/\bv?(\d+\.\d+\.\d+)\b/` (`Layers/GrokProvider.ts:394`, parser at | |
| 143 | + `providerSnapshot.ts:114-119`). | |
| 144 | + - `grok models` must exit 0. Login state is `/you are logged in/i` versus | |
| 145 | + `/not authenticated|not logged in/i`; models are bullet lines | |
| 146 | + `* slug (default)` or `- slug` (`Layers/GrokProvider.ts:258-286`). | |
| 147 | + | |
| 148 | + Compare Antigravity, which fingerprints the agent properly — | |
| 149 | + `agentInfo.name === "antigravity-acp"` plus four required capabilities | |
| 150 | + (`AntigravityInstallation.ts:493-499`). Nothing of the sort exists on the Grok | |
| 151 | + path: no filename check, no minimum version, no config file under `~/.grok`. | |
| 152 | + | |
| 153 | + Cursor is nearly as cheap but adds three obligations: an `about --format json` | |
| 154 | + probe that must report a `YYYY.MM.DD` `cliVersion` and a non-null `userEmail` | |
| 155 | + or the card goes to `status: "error"` (`Layers/CursorProvider.ts:1086-1097, | |
| 156 | + 932-952`); an unconditional `session/set_config_option` on model selection | |
| 157 | + (`acp/CursorAcpSupport.ts:113`); and hardcoded permission option ids | |
| 158 | + `"allow-always"` / `"allow-once"` / `"reject-once"` that the agent must accept | |
| 159 | + verbatim (`acp/AcpAdapterSupport.ts:46-55`). Grok, by contrast, echoes back an | |
| 160 | + option id chosen from the ones *we* advertised | |
| 161 | + (`Layers/GrokAdapter.ts:1219-1237`). | |
| 162 | + | |
| 163 | + ### Scoring | |
| 164 | + | |
| 165 | + | | Claude | Codex | OpenCode | Cursor | **Grok** | | |
| 166 | + |---|---|---|---|---|---| | |
| 167 | + | Transport we must implement | SDK-private | JSON-RPC, 99+70 methods | HTTP+SSE server | ACP | **ACP** | | |
| 168 | + | Minimum methods | n/a | 4 req + 4 notif, strict schemas | many | 4 + probe | **4 + 1 notif** | | |
| 169 | + | Startup gates | version-gated model manifest | `account/read` auth shape | server lifecycle | `about` JSON + email | **two regexes** | | |
| 170 | + | Our own model names show | no | yes | partly | via extension | via `models` output | | |
| 171 | + | Protocol owner | Anthropic SDK | OpenAI, mirrored | SST | Cursor | **open standard** | | |
| 172 | + | Fingerprints the agent | — | — | — | — | **no** | | |
| 173 | + | |
| 174 | + ## The contract we are signing up to | |
| 175 | + | |
| 176 | + ### Command line | |
| 177 | + | |
| 178 | + | invocation | must do | | |
| 179 | + |---|---| | |
| 180 | + | `codey --version` | exit 0, print a line containing `x.y.z` | | |
| 181 | + | `codey models` | exit 0, print `You are logged in as …` and one `* slug (default)` / `- slug` line per model | | |
| 182 | + | `codey agent stdio` | speak ACP over stdin/stdout until EOF | | |
| 183 | + | `codey agent --always-approve stdio` | same, with every tool auto-approved | | |
| 184 | + | `codey --permission-mode default\|acceptEdits\|auto agent stdio` | same, with that approval policy | | |
| 185 | + | |
| 186 | + `codey login`, `codey update` and any other subcommand may be absent; T3 Code | |
| 187 | + only ever mentions them in error text. Note the flag placement: T3 Code puts | |
| 188 | + `--permission-mode` *before* the `agent` subcommand and `--always-approve` | |
| 189 | + *between* `agent` and `stdio`, so the argument parser has to be positional- | |
| 190 | + agnostic rather than a strict `agent` subcommand with its own flag set. | |
| 191 | + | |
| 192 | + ### ACP, client → us | |
| 193 | + | |
| 194 | + 1. `initialize` — `{protocolVersion: 1, clientCapabilities: {fs: {readTextFile: false, writeTextFile: false}, terminal: false}, clientInfo: {name: "t3-code", version}}` (`acp/AcpSessionRuntime.ts:696-705, 566-578`). Minimum valid reply: `{"protocolVersion": 1}`. We will also send `agentInfo` and `agentCapabilities`. | |
| 195 | + 2. `authenticate` — `{methodId: "cached_token"}`, or `"xai.api_key"` if `XAI_API_KEY` is set in the instance env (`acp/GrokAcpSupport.ts:65-69`). Sent unconditionally and *not* caught: an error here aborts session start (`acp/AcpSessionRuntime.ts:710-718`). Reply `{}` regardless of the id. | |
| 196 | + 3. `session/new` — `{cwd, mcpServers: [], additionalDirectories?}`. Only `sessionId` is required back (`acp/AcpSessionRuntime.ts:836-860`). | |
| 197 | + 4. `session/prompt` — `{sessionId, prompt: ContentBlock[]}`. Only `stopReason` is read from the result (`Layers/CursorAdapter.ts:1112`). | |
| 198 | + 5. `session/cancel` — notification; T3 Code synthesises `{stopReason: "cancelled"}` locally (`acp/AcpSessionRuntime.ts:1027-1029`). | |
| 199 | + | |
| 200 | + `session/load` / resume is only attempted when | |
| 201 | + `agentCapabilities.sessionCapabilities.resume` is true | |
| 202 | + (`acp/AcpSessionRuntime.ts:726`), so omitting it is a supported state, not a | |
| 203 | + failure. | |
| 204 | + | |
| 205 | + ### ACP, us → client | |
| 206 | + | |
| 207 | + `session/update` notifications, switched on `sessionUpdate` | |
| 208 | + (`acp/AcpRuntimeModel.ts:795-884`): | |
| 209 | + | |
| 210 | + | variant | fields read | we need it | | |
| 211 | + |---|---|---| | |
| 212 | + | `agent_message_chunk` | `content.text` | **yes** — the visible answer | | |
| 213 | + | `agent_thought_chunk` | `content.text` | phase 2 (we have no reasoning events yet) | | |
| 214 | + | `tool_call` | `toolCallId`, `title`, `kind`, `status`, `content`, `rawInput` | **yes** — otherwise tools are invisible | | |
| 215 | + | `tool_call_update` | same, merged by `toolCallId`; terminal on `completed`/`failed` | **yes** | | |
| 216 | + | `plan` | `entries[].content`, `.status` | no | | |
| 217 | + | `available_commands_update` | `availableCommands` | later, for slash commands | | |
| 218 | + | `current_mode_update`, `config_option_update` | mode / config ids | no | | |
| 219 | + | |
| 220 | + And `session/request_permission` — we send `{sessionId, toolCall: {kind, title, rawInput, locations}, options: [{optionId, name, kind}]}`; T3 Code replies `{outcome: {outcome: "selected", optionId}}` or `{outcome: {outcome: "cancelled"}}` (`Layers/CursorAdapter.ts:746-754`). On the Grok path the id comes from our own list (`Layers/GrokAdapter.ts:1219-1237`), so we choose the vocabulary — but we should still accept `allow-always`/`allow-once`/`reject-once` verbatim so that pointing the same binary at a Cursor instance also works. | |
| 221 | + | |
| 222 | + ### What T3 Code will show | |
| 223 | + | |
| 224 | + The card, icon and colour come from `GROK_PRESENTATION`, so out of the box it | |
| 225 | + says Grok. The instance's `displayName` and `accentColor` are user-editable | |
| 226 | + (`packages/contracts/src/providerInstance.ts:124-131`) and are stamped onto | |
| 227 | + every event by `withInstanceIdentity`, so renaming the instance to "Codey" gets | |
| 228 | + most of the way. The xAI glyph stays. That is the price of not forking. | |
| 229 | + | |
| 230 | + Model slugs pass through `resolveGrokAcpBaseModelId` and get a display name | |
| 231 | + from `displayNameFromGrokModelSlug`, which title-cases on `-`/`_` | |
| 232 | + (`Layers/GrokProvider.ts:272-293`). `codey-opus-5` renders as "Codey Opus 5". | |
| 233 | + | |
| 234 | + ## What this costs on our side | |
| 235 | + | |
| 236 | + Codey today is a TUI and a `-p` one-shot. It already has the two seams an | |
| 237 | + adapter needs — `Agent.Observe func(Event)` and `Agent.Approve Approver` | |
| 238 | + (`internal/agent/loop.go:205-211`) — plus a tool registry, a session store and | |
| 239 | + an approval policy that can remember decisions. The new work is a third front | |
| 240 | + end: `internal/acp` (wire) + `internal/serve` (bridge), and an `agent stdio` | |
| 241 | + verb in `cmd/codey`. Nothing in `internal/agent` needs to change for phase 1. | |
| 242 | + | |
| 243 | + Three gaps show up when mapping our `Event` onto ACP: | |
| 244 | + | |
| 245 | + - **`Event` has no tool call id.** `EventToolStart`/`EventToolResult` carry | |
| 246 | + `Tool string` and nothing that pairs them | |
| 247 | + (`internal/agent/loop.go:180-191`). ACP's `tool_call` /`tool_call_update` | |
| 248 | + are keyed by `toolCallId`. Sequential dispatch means we can pair by order for | |
| 249 | + now, but the honest fix is to add an id to the event. | |
| 250 | + - **No reasoning events.** The loop never surfaces thinking blocks to | |
| 251 | + observers, so `agent_thought_chunk` has nothing to carry. | |
| 252 | + - **No diff events.** Our `edit`/`write` tools return text; T3 Code's diff view | |
| 253 | + wants `tool_call` content of kind `diff` with old/new text. | |
| 254 | + | |
| 255 | + None of these block a working turn; all three are what separate "it works" | |
| 256 | + from "it looks like a first-class provider". | |
| 257 | + | |
| 258 | + ## Risks | |
| 259 | + | |
| 260 | + - **Their churn.** T3 Code ships nightly and the Grok path is explicitly young: | |
| 261 | + the driver is disabled by default because "the binding is not yet stable | |
| 262 | + enough to probe on every install" (`packages/contracts/src/settings.ts:713-716`). | |
| 263 | + Mitigation: the contract surface we depend on is five methods, and a conformance | |
| 264 | + test that replays T3 Code's exact handshake catches a break in seconds. | |
| 265 | + - **Occupying a slot means losing it.** A user who wants both real Grok and | |
| 266 | + Codey needs two instances of the `grok` driver. That works | |
| 267 | + (`supportsMultipleInstances: true`) but it is a confusing settings page. | |
| 268 | + - **It says Grok.** See above. | |
| 269 | + - **Upstream ACP version.** T3 Code sends `protocolVersion: 1`. If they move to | |
| 270 | + 2 we have to follow; the handshake is where we would find out. | |
| 271 | + | |
| 272 | + ## The fork option, for the record | |
| 273 | + | |
| 274 | + Because driver kinds are open and forks are anticipated, a `codey` driver is | |
| 275 | + about one file (`Drivers/CodeyDriver.ts`), one line in `BUILT_IN_DRIVERS`, and | |
| 276 | + a settings schema — additive, no rewriting, and it would get us our own name, | |
| 277 | + icon and config fields. The cost is that it only exists in a build we compile | |
| 278 | + and ship ourselves, and it has to be rebased onto a repo that moves daily. | |
| 279 | + | |
| 280 | + The right sequencing is impersonation first: the ACP server we build for the | |
| 281 | + Grok slot is exactly the server a `codey` driver would talk to, so nothing is | |
| 282 | + thrown away if we later decide the fork is worth maintaining. | |
| 283 | + | |
| 284 | + ## Appendix: the Codex app-server contract | |
| 285 | + | |
| 286 | + Kept because it is the fallback if the Grok path is withdrawn, and because it | |
| 287 | + is the only slot that lets our model names reach the picker honestly. | |
| 288 | + | |
| 289 | + Launch: `<binaryPath> app-server [launchArgs]`, `CODEX_HOME` set in the child | |
| 290 | + env, tilde-expanded (`Layers/CodexProvider.ts:368-378`). | |
| 291 | + | |
| 292 | + Must answer: | |
| 293 | + - `initialize` → `{codexHome, platformFamily, platformOs, userAgent}`, all four required; version is scraped from `userAgent` after the first `/`. | |
| 294 | + - `thread/start` → `{approvalPolicy, approvalsReviewer, cwd, model, modelProvider, sandbox, thread{...}}` — the strictest decode in the flow. | |
| 295 | + - `turn/start` → `{turn: {id, items: [], status: "inProgress"}}`. | |
| 296 | + - `turn/interrupt` → `{}`. | |
| 297 | + - probe path: `account/read` → `{requiresOpenaiAuth: false, account: {...}}`; `model/list` → `{data: [...]}`; `skills/list` (a failure here fails the whole probe, unlike `account/rateLimits/read`, which degrades). | |
| 298 | + | |
| 299 | + Must emit: `turn/started`, `item/agentMessage/delta`, `item/completed`, | |
| 300 | + `turn/completed` — the last is what ends the turn | |
| 301 | + (`Layers/CodexAdapter.ts:1613`). | |
| 302 | + | |
| 303 | + Traps: notifications must have no `id` key at all; inbound queues are | |
| 304 | + `sliding(32)` so bursts drop oldest; a server→client request left unanswered | |
| 305 | + blocks T3 Code's entire stdin loop (`Layers/CodexSessionRuntime.ts:2565-2571`); | |
| 306 | + there is no shutdown RPC — exit on stdin EOF. | |
| @@ -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 | + } | |
| @@ -364,6 +364,23 @@ const ( | ||
| 364 | 364 | EventNotice EventKind = "notice" |
| 365 | 365 | // EventTextDelta is emitted instead of EventText while streaming. |
| 366 | 366 | EventTextDelta EventKind = "text_delta" |
| 367 | + // EventThinking is the reasoning the model showed before answering, and | |
| 368 | + // EventThinkingDelta is the same thing arriving piece by piece while | |
| 369 | + // streaming. Only models asked for extended thinking produce either. | |
| 370 | + // | |
| 371 | + // Redacted thinking is not reported at all: it is encrypted bytes that go | |
| 372 | + // back to the API untouched, with nothing in them to show. | |
| 373 | + EventThinking EventKind = "thinking" | |
| 374 | + EventThinkingDelta EventKind = "thinking_delta" | |
| 375 | + // EventToolDelta is output a tool has produced but not finished producing: | |
| 376 | + // the lines of a test run as they appear, rather than all of them at the | |
| 377 | + // end. Text carries the new bytes, not everything so far, and Tool and ID | |
| 378 | + // say which call they belong to. | |
| 379 | + // | |
| 380 | + // Only tools that opt into it produce these, and only while someone is | |
| 381 | + // watching — see [tools.WithProgress]. A front end that ignores this kind | |
| 382 | + // still shows the whole output when the call finishes. | |
| 383 | + EventToolDelta EventKind = "tool_delta" | |
| 367 | 384 | // EventPaused is emitted when the loop stops at a turn boundary, and |
| 368 | 385 | // EventResumed when it is let go again. |
| 369 | 386 | EventPaused EventKind = "paused" |
| @@ -373,10 +390,16 @@ const ( | ||
| 373 | 390 | // Event is what the loop reports to an observer. It carries plain data only — |
| 374 | 391 | // no terminal concerns — so the eval runner can consume the same stream. |
| 375 | 392 | type Event struct { |
| 376 | - Kind EventKind | |
| 377 | - Turn int | |
| 378 | - Text string | |
| 379 | - Tool string | |
| 393 | + Kind EventKind | |
| 394 | + Turn int | |
| 395 | + Text string | |
| 396 | + Tool string | |
| 397 | + // ID pairs a tool_start with the tool_result that answers it: the model's | |
| 398 | + // own tool_use id, echoed on both. Dispatch is sequential, so a front end | |
| 399 | + // can get away with pairing by arrival order — but a protocol that keys | |
| 400 | + // tool calls by id, as ACP does, needs the id rather than a promise about | |
| 401 | + // the order things arrive in. | |
| 402 | + ID string | |
| 380 | 403 | Input json.RawMessage |
| 381 | 404 | Result string |
| 382 | 405 | IsError bool |
| @@ -670,7 +693,16 @@ func (a *Agent) drive(ctx context.Context) (string, error) { | ||
| 670 | 693 | // go quiet for a search and see nothing of what it asked or found. |
| 671 | 694 | a.emitServerTools(turn, resp) |
| 672 | 695 | |
| 673 | - // While streaming the text has already gone out delta by delta. | |
| 696 | + // While streaming both of these have already gone out delta by delta. | |
| 697 | + // Thinking goes first because that is the order it was produced in: | |
| 698 | + // the model reasons and then answers, and a front end that showed them | |
| 699 | + // the other way round would be describing a conversation that did not | |
| 700 | + // happen. | |
| 701 | + if !a.opts.Stream { | |
| 702 | + if thinking := resp.Thinking(); thinking != "" { | |
| 703 | + a.emit(Event{Kind: EventThinking, Turn: turn, Text: thinking}) | |
| 704 | + } | |
| 705 | + } | |
| 674 | 706 | if text := resp.Text(); text != "" { |
| 675 | 707 | last = text |
| 676 | 708 | if !a.opts.Stream { |
| @@ -723,8 +755,11 @@ func (a *Agent) send(ctx context.Context, turn int) (*llm.Response, error) { | ||
| 723 | 755 | return a.client.CreateMessage(ctx, req) |
| 724 | 756 | } |
| 725 | 757 | return a.client.StreamMessage(ctx, req, func(ev llm.StreamEvent) { |
| 726 | - if ev.Kind == llm.StreamText { | |
| 758 | + switch ev.Kind { | |
| 759 | + case llm.StreamText: | |
| 727 | 760 | a.emit(Event{Kind: EventTextDelta, Turn: turn, Text: ev.Text}) |
| 761 | + case llm.StreamThinking: | |
| 762 | + a.emit(Event{Kind: EventThinkingDelta, Turn: turn, Text: ev.Text}) | |
| 728 | 763 | } |
| 729 | 764 | }) |
| 730 | 765 | } |
| @@ -735,10 +770,10 @@ func (a *Agent) send(ctx context.Context, turn int) (*llm.Response, error) { | ||
| 735 | 770 | func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock) ([]llm.ContentBlock, error) { |
| 736 | 771 | results := make([]llm.ContentBlock, 0, len(calls)) |
| 737 | 772 | for _, call := range calls { |
| 738 | - a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: call.Name, Input: call.Input}) | |
| 773 | + a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: call.Name, ID: call.ID, Input: call.Input}) | |
| 739 | 774 | a.fire(ctx, hooks.ToolStart, hooks.Payload{Turn: turn, Tool: call.Name}) |
| 740 | 775 | |
| 741 | - out, err := a.authorizedExecute(ctx, call) | |
| 776 | + out, err := a.authorizedExecute(a.watching(ctx, turn, call), call) | |
| 742 | 777 | if ctx.Err() != nil { |
| 743 | 778 | return nil, ctx.Err() |
| 744 | 779 | } |
| @@ -767,7 +802,7 @@ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock | ||
| 767 | 802 | } else { |
| 768 | 803 | results = append(results, llm.ToolResultBlock(call.ID, payload, isErr)) |
| 769 | 804 | } |
| 770 | - a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, Result: payload, IsError: isErr}) | |
| 805 | + a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, ID: call.ID, Result: payload, IsError: isErr}) | |
| 771 | 806 | // The result itself is not published: a hook is a bystander, and what |
| 772 | 807 | // a tool read out of this repository is not a bystander's business. |
| 773 | 808 | // Whether it failed is, because that is what a watcher acts on. |
| @@ -795,7 +830,7 @@ func (a *Agent) emitServerTools(turn int, resp *llm.Response) { | ||
| 795 | 830 | switch b.Type { |
| 796 | 831 | case llm.BlockServerToolUse: |
| 797 | 832 | names[b.ID] = b.Name |
| 798 | - a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: b.Name, Input: b.Input}) | |
| 833 | + a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: b.Name, ID: b.ID, Input: b.Input}) | |
| 799 | 834 | case llm.BlockWebSearchToolResult: |
| 800 | 835 | name, ok := names[b.ToolUseID] |
| 801 | 836 | if !ok { |
| @@ -804,7 +839,7 @@ func (a *Agent) emitServerTools(turn int, resp *llm.Response) { | ||
| 804 | 839 | name = llm.WebSearchToolName |
| 805 | 840 | } |
| 806 | 841 | summary, isErr := summarizeWebSearchResult(b) |
| 807 | - a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: name, Result: summary, IsError: isErr}) | |
| 842 | + a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: name, ID: b.ToolUseID, Result: summary, IsError: isErr}) | |
| 808 | 843 | } |
| 809 | 844 | } |
| 810 | 845 | } |
| @@ -855,6 +890,28 @@ func summarizeWebSearchResult(b llm.ContentBlock) (string, bool) { | ||
| 855 | 890 | return strings.Join(lines, "\n"), false |
| 856 | 891 | } |
| 857 | 892 | |
| 893 | + // watching attaches a progress sink to the context a tool runs under, so a | |
| 894 | + // long command's output reaches the front end while it is still running rather | |
| 895 | + // than in one lump at the end. | |
| 896 | + // | |
| 897 | + // The sink is installed only when somebody is observing: a tool that finds | |
| 898 | + // nothing on the context does no extra copying, which keeps the eval runner | |
| 899 | + // and the tests on exactly the path they had before. The mutex is not | |
| 900 | + // decoration — a command's stdout and stderr are copied by separate | |
| 901 | + // goroutines, and an observer written for the loop's own goroutine has no | |
| 902 | + // reason to expect two calls at once. | |
| 903 | + func (a *Agent) watching(ctx context.Context, turn int, call llm.ContentBlock) context.Context { | |
| 904 | + if a.Observe == nil { | |
| 905 | + return ctx | |
| 906 | + } | |
| 907 | + var mu sync.Mutex | |
| 908 | + return tools.WithProgress(ctx, func(chunk string) { | |
| 909 | + mu.Lock() | |
| 910 | + defer mu.Unlock() | |
| 911 | + a.emit(Event{Kind: EventToolDelta, Turn: turn, Tool: call.Name, ID: call.ID, Text: chunk}) | |
| 912 | + }) | |
| 913 | + } | |
| 914 | + | |
| 858 | 915 | // authorizedExecute runs a tool once the approval policy allows it. A denial |
| 859 | 916 | // comes back as an ordinary tool error: the model should read it and change |
| 860 | 917 | // course, not see Codey crash. |
| @@ -981,3 +981,151 @@ func TestSetToolsIsSafeAgainstAReadingTurn(t *testing.T) { | ||
| 981 | 981 | t.Error("SetTools(nil) replaced the tool set") |
| 982 | 982 | } |
| 983 | 983 | } |
| 984 | + | |
| 985 | + func TestToolProgressReachesTheObserver(t *testing.T) { | |
| 986 | + f := newFakeAPI(t, | |
| 987 | + msg("tool_use", toolUseBlock("call-1", "noisy", `{}`)), | |
| 988 | + msg("end_turn", textBlock("done")), | |
| 989 | + ) | |
| 990 | + | |
| 991 | + var logBuf bytes.Buffer | |
| 992 | + log := metrics.NewWriter(&logBuf, "test") | |
| 993 | + state := NewState() | |
| 994 | + reg := tools.NewRegistry(log).Register( | |
| 995 | + tools.New("noisy", "Reports as it goes", func(ctx context.Context, _ struct{}) (string, error) { | |
| 996 | + if p := tools.ProgressFrom(ctx); p != nil { | |
| 997 | + p("half ") | |
| 998 | + p("way") | |
| 999 | + } | |
| 1000 | + return "all of it", nil | |
| 1001 | + }), | |
| 1002 | + ) | |
| 1003 | + client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k")) | |
| 1004 | + a := New(client, reg, state, log, Options{}) | |
| 1005 | + | |
| 1006 | + var mu sync.Mutex | |
| 1007 | + var deltas []Event | |
| 1008 | + a.Observe = func(ev Event) { | |
| 1009 | + if ev.Kind == EventToolDelta { | |
| 1010 | + mu.Lock() | |
| 1011 | + deltas = append(deltas, ev) | |
| 1012 | + mu.Unlock() | |
| 1013 | + } | |
| 1014 | + } | |
| 1015 | + | |
| 1016 | + if _, err := a.Run(context.Background(), "go"); err != nil { | |
| 1017 | + t.Fatalf("Run: %v", err) | |
| 1018 | + } | |
| 1019 | + | |
| 1020 | + mu.Lock() | |
| 1021 | + defer mu.Unlock() | |
| 1022 | + if len(deltas) != 2 { | |
| 1023 | + t.Fatalf("got %d deltas, want 2", len(deltas)) | |
| 1024 | + } | |
| 1025 | + var text strings.Builder | |
| 1026 | + for _, d := range deltas { | |
| 1027 | + if d.Tool != "noisy" || d.ID != "call-1" { | |
| 1028 | + t.Errorf("a delta could not be matched to its call: %+v", d) | |
| 1029 | + } | |
| 1030 | + text.WriteString(d.Text) | |
| 1031 | + } | |
| 1032 | + if text.String() != "half way" { | |
| 1033 | + t.Errorf("deltas reassembled to %q", text.String()) | |
| 1034 | + } | |
| 1035 | + } | |
| 1036 | + | |
| 1037 | + func TestNoObserverMeansNoProgressSink(t *testing.T) { | |
| 1038 | + // A tool asks whether anyone is watching before it does the extra work of | |
| 1039 | + // streaming. With no observer the answer must be no, so the eval runner | |
| 1040 | + // and the -p path stay on exactly the code they were on before. | |
| 1041 | + f := newFakeAPI(t, | |
| 1042 | + msg("tool_use", toolUseBlock("call-1", "checker", `{}`)), | |
| 1043 | + msg("end_turn", textBlock("done")), | |
| 1044 | + ) | |
| 1045 | + | |
| 1046 | + var logBuf bytes.Buffer | |
| 1047 | + log := metrics.NewWriter(&logBuf, "test") | |
| 1048 | + state := NewState() | |
| 1049 | + watched := true | |
| 1050 | + reg := tools.NewRegistry(log).Register( | |
| 1051 | + tools.New("checker", "Reports whether anyone is watching", func(ctx context.Context, _ struct{}) (string, error) { | |
| 1052 | + watched = tools.ProgressFrom(ctx) != nil | |
| 1053 | + return "ok", nil | |
| 1054 | + }), | |
| 1055 | + ) | |
| 1056 | + a := New(llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k")), reg, state, log, Options{}) | |
| 1057 | + | |
| 1058 | + if _, err := a.Run(context.Background(), "go"); err != nil { | |
| 1059 | + t.Fatalf("Run: %v", err) | |
| 1060 | + } | |
| 1061 | + if watched { | |
| 1062 | + t.Error("a sink was installed with no observer to receive it") | |
| 1063 | + } | |
| 1064 | + } | |
| 1065 | + | |
| 1066 | + func thinkingBlock(s string) string { | |
| 1067 | + return `{"type":"thinking","thinking":"` + s + `","signature":"sig"}` | |
| 1068 | + } | |
| 1069 | + | |
| 1070 | + func TestReasoningReachesTheObserver(t *testing.T) { | |
| 1071 | + f := newFakeAPI(t, msg("end_turn", thinkingBlock("the parser is the suspect"), textBlock("it is the parser"))) | |
| 1072 | + | |
| 1073 | + var logBuf bytes.Buffer | |
| 1074 | + a := newTestAgent(t, f, t.TempDir(), &logBuf) | |
| 1075 | + | |
| 1076 | + var thoughts, text strings.Builder | |
| 1077 | + a.Observe = func(ev Event) { | |
| 1078 | + switch ev.Kind { | |
| 1079 | + case EventThinking: | |
| 1080 | + thoughts.WriteString(ev.Text) | |
| 1081 | + case EventText: | |
| 1082 | + text.WriteString(ev.Text) | |
| 1083 | + } | |
| 1084 | + } | |
| 1085 | + | |
| 1086 | + if _, err := a.Run(context.Background(), "why does it fail?"); err != nil { | |
| 1087 | + t.Fatalf("Run: %v", err) | |
| 1088 | + } | |
| 1089 | + if got := thoughts.String(); got != "the parser is the suspect" { | |
| 1090 | + t.Errorf("thinking = %q", got) | |
| 1091 | + } | |
| 1092 | + if got := text.String(); got != "it is the parser" { | |
| 1093 | + t.Errorf("text = %q", got) | |
| 1094 | + } | |
| 1095 | + } | |
| 1096 | + | |
| 1097 | + func TestRedactedReasoningIsNotReported(t *testing.T) { | |
| 1098 | + // Encrypted bytes that go back to the API untouched. There is nothing in | |
| 1099 | + // them to show, and an empty thought chunk is a blank row in a UI. | |
| 1100 | + f := newFakeAPI(t, msg("end_turn", | |
| 1101 | + `{"type":"redacted_thinking","data":"AAAA"}`, textBlock("done"))) | |
| 1102 | + | |
| 1103 | + var logBuf bytes.Buffer | |
| 1104 | + a := newTestAgent(t, f, t.TempDir(), &logBuf) | |
| 1105 | + | |
| 1106 | + var thinking int | |
| 1107 | + a.Observe = func(ev Event) { | |
| 1108 | + if ev.Kind == EventThinking { | |
| 1109 | + thinking++ | |
| 1110 | + } | |
| 1111 | + } | |
| 1112 | + if _, err := a.Run(context.Background(), "go"); err != nil { | |
| 1113 | + t.Fatalf("Run: %v", err) | |
| 1114 | + } | |
| 1115 | + if thinking != 0 { | |
| 1116 | + t.Errorf("%d thinking events for a redacted block", thinking) | |
| 1117 | + } | |
| 1118 | + // And it must still be in the history: the API rejects a follow-up request | |
| 1119 | + // whose redacted block was dropped or altered. | |
| 1120 | + var kept bool | |
| 1121 | + for _, m := range a.State().History() { | |
| 1122 | + for _, b := range m.Content { | |
| 1123 | + if b.Type == llm.BlockRedactedThinking { | |
| 1124 | + kept = true | |
| 1125 | + } | |
| 1126 | + } | |
| 1127 | + } | |
| 1128 | + if !kept { | |
| 1129 | + t.Error("the redacted block was dropped from the conversation") | |
| 1130 | + } | |
| 1131 | + } | |
| @@ -111,7 +111,7 @@ func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.R | ||
| 111 | 111 | if p == nil || p.YOLO || readOnly[tool] { |
| 112 | 112 | return nil |
| 113 | 113 | } |
| 114 | - req := describe(tool, input) | |
| 114 | + req := Describe(tool, input) | |
| 115 | 115 | req.Agent = agent |
| 116 | 116 | |
| 117 | 117 | p.mu.Lock() |
| @@ -209,8 +209,13 @@ func article(word string) string { | ||
| 209 | 209 | return "a" |
| 210 | 210 | } |
| 211 | 211 | |
| 212 | - // describe turns a tool call into something a human can decide on in one read. | |
| 213 | - func describe(tool string, input json.RawMessage) Request { | |
| 212 | + // Describe turns a tool call into something a human can decide on in one read. | |
| 213 | + // | |
| 214 | + // It is exported because the summary is not only for a prompt box: a front end | |
| 215 | + // that shows the agent's tool calls as they happen wants the same one-line | |
| 216 | + // description, and two vocabularies for the same call would be two things to | |
| 217 | + // keep in step. | |
| 218 | + func Describe(tool string, input json.RawMessage) Request { | |
| 214 | 219 | var args struct { |
| 215 | 220 | Command string `json:"command"` |
| 216 | 221 | Background bool `json:"background"` |
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.
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.
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.
Large diffs are not rendered by default.