Vectrum
Sign In

Merge integrate-harness-provider: Codey as an ACP agent inside T3 Code

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>
Jjonicommitted 2 parents 36847de e8d9eadcommite61fb26

Showing 26 changed files with 5976 additions and 28 deletions.

Merge commit — showing changes against the first parent.

+72−1README.mdView File
@@ -1302,6 +1302,75 @@ the only one where pressing enter through the screens does something: the row
13021302 names the exact path before you answer, and the notice afterwards names the
13031303 file that was written. Uninstalling is `rm -r ~/.agents/skills/herdr`.
13041304
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+
13051374 ## Evals
13061375
13071376 ```bash
@@ -1385,12 +1454,14 @@ internal/herdr reporting agent state to the herdr pane Codey is running in
13851454 internal/attach pictures pasted into a prompt: clipboard, files, markers
13861455 internal/metrics JSONL event log
13871456 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
13881459 internal/tui full-screen Bubble Tea front end, and the setup wizard
13891460 evals task suite and runner
13901461 ```
13911462
13921463 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)`
13941465 where the error is text the model reads, not an exception Codey swallows.
13951466 A failed edit is a normal event in the transcript.
13961467
+301−0cmd/codey/agentcmd.goaddedView File
@@ -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+ }
+185−0cmd/codey/agentcmd_test.goaddedView File
@@ -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+ }
+51−2cmd/codey/main.goView File
@@ -24,6 +24,7 @@ import (
2424 "codey/internal/hooks"
2525 "codey/internal/llm"
2626 "codey/internal/metrics"
27+ "codey/internal/serve"
2728 "codey/internal/skills"
2829 "codey/internal/statedir"
2930 "codey/internal/subagent"
@@ -90,7 +91,18 @@ func run() error {
9091 noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
9192 noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr")
9293 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+ }
94106
95107 root, err := filepath.Abs(*workdir)
96108 if err != nil {
@@ -122,6 +134,10 @@ func run() error {
122134 llm.WithAuthToken(*authToken),
123135 }
124136 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
125141
126142 home, err := os.UserHomeDir()
127143 if err != nil {
@@ -144,7 +160,8 @@ func run() error {
144160 }
145161 if src != nil {
146162 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...)
148165 // Not expected to ever trigger: resolveClaudeCode already checked
149166 // the endpoint before we agreed to borrow this credential, but
150167 // that check ran against the client we had before this rebuild.
@@ -158,6 +175,15 @@ func run() error {
158175 systemPrefix = agent.ClaudeCodeIdentity
159176 }
160177 }
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+
161187 if !client.HasCredentials() {
162188 return errNoCredentials
163189 }
@@ -422,6 +448,29 @@ func run() error {
422448 SystemPrefix: systemPrefix,
423449 }
424450
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+
425474 // Subagents are a TUI feature: their reports come back as messages the
426475 // front end puts into the conversation, and the line-based REPL has
427476 // nowhere to show four agents working at once. Offering the tools where
+492−0docs/superpowers/plans/2026-09-19-t3code-provider.mdaddedView File
@@ -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.
+306−0docs/superpowers/specs/2026-09-19-t3code-provider-design.mdaddedView File
@@ -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.
+348−0internal/acp/conn.goaddedView File
@@ -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, &params); 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+ }
+382−0internal/acp/conn_test.goaddedView File
@@ -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+ }
+308−0internal/acp/types.goaddedView File
@@ -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+ )
+87−0internal/acp/wire.goaddedView File
@@ -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+ }
+68−11internal/agent/loop.goView File
@@ -364,6 +364,23 @@ const (
364364 EventNotice EventKind = "notice"
365365 // EventTextDelta is emitted instead of EventText while streaming.
366366 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"
367384 // EventPaused is emitted when the loop stops at a turn boundary, and
368385 // EventResumed when it is let go again.
369386 EventPaused EventKind = "paused"
@@ -373,10 +390,16 @@ const (
373390 // Event is what the loop reports to an observer. It carries plain data only —
374391 // no terminal concerns — so the eval runner can consume the same stream.
375392 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
380403 Input json.RawMessage
381404 Result string
382405 IsError bool
@@ -670,7 +693,16 @@ func (a *Agent) drive(ctx context.Context) (string, error) {
670693 // go quiet for a search and see nothing of what it asked or found.
671694 a.emitServerTools(turn, resp)
672695
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+ }
674706 if text := resp.Text(); text != "" {
675707 last = text
676708 if !a.opts.Stream {
@@ -723,8 +755,11 @@ func (a *Agent) send(ctx context.Context, turn int) (*llm.Response, error) {
723755 return a.client.CreateMessage(ctx, req)
724756 }
725757 return a.client.StreamMessage(ctx, req, func(ev llm.StreamEvent) {
726- if ev.Kind == llm.StreamText {
758+ switch ev.Kind {
759+ case llm.StreamText:
727760 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})
728763 }
729764 })
730765 }
@@ -735,10 +770,10 @@ func (a *Agent) send(ctx context.Context, turn int) (*llm.Response, error) {
735770 func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock) ([]llm.ContentBlock, error) {
736771 results := make([]llm.ContentBlock, 0, len(calls))
737772 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})
739774 a.fire(ctx, hooks.ToolStart, hooks.Payload{Turn: turn, Tool: call.Name})
740775
741- out, err := a.authorizedExecute(ctx, call)
776+ out, err := a.authorizedExecute(a.watching(ctx, turn, call), call)
742777 if ctx.Err() != nil {
743778 return nil, ctx.Err()
744779 }
@@ -767,7 +802,7 @@ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock
767802 } else {
768803 results = append(results, llm.ToolResultBlock(call.ID, payload, isErr))
769804 }
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})
771806 // The result itself is not published: a hook is a bystander, and what
772807 // a tool read out of this repository is not a bystander's business.
773808 // 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) {
795830 switch b.Type {
796831 case llm.BlockServerToolUse:
797832 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})
799834 case llm.BlockWebSearchToolResult:
800835 name, ok := names[b.ToolUseID]
801836 if !ok {
@@ -804,7 +839,7 @@ func (a *Agent) emitServerTools(turn int, resp *llm.Response) {
804839 name = llm.WebSearchToolName
805840 }
806841 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})
808843 }
809844 }
810845 }
@@ -855,6 +890,28 @@ func summarizeWebSearchResult(b llm.ContentBlock) (string, bool) {
855890 return strings.Join(lines, "\n"), false
856891 }
857892
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+
858915 // authorizedExecute runs a tool once the approval policy allows it. A denial
859916 // comes back as an ordinary tool error: the model should read it and change
860917 // course, not see Codey crash.
+148−0internal/agent/loop_test.goView File
@@ -981,3 +981,151 @@ func TestSetToolsIsSafeAgainstAReadingTurn(t *testing.T) {
981981 t.Error("SetTools(nil) replaced the tool set")
982982 }
983983 }
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+ }
+8−3internal/approval/policy.goView File
@@ -111,7 +111,7 @@ func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.R
111111 if p == nil || p.YOLO || readOnly[tool] {
112112 return nil
113113 }
114- req := describe(tool, input)
114+ req := Describe(tool, input)
115115 req.Agent = agent
116116
117117 p.mu.Lock()
@@ -209,8 +209,13 @@ func article(word string) string {
209209 return "a"
210210 }
211211
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 {
214219 var args struct {
215220 Command string `json:"command"`
216221 Background bool `json:"background"`
+9−9internal/approval/policy_test.goView File

Large diffs are not rendered by default.

+15−0internal/llm/types.goView File

Large diffs are not rendered by default.

+247−0internal/serve/agents.goaddedView File

Large diffs are not rendered by default.

+355−0internal/serve/agents_test.goaddedView File

Large diffs are not rendered by default.

+289−0internal/serve/conformance_test.goaddedView File

Large diffs are not rendered by default.

+493−0internal/serve/serve.goaddedView File

Large diffs are not rendered by default.

+663−0internal/serve/serve_test.goaddedView File

Large diffs are not rendered by default.

+163−0internal/serve/session.goaddedView File

Large diffs are not rendered by default.

+409−0internal/serve/update.goaddedView File

Large diffs are not rendered by default.

+421−0internal/serve/update_test.goaddedView File

Large diffs are not rendered by default.

+7−2internal/tools/bash.goView File

Large diffs are not rendered by default.

+56−0internal/tools/progress.goaddedView File

Large diffs are not rendered by default.

+93−0internal/tools/progress_test.goaddedView File

Large diffs are not rendered by default.