Vectrum
Sign In

Phase 5: SSE streaming, interruption, sessions, colour

The fiddly part of streaming is tool arguments: they arrive as
input_json_delta fragments that are individually invalid JSON and only
parseable once concatenated, so they are buffered per block index and
parsed at content_block_stop. A stream that ends mid-fragment is reported
as such rather than dispatched with truncated arguments. Signature deltas
are joined the same way, since a thinking block whose signature is
half-assembled is rejected on the next request.

Ctrl-C cancels the turn in progress and leaves the session intact so the
next message can correct course; a second within two seconds, or one with
nothing running, exits. An interrupted turn is reported as an
interruption, not as an error.

Sessions persist to .harness/sessions and --resume takes an id or
"latest". Restoring sets the usage totals outright rather than adding
them, which would have counted the entire restored history as one turn.

Pricing lives in llm so the REPL's running cost and the eval report cannot
drift apart, and an unpriced model shows nothing rather than $0.0000 — a
made-up number in METRICS.md is worse than a blank.

Diffs render in colour, and the renderer tracks whether streamed text has
left a line open so tool output never lands mid-sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent e8e53e8commitf02316b

Showing 15 changed files with 1076 additions and 64 deletions.

+89−21cmd/harness/main.goView File
@@ -8,8 +8,10 @@ import (
88 "flag"
99 "fmt"
1010 "os"
11+ "os/signal"
1112 "path/filepath"
1213 "strings"
14+ "sync/atomic"
1315 "time"
1416
1517 "harness/internal/agent"
@@ -37,8 +39,10 @@ func run() error {
3739 contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact")
3840 compactAt := flag.Float64("compact-at", agent.DefaultCompactAt, "fraction of the window at which to compact")
3941 noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints")
42+ noStream := flag.Bool("no-stream", false, "wait for each complete response instead of streaming")
4043 logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root")
4144 prompt := flag.String("p", "", "run one prompt and exit, instead of starting the REPL")
45+ resume := flag.String("resume", "", "resume a saved session by id, or \"latest\"")
4246 verbose := flag.Bool("v", false, "print full tool output")
4347 flag.Parse()
4448
@@ -47,15 +51,6 @@ func run() error {
4751 return err
4852 }
4953
50- session := time.Now().UTC().Format("20060102T150405Z")
51- log, err := metrics.Open(filepath.Join(root, *logPath), session)
52- if err != nil {
53- return err
54- }
55- defer log.Close()
56- // Flags win over the environment, which wins over the defaults. Every
57- // option below ignores an empty value, so an unset flag leaves the
58- // environment in place.
5954 client := llm.New(
6055 llm.WithModel(*model),
6156 llm.WithBaseURL(*baseURL),
@@ -65,6 +60,13 @@ func run() error {
6560 if !client.HasCredentials() {
6661 return errNoCredentials
6762 }
63+
64+ session := time.Now().UTC().Format("20060102T150405Z")
65+ log, err := metrics.Open(filepath.Join(root, *logPath), session)
66+ if err != nil {
67+ return err
68+ }
69+ defer log.Close()
6870 log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
6971 "root": root, "model": client.Model, "base_url": client.BaseURL,
7072 }})
@@ -91,15 +93,29 @@ func run() error {
9193 ContextWindow: *contextWindow,
9294 CompactAt: *compactAt,
9395 NoCache: *noCache,
96+ Stream: !*noStream,
9497 })
9598 a.Observe = render.Handle
9699
97- ctx := context.Background()
100+ sessionDir := filepath.Join(root, agent.SessionDir)
101+ if *resume != "" {
102+ prev, err := agent.ResolveSession(sessionDir, *resume)
103+ if err != nil {
104+ return err
105+ }
106+ a.Restore(prev)
107+ session = prev.ID
108+ fmt.Printf("resumed session %s (%d messages, %d turns)\n", prev.ID, len(prev.Messages), prev.Turns)
109+ }
110+
111+ ctx, stopSignals := interruptible(context.Background())
112+ defer stopSignals()
113+
98114 if *prompt != "" {
99- return once(ctx, a, render, *prompt)
115+ return once(ctx, a, render, sessionDir, session, root, client, *prompt)
100116 }
101117
102- fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, or ctrl-d to exit\n\n",
118+ fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n",
103119 client.Model, root, strings.Join(toolNames(reg), ", "))
104120
105121 in := bufio.NewScanner(os.Stdin)
@@ -111,25 +127,77 @@ func run() error {
111127 return in.Err()
112128 }
113129 line := strings.TrimSpace(in.Text())
114- if line == "" {
130+ switch {
131+ case line == "":
115132 continue
116- }
117- if line == "/exit" || line == "/quit" {
133+ case line == "/exit", line == "/quit":
118134 return nil
119135 }
120- if err := once(ctx, a, render, line); err != nil {
136+ if err := once(ctx, a, render, sessionDir, session, root, client, line); err != nil {
121137 render.Error(err)
122138 }
123139 fmt.Println()
124140 }
125141 }
126142
127- // once runs a single prompt and prints the session totals afterwards.
128- func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, prompt string) error {
129- _, err := a.Run(ctx, prompt)
143+ // once runs a single prompt, prints the session totals, and saves the session.
144+ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, id, root string, client *llm.Client, prompt string) error {
145+ turnCtx, endTurn := beginTurn(ctx)
146+ _, runErr := a.Run(turnCtx, prompt)
147+ endTurn()
148+
149+ render.Flush()
130150 usage, turns := a.State().Usage()
131- render.Usage(usage, turns)
132- return err
151+ render.Usage(usage, turns, client.Model)
152+
153+ if err := a.Save(sessionDir, id, root, client.Model); err != nil {
154+ fmt.Fprintln(os.Stderr, "harness: saving session:", err)
155+ }
156+ if errors.Is(runErr, context.Canceled) {
157+ // An interrupted turn is not an error: the point is to leave the
158+ // session intact so the next message can correct course.
159+ fmt.Println("interrupted — the session is intact; say what to do differently")
160+ return nil
161+ }
162+ return runErr
163+ }
164+
165+ // interrupt plumbing. The first ctrl-c cancels the turn in progress; a second
166+ // within two seconds, or one with nothing running, exits.
167+ var (
168+ turnCancel atomic.Pointer[context.CancelFunc]
169+ lastSignal atomic.Int64
170+ )
171+
172+ func interruptible(parent context.Context) (context.Context, func()) {
173+ ch := make(chan os.Signal, 1)
174+ signal.Notify(ch, os.Interrupt)
175+ go func() {
176+ for range ch {
177+ now := time.Now()
178+ prev := lastSignal.Swap(now.UnixNano())
179+ cancel := turnCancel.Load()
180+
181+ if cancel == nil || (prev != 0 && now.Sub(time.Unix(0, prev)) < 2*time.Second) {
182+ fmt.Fprintln(os.Stderr, "\nharness: exiting")
183+ os.Exit(130)
184+ }
185+ fmt.Fprintln(os.Stderr, "\nharness: interrupting this turn (ctrl-c again to exit)")
186+ (*cancel)()
187+ }
188+ }()
189+ return parent, func() { signal.Stop(ch) }
190+ }
191+
192+ // beginTurn makes the running turn cancellable by the signal handler.
193+ func beginTurn(parent context.Context) (context.Context, func()) {
194+ ctx, cancel := context.WithCancel(parent)
195+ turnCancel.Store(&cancel)
196+ return ctx, func() {
197+ turnCancel.Store(nil)
198+ lastSignal.Store(0)
199+ cancel()
200+ }
133201 }
134202
135203 // errNoCredentials spells out every way to supply one, because "unauthorized"
+25−18evals/report.goView File
@@ -6,8 +6,6 @@ import (
66 "sort"
77 "strings"
88 "text/tabwriter"
9-
10- "harness/internal/llm"
119 )
1210
1311 // report prints the numbers METRICS.md wants: the headline pass rate, and
@@ -61,7 +59,7 @@ func report(w io.Writer, results []Result) {
6159 mean(results, func(r Result) float64 { return float64(r.Usage.InputTokens) }),
6260 mean(results, func(r Result) float64 { return float64(r.Usage.CacheReadInputTokens) }),
6361 mean(results, func(r Result) float64 { return float64(r.Usage.OutputTokens) }))
64- fmt.Fprintf(w, "mean cost $%.4f\n", mean(results, func(r Result) float64 { return cost(r.Usage) }))
62+ fmt.Fprintf(w, "mean cost %s\n", costCell(results))
6563 fmt.Fprintf(w, "mean wall %.1fs\n", mean(results, func(r Result) float64 { return float64(r.WallMS) / 1000 }))
6664
6765 fmt.Fprintln(w, "\ntool calls (failures in brackets)")
@@ -78,27 +76,36 @@ func report(w io.Writer, results []Result) {
7876 tw.Flush()
7977
8078 fmt.Fprintln(w, "\nMETRICS.md row:")
81- fmt.Fprintf(w, "| <change> | %d/%d | %s | %.1f | $%.4f |\n",
79+ fmt.Fprintf(w, "| <change> | %d/%d | %s | %.1f | %s |\n",
8280 solved, len(order),
8381 rate(calls["edit_file"]-fails["edit_file"], calls["edit_file"]),
8482 mean(results, func(r Result) float64 { return float64(r.Turns) }),
85- mean(results, func(r Result) float64 { return cost(r.Usage) }))
83+ costCell(results))
8684 }
8785
88- // Per-MTok rates for Claude Opus 5. Cache reads and writes use the standard
89- // 0.1x and 1.25x multipliers. Override these if you are running another model.
90- const (
91- inputPerMTok = 5.0
92- outputPerMTok = 25.0
93- cacheReadPerMTok = inputPerMTok * 0.1
94- cacheWritePerMTok = inputPerMTok * 1.25
95- )
86+ // cost prices one run. Pricing lives in the llm package so the REPL's running
87+ // total and this table cannot drift apart.
88+ func cost(r Result) float64 {
89+ c, _ := r.Usage.Cost(r.Model)
90+ return c
91+ }
92+
93+ // costKnown reports whether every run had priceable usage, so an unpriced
94+ // model produces a blank rather than $0.0000.
95+ func costKnown(results []Result) bool {
96+ for _, r := range results {
97+ if _, ok := r.Usage.Cost(r.Model); !ok {
98+ return false
99+ }
100+ }
101+ return len(results) > 0
102+ }
96103
97- func cost(u llm.Usage) float64 {
98- return (float64(u.InputTokens)*inputPerMTok +
99- float64(u.OutputTokens)*outputPerMTok +
100- float64(u.CacheReadInputTokens)*cacheReadPerMTok +
101- float64(u.CacheCreationInputTokens)*cacheWritePerMTok) / 1_000_000
104+ func costCell(results []Result) string {
105+ if !costKnown(results) {
106+ return "n/a"
107+ }
108+ return fmt.Sprintf("$%.4f", mean(results, cost))
102109 }
103110
104111 func toolStats(results []Result) (calls, fails map[string]int) {
+4−1evals/runner.goView File
@@ -37,6 +37,7 @@ type Task struct {
3737 type Result struct {
3838 Task string `json:"task"`
3939 Run int `json:"run"`
40+ Model string `json:"model"`
4041 Pass bool `json:"pass"`
4142 Turns int `json:"turns"`
4243 Usage llm.Usage `json:"usage"`
@@ -71,6 +72,7 @@ func run() error {
7172 maxTurns := flag.Int("max-turns", 40, "turn cap per run")
7273 contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window")
7374 noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints, to measure what they buy")
75+ noStream := flag.Bool("no-stream", false, "use the non-streaming endpoint")
7476 timeout := flag.Duration("timeout", 10*time.Minute, "wall-clock cap per run")
7577 tasksDir := flag.String("dir", "evals/tasks", "directory of task definitions")
7678 outDir := flag.String("out", "evals/runs", "where to persist transcripts")
@@ -125,6 +127,7 @@ func run() error {
125127 Effort: *effort,
126128 ContextWindow: *contextWindow,
127129 NoCache: *noCache,
130+ Stream: !*noStream,
128131 }, *timeout, *keep)
129132 mu.Lock()
130133 results = append(results, res)
@@ -159,7 +162,7 @@ func run() error {
159162 // The return value is named so the deferred timing assignment lands in the
160163 // value actually returned rather than in a copy already made.
161164 func runOne(task Task, runNo int, client *llm.Client, opts agent.Options, timeout time.Duration, keep bool) (res Result) {
162- res = Result{Task: task.Name, Run: runNo}
165+ res = Result{Task: task.Name, Run: runNo, Model: client.Model}
163166 start := time.Now()
164167 defer func() { res.WallMS = time.Since(start).Milliseconds() }()
165168
+20−2internal/agent/loop.goView File
@@ -50,6 +50,8 @@ type Options struct {
5050 CompactAt float64
5151 // NoCache disables prompt cache breakpoints, for measuring what they buy.
5252 NoCache bool
53+ // Stream uses the SSE endpoint so output appears as it is produced.
54+ Stream bool
5355 }
5456
5557 // EventKind identifies what happened, for an observer to render.
@@ -62,6 +64,8 @@ const (
6264 EventToolResult EventKind = "tool_result"
6365 EventUsage EventKind = "usage"
6466 EventCompact EventKind = "compact"
67+ // EventTextDelta is emitted instead of EventText while streaming.
68+ EventTextDelta EventKind = "text_delta"
6569 )
6670
6771 // Event is what the loop reports to an observer. It carries plain data only —
@@ -129,7 +133,7 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
129133 return "", err
130134 }
131135
132- resp, err := a.client.CreateMessage(ctx, a.request())
136+ resp, err := a.send(ctx, turn)
133137 if err != nil {
134138 return "", err
135139 }
@@ -150,7 +154,8 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
150154 // all: their signatures must come back unchanged on the next request.
151155 a.state.Append(resp.Message())
152156
153- if text := resp.Text(); text != "" {
157+ // While streaming the text has already gone out delta by delta.
158+ if text := resp.Text(); text != "" && !a.opts.Stream {
154159 a.emit(Event{Kind: EventText, Turn: turn, Text: text})
155160 }
156161
@@ -185,6 +190,19 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
185190 ErrMaxTurns, a.opts.MaxTurns)
186191 }
187192
193+ // send performs one model call, streaming when asked to.
194+ func (a *Agent) send(ctx context.Context, turn int) (*llm.Response, error) {
195+ req := a.request()
196+ if !a.opts.Stream {
197+ return a.client.CreateMessage(ctx, req)
198+ }
199+ return a.client.StreamMessage(ctx, req, func(ev llm.StreamEvent) {
200+ if ev.Kind == llm.StreamText {
201+ a.emit(Event{Kind: EventTextDelta, Turn: turn, Text: ev.Text})
202+ }
203+ })
204+ }
205+
188206 // dispatch runs each tool call in order and builds one tool_result per
189207 // tool_use id. A tool that fails still produces a result — a missing one is an
190208 // API error, and the model can usually recover from the error text.
+50−0internal/agent/loop_test.goView File
@@ -394,3 +394,53 @@ func containsKind(kinds []EventKind, want EventKind) bool {
394394 }
395395 return false
396396 }
397+
398+ // streamingAPI serves one scripted SSE response.
399+ func streamingAPI(t *testing.T, events ...string) *httptest.Server {
400+ t.Helper()
401+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
402+ for _, e := range events {
403+ io.WriteString(w, "event: x\ndata: "+e+"\n\n")
404+ }
405+ }))
406+ t.Cleanup(srv.Close)
407+ return srv
408+ }
409+
410+ func TestStreamingRunEmitsDeltas(t *testing.T) {
411+ srv := streamingAPI(t,
412+ `{"type":"message_start","message":{"id":"m","model":"claude-opus-5","usage":{"input_tokens":50}}}`,
413+ `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
414+ `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"All "}}`,
415+ `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"done."}}`,
416+ `{"type":"content_block_stop","index":0}`,
417+ `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
418+ )
419+ state := NewState()
420+ a := New(llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k")),
421+ tools.NewRegistry(nil), state, nil, Options{Stream: true})
422+
423+ var deltas []string
424+ var finalText int
425+ a.Observe = func(ev Event) {
426+ switch ev.Kind {
427+ case EventTextDelta:
428+ deltas = append(deltas, ev.Text)
429+ case EventText:
430+ finalText++
431+ }
432+ }
433+ got, err := a.Run(context.Background(), "go")
434+ if err != nil {
435+ t.Fatalf("Run: %v", err)
436+ }
437+ if got != "All done." {
438+ t.Errorf("answer = %q", got)
439+ }
440+ if strings.Join(deltas, "") != "All done." {
441+ t.Errorf("deltas = %v", deltas)
442+ }
443+ if finalText != 0 {
444+ t.Error("the whole text must not be emitted again after streaming it")
445+ }
446+ }
+102−0internal/agent/session.goaddedView File
@@ -0,0 +1,102 @@
1+ package agent
2+
3+ import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "time"
10+
11+ "harness/internal/llm"
12+ )
13+
14+ // Session is a conversation saved to disk so --resume can pick it up.
15+ type Session struct {
16+ ID string `json:"id"`
17+ Root string `json:"root"`
18+ Model string `json:"model"`
19+ Created time.Time `json:"created"`
20+ Updated time.Time `json:"updated"`
21+ Turns int `json:"turns"`
22+ Usage llm.Usage `json:"usage"`
23+ Messages []llm.Message `json:"messages"`
24+ }
25+
26+ // SessionDir is where sessions live, relative to the working root.
27+ const SessionDir = ".harness/sessions"
28+
29+ // Save writes the session for id, overwriting any previous copy.
30+ func (a *Agent) Save(dir, id, root, model string) error {
31+ if err := os.MkdirAll(dir, 0o755); err != nil {
32+ return err
33+ }
34+ usage, turns := a.state.Usage()
35+ sess := Session{
36+ ID: id,
37+ Root: root,
38+ Model: model,
39+ Created: time.Now().UTC(),
40+ Updated: time.Now().UTC(),
41+ Turns: turns,
42+ Usage: usage,
43+ Messages: a.state.History(),
44+ }
45+ if prev, err := LoadSession(filepath.Join(dir, id+".json")); err == nil {
46+ sess.Created = prev.Created
47+ }
48+ b, err := json.MarshalIndent(sess, "", " ")
49+ if err != nil {
50+ return err
51+ }
52+ return os.WriteFile(filepath.Join(dir, id+".json"), b, 0o644)
53+ }
54+
55+ // LoadSession reads one session file.
56+ func LoadSession(path string) (Session, error) {
57+ var s Session
58+ b, err := os.ReadFile(path)
59+ if err != nil {
60+ return s, err
61+ }
62+ if err := json.Unmarshal(b, &s); err != nil {
63+ return s, fmt.Errorf("session %s: %w", path, err)
64+ }
65+ return s, nil
66+ }
67+
68+ // ResolveSession finds a session by id, or the most recently updated one when
69+ // id is "latest" or empty.
70+ func ResolveSession(dir, id string) (Session, error) {
71+ if id != "" && id != "latest" {
72+ return LoadSession(filepath.Join(dir, id+".json"))
73+ }
74+ entries, err := os.ReadDir(dir)
75+ if err != nil {
76+ return Session{}, fmt.Errorf("no sessions in %s", dir)
77+ }
78+ var files []string
79+ for _, e := range entries {
80+ if !e.IsDir() && filepath.Ext(e.Name()) == ".json" {
81+ files = append(files, filepath.Join(dir, e.Name()))
82+ }
83+ }
84+ if len(files) == 0 {
85+ return Session{}, fmt.Errorf("no sessions in %s", dir)
86+ }
87+ sort.Slice(files, func(i, j int) bool {
88+ a, _ := os.Stat(files[i])
89+ b, _ := os.Stat(files[j])
90+ return a.ModTime().After(b.ModTime())
91+ })
92+ return LoadSession(files[0])
93+ }
94+
95+ // Restore seeds the agent's state from a saved session.
96+ func (a *Agent) Restore(s Session) {
97+ a.state.Replace(s.Messages)
98+ a.state.RestoreUsage(s.Usage, s.Turns)
99+ // The last request's size is unknown after a restore; the next turn's
100+ // response re-establishes it before compaction is consulted again.
101+ a.lastInputTokens = 0
102+ }
+118−0internal/agent/session_test.goaddedView File
@@ -0,0 +1,118 @@
1+ package agent
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "harness/internal/llm"
13+ )
14+
15+ func TestSaveAndResumeSession(t *testing.T) {
16+ dir := t.TempDir()
17+ f := newFakeAPI(t,
18+ msg("tool_use", toolUseBlock("tu_1", "boom", `{}`)),
19+ msg("end_turn", textBlock("first answer")),
20+ )
21+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
22+ if _, err := a.Run(context.Background(), "the original task"); err != nil {
23+ t.Fatalf("Run: %v", err)
24+ }
25+ if err := a.Save(dir, "sess-1", "/repo", "claude-opus-5"); err != nil {
26+ t.Fatalf("Save: %v", err)
27+ }
28+
29+ loaded, err := ResolveSession(dir, "sess-1")
30+ if err != nil {
31+ t.Fatalf("ResolveSession: %v", err)
32+ }
33+ if loaded.Model != "claude-opus-5" || loaded.Root != "/repo" || loaded.Turns != 2 {
34+ t.Errorf("session = %+v", loaded)
35+ }
36+ if len(loaded.Messages) != len(a.State().History()) {
37+ t.Errorf("%d messages saved, want %d", len(loaded.Messages), len(a.State().History()))
38+ }
39+
40+ // A resumed agent continues the same conversation.
41+ f2 := newFakeAPI(t, msg("end_turn", textBlock("second answer")))
42+ b := newTestAgent(t, f2, t.TempDir(), &bytes.Buffer{})
43+ b.Restore(loaded)
44+ if _, err := b.Run(context.Background(), "and now this"); err != nil {
45+ t.Fatalf("Run after restore: %v", err)
46+ }
47+ sent := f2.rawRequest(0)
48+ if !strings.Contains(sent, "the original task") {
49+ t.Errorf("resumed request lost the earlier conversation:\n%s", sent)
50+ }
51+ if !strings.Contains(sent, "and now this") {
52+ t.Error("resumed request is missing the new prompt")
53+ }
54+ if _, turns := b.State().Usage(); turns != 3 {
55+ t.Errorf("turns = %d, want the restored 2 plus 1", turns)
56+ }
57+ }
58+
59+ func TestSavePreservesCreatedTime(t *testing.T) {
60+ dir := t.TempDir()
61+ f := newFakeAPI(t, msg("end_turn", textBlock("x")), msg("end_turn", textBlock("y")))
62+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
63+
64+ a.Run(context.Background(), "one")
65+ a.Save(dir, "s", "/r", "m")
66+ first, _ := LoadSession(filepath.Join(dir, "s.json"))
67+
68+ time.Sleep(5 * time.Millisecond)
69+ a.Run(context.Background(), "two")
70+ a.Save(dir, "s", "/r", "m")
71+ second, _ := LoadSession(filepath.Join(dir, "s.json"))
72+
73+ if !second.Created.Equal(first.Created) {
74+ t.Errorf("created moved from %v to %v across saves", first.Created, second.Created)
75+ }
76+ if !second.Updated.After(first.Updated) {
77+ t.Error("updated should move forward")
78+ }
79+ }
80+
81+ func TestResolveLatestSession(t *testing.T) {
82+ dir := t.TempDir()
83+ for _, id := range []string{"older", "newer"} {
84+ s := Session{ID: id, Messages: []llm.Message{{Role: llm.RoleUser}}}
85+ writeSession(t, dir, s)
86+ time.Sleep(10 * time.Millisecond)
87+ }
88+ got, err := ResolveSession(dir, "latest")
89+ if err != nil {
90+ t.Fatalf("ResolveSession: %v", err)
91+ }
92+ if got.ID != "newer" {
93+ t.Errorf("resolved %q, want the most recently updated", got.ID)
94+ }
95+ if _, err := ResolveSession(t.TempDir(), "latest"); err == nil {
96+ t.Error("an empty session directory should be an error, not an empty session")
97+ }
98+ if _, err := ResolveSession(dir, "nonexistent"); err == nil {
99+ t.Error("an unknown id should be an error")
100+ }
101+ }
102+
103+ func writeSession(t *testing.T, dir string, s Session) {
104+ t.Helper()
105+ a := &Agent{state: NewState()}
106+ a.state.Replace(s.Messages)
107+ if err := a.Save(dir, s.ID, "/r", "m"); err != nil {
108+ t.Fatal(err)
109+ }
110+ }
111+
112+ func TestLoadSessionRejectsGarbage(t *testing.T) {
113+ dir := t.TempDir()
114+ os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{not json"), 0o644)
115+ if _, err := ResolveSession(dir, "broken"); err == nil {
116+ t.Error("want an error for a corrupt session file")
117+ }
118+ }
+9−0internal/agent/state.goView File
@@ -145,6 +145,15 @@ func (s *State) AddUsage(u llm.Usage) {
145145 s.turns++
146146 }
147147
148+ // RestoreUsage sets the running totals outright, which is what resuming a
149+ // saved session needs: AddUsage would count the whole restored history as one
150+ // more turn.
151+ func (s *State) RestoreUsage(u llm.Usage, turns int) {
152+ s.mu.Lock()
153+ defer s.mu.Unlock()
154+ s.usage, s.turns = u, turns
155+ }
156+
148157 // Usage is the running total for the session, and the number of model turns it
149158 // took to get there.
150159 func (s *State) Usage() (llm.Usage, int) {
+1−15internal/llm/client.goView File
@@ -214,21 +214,7 @@ func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) {
214214 return nil, fmt.Errorf("llm: reading response: %w", err)
215215 }
216216 if httpResp.StatusCode != http.StatusOK {
217- apiErr := &APIError{StatusCode: httpResp.StatusCode, Body: string(raw)}
218- var envelope struct {
219- Error struct {
220- Type string `json:"type"`
221- Message string `json:"message"`
222- } `json:"error"`
223- }
224- if json.Unmarshal(raw, &envelope) == nil {
225- apiErr.Type = envelope.Error.Type
226- apiErr.Message = envelope.Error.Message
227- }
228- if ra := httpResp.Header.Get("retry-after"); ra != "" {
229- apiErr.Message += " (retry-after " + ra + "s)"
230- }
231- return nil, apiErr
217+ return nil, parseAPIError(httpResp.StatusCode, httpResp.Header.Get("retry-after"), raw)
232218 }
233219
234220 var out Response
+38−0internal/llm/pricing.goaddedView File
@@ -0,0 +1,38 @@
1+ package llm
2+
3+ // Price is per-million-token list pricing.
4+ type Price struct {
5+ InputPerMTok float64
6+ OutputPerMTok float64
7+ }
8+
9+ // Cache multipliers relative to the input rate.
10+ const (
11+ cacheReadMultiplier = 0.1
12+ cacheWriteMultiplier = 1.25
13+ )
14+
15+ // prices covers the models this harness is likely to be pointed at. A model
16+ // that is not listed reports no cost rather than a wrong one.
17+ var prices = map[string]Price{
18+ "claude-opus-5": {InputPerMTok: 5, OutputPerMTok: 25},
19+ "claude-opus-4-8": {InputPerMTok: 5, OutputPerMTok: 25},
20+ "claude-opus-4-7": {InputPerMTok: 5, OutputPerMTok: 25},
21+ "claude-sonnet-5": {InputPerMTok: 2, OutputPerMTok: 10},
22+ "claude-haiku-4-5": {InputPerMTok: 1, OutputPerMTok: 5},
23+ "claude-fable-5-1": {InputPerMTok: 10, OutputPerMTok: 50},
24+ "claude-fable-5": {InputPerMTok: 10, OutputPerMTok: 50},
25+ }
26+
27+ // Cost prices one usage record. The boolean reports whether the model's
28+ // pricing is known; a made-up number in METRICS.md is worse than a blank.
29+ func (u Usage) Cost(model string) (float64, bool) {
30+ p, ok := prices[model]
31+ if !ok {
32+ return 0, false
33+ }
34+ return (float64(u.InputTokens)*p.InputPerMTok +
35+ float64(u.OutputTokens)*p.OutputPerMTok +
36+ float64(u.CacheReadInputTokens)*p.InputPerMTok*cacheReadMultiplier +
37+ float64(u.CacheCreationInputTokens)*p.InputPerMTok*cacheWriteMultiplier) / 1_000_000, true
38+ }
+278−0internal/llm/stream.goaddedView File
@@ -0,0 +1,278 @@
1+ package llm
2+
3+ import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+ )
13+
14+ // StreamEventKind is what a streaming callback is being told about.
15+ type StreamEventKind string
16+
17+ const (
18+ StreamText StreamEventKind = "text"
19+ StreamThinking StreamEventKind = "thinking"
20+ StreamToolStart StreamEventKind = "tool_start"
21+ StreamToolInput StreamEventKind = "tool_input"
22+ StreamBlockStop StreamEventKind = "block_stop"
23+ )
24+
25+ // StreamEvent is one incremental update.
26+ type StreamEvent struct {
27+ Kind StreamEventKind
28+ Index int
29+ Text string // the delta, not the accumulated value
30+ ToolID string
31+ ToolName string
32+ }
33+
34+ // StreamMessage sends a streaming request, calls onEvent for each delta, and
35+ // returns the assembled response. The callback may be nil.
36+ func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(StreamEvent)) (*Response, error) {
37+ if !c.HasCredentials() {
38+ return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`")
39+ }
40+ if req.Model == "" {
41+ req.Model = c.Model
42+ }
43+ if req.MaxTokens == 0 {
44+ req.MaxTokens = c.MaxTokens
45+ }
46+ req.Stream = true
47+
48+ body, err := json.Marshal(req)
49+ if err != nil {
50+ return nil, fmt.Errorf("llm: encoding request: %w", err)
51+ }
52+
53+ var lastErr error
54+ for attempt := 0; attempt <= c.MaxRetries; attempt++ {
55+ if attempt > 0 {
56+ if err := sleep(ctx, c.backoff(attempt, lastErr)); err != nil {
57+ return nil, err
58+ }
59+ }
60+ resp, err := c.streamAttempt(ctx, body, onEvent)
61+ if err == nil {
62+ return resp, nil
63+ }
64+ lastErr = err
65+ if ctx.Err() != nil {
66+ return nil, ctx.Err()
67+ }
68+ var apiErr *APIError
69+ if ok := asAPIError(err, &apiErr); ok && !apiErr.Retryable() {
70+ return nil, err
71+ }
72+ // A stream that broke partway is not safely retryable either: the
73+ // caller may already have seen deltas.
74+ if _, isAPI := err.(*APIError); !isAPI {
75+ return nil, err
76+ }
77+ }
78+ return nil, lastErr
79+ }
80+
81+ func (c *Client) streamAttempt(ctx context.Context, body []byte, onEvent func(StreamEvent)) (*Response, error) {
82+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body))
83+ if err != nil {
84+ return nil, err
85+ }
86+ httpReq.Header.Set("content-type", "application/json")
87+ httpReq.Header.Set("accept", "text/event-stream")
88+ httpReq.Header.Set("anthropic-version", apiVersion)
89+ if c.apiKey != "" {
90+ httpReq.Header.Set("x-api-key", c.apiKey)
91+ } else {
92+ httpReq.Header.Set("authorization", "Bearer "+c.authToken)
93+ httpReq.Header.Set("anthropic-beta", oauthBeta)
94+ }
95+
96+ httpResp, err := c.HTTP.Do(httpReq)
97+ if err != nil {
98+ return nil, fmt.Errorf("llm: %w", err)
99+ }
100+ defer httpResp.Body.Close()
101+
102+ if httpResp.StatusCode != http.StatusOK {
103+ raw, _ := io.ReadAll(httpResp.Body)
104+ return nil, parseAPIError(httpResp.StatusCode, httpResp.Header.Get("retry-after"), raw)
105+ }
106+ return readStream(httpResp.Body, onEvent)
107+ }
108+
109+ // blockBuilder accumulates one content block across its deltas. Tool input
110+ // arrives as a series of JSON fragments that are only valid once concatenated,
111+ // which is the fiddly part of streaming.
112+ type blockBuilder struct {
113+ block ContentBlock
114+ json strings.Builder
115+ }
116+
117+ func readStream(r io.Reader, onEvent func(StreamEvent)) (*Response, error) {
118+ emit := func(ev StreamEvent) {
119+ if onEvent != nil {
120+ onEvent(ev)
121+ }
122+ }
123+
124+ resp := &Response{Role: RoleAssistant}
125+ blocks := map[int]*blockBuilder{}
126+ var order []int
127+
128+ sc := bufio.NewScanner(r)
129+ sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
130+ for sc.Scan() {
131+ line := sc.Text()
132+ if !strings.HasPrefix(line, "data:") {
133+ continue // "event:" lines are redundant; the payload names its type
134+ }
135+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
136+ if payload == "" || payload == "[DONE]" {
137+ continue
138+ }
139+
140+ var ev struct {
141+ Type string `json:"type"`
142+ Index int `json:"index"`
143+ Message *struct {
144+ ID string `json:"id"`
145+ Model string `json:"model"`
146+ Usage Usage `json:"usage"`
147+ } `json:"message"`
148+ ContentBlock *ContentBlock `json:"content_block"`
149+ Delta *struct {
150+ Type string `json:"type"`
151+ Text string `json:"text"`
152+ PartialJSON string `json:"partial_json"`
153+ Thinking string `json:"thinking"`
154+ Signature string `json:"signature"`
155+ StopReason string `json:"stop_reason"`
156+ StopDetails *StopDetails `json:"stop_details"`
157+ } `json:"delta"`
158+ Usage *Usage `json:"usage"`
159+ Error *struct {
160+ Type string `json:"type"`
161+ Message string `json:"message"`
162+ } `json:"error"`
163+ }
164+ if err := json.Unmarshal([]byte(payload), &ev); err != nil {
165+ return nil, fmt.Errorf("llm: malformed stream event: %w", err)
166+ }
167+
168+ switch ev.Type {
169+ case "error":
170+ if ev.Error != nil {
171+ return nil, &APIError{StatusCode: 200, Type: ev.Error.Type, Message: ev.Error.Message}
172+ }
173+ return nil, fmt.Errorf("llm: stream error")
174+
175+ case "message_start":
176+ if ev.Message != nil {
177+ resp.ID, resp.Model, resp.Usage = ev.Message.ID, ev.Message.Model, ev.Message.Usage
178+ }
179+
180+ case "content_block_start":
181+ if ev.ContentBlock == nil {
182+ continue
183+ }
184+ blocks[ev.Index] = &blockBuilder{block: *ev.ContentBlock}
185+ order = append(order, ev.Index)
186+ if ev.ContentBlock.Type == BlockToolUse {
187+ emit(StreamEvent{Kind: StreamToolStart, Index: ev.Index,
188+ ToolID: ev.ContentBlock.ID, ToolName: ev.ContentBlock.Name})
189+ }
190+
191+ case "content_block_delta":
192+ b, ok := blocks[ev.Index]
193+ if !ok || ev.Delta == nil {
194+ continue
195+ }
196+ switch ev.Delta.Type {
197+ case "text_delta":
198+ b.block.Text += ev.Delta.Text
199+ emit(StreamEvent{Kind: StreamText, Index: ev.Index, Text: ev.Delta.Text})
200+ case "thinking_delta":
201+ b.block.Thinking += ev.Delta.Thinking
202+ emit(StreamEvent{Kind: StreamThinking, Index: ev.Index, Text: ev.Delta.Thinking})
203+ case "signature_delta":
204+ b.block.Signature += ev.Delta.Signature
205+ case "input_json_delta":
206+ // Fragments are not individually parseable; buffer and parse
207+ // the whole thing at content_block_stop.
208+ b.json.WriteString(ev.Delta.PartialJSON)
209+ emit(StreamEvent{Kind: StreamToolInput, Index: ev.Index, Text: ev.Delta.PartialJSON})
210+ }
211+
212+ case "content_block_stop":
213+ b, ok := blocks[ev.Index]
214+ if !ok {
215+ continue
216+ }
217+ if s := b.json.String(); s != "" {
218+ if !json.Valid([]byte(s)) {
219+ return nil, fmt.Errorf("llm: tool %q streamed invalid JSON arguments: %s", b.block.Name, s)
220+ }
221+ b.block.Input = json.RawMessage(s)
222+ }
223+ emit(StreamEvent{Kind: StreamBlockStop, Index: ev.Index})
224+
225+ case "message_delta":
226+ if ev.Delta != nil {
227+ resp.StopReason = ev.Delta.StopReason
228+ if ev.Delta.StopDetails != nil {
229+ resp.StopDetails = ev.Delta.StopDetails
230+ }
231+ }
232+ if ev.Usage != nil {
233+ // message_delta carries the final output count; the input
234+ // counts arrived with message_start.
235+ resp.Usage.OutputTokens = ev.Usage.OutputTokens
236+ if ev.Usage.InputTokens > 0 {
237+ resp.Usage.InputTokens = ev.Usage.InputTokens
238+ }
239+ }
240+ }
241+ }
242+ if err := sc.Err(); err != nil {
243+ return nil, fmt.Errorf("llm: reading stream: %w", err)
244+ }
245+
246+ for _, i := range order {
247+ if b := blocks[i]; b != nil {
248+ // A tool_use with no input at all still needs a valid object.
249+ if b.block.Type == BlockToolUse && len(b.block.Input) == 0 {
250+ b.block.Input = json.RawMessage("{}")
251+ }
252+ resp.Content = append(resp.Content, b.block)
253+ }
254+ }
255+ if resp.StopReason == "" {
256+ return nil, fmt.Errorf("llm: stream ended without a stop_reason")
257+ }
258+ return resp, nil
259+ }
260+
261+ // parseAPIError builds an APIError from a non-2xx body.
262+ func parseAPIError(status int, retryAfterHeader string, raw []byte) *APIError {
263+ apiErr := &APIError{StatusCode: status, Body: string(raw)}
264+ var envelope struct {
265+ Error struct {
266+ Type string `json:"type"`
267+ Message string `json:"message"`
268+ } `json:"error"`
269+ }
270+ if json.Unmarshal(raw, &envelope) == nil {
271+ apiErr.Type = envelope.Error.Type
272+ apiErr.Message = envelope.Error.Message
273+ }
274+ if retryAfterHeader != "" {
275+ apiErr.Message += " (retry-after " + retryAfterHeader + "s)"
276+ }
277+ return apiErr
278+ }
+209−0internal/llm/stream_test.goaddedView File
@@ -0,0 +1,209 @@
1+ package llm
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+ "time"
12+ )
13+
14+ // sse formats events the way the API does.
15+ func sse(events ...string) string {
16+ var b strings.Builder
17+ for _, e := range events {
18+ b.WriteString("event: x\ndata: " + e + "\n\n")
19+ }
20+ return b.String()
21+ }
22+
23+ func streamServer(t *testing.T, payload string) *Client {
24+ t.Helper()
25+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+ if r.Header.Get("accept") != "text/event-stream" {
27+ t.Errorf("accept = %q", r.Header.Get("accept"))
28+ }
29+ var req map[string]any
30+ body, _ := io.ReadAll(r.Body)
31+ json.Unmarshal(body, &req)
32+ if req["stream"] != true {
33+ t.Errorf("stream flag not set in body: %s", body)
34+ }
35+ w.Header().Set("content-type", "text/event-stream")
36+ io.WriteString(w, payload)
37+ }))
38+ t.Cleanup(srv.Close)
39+ c := New(WithBaseURL(srv.URL), WithAPIKey("k"))
40+ c.RetryDelay = time.Millisecond
41+ return c
42+ }
43+
44+ // The fiddly part: tool arguments arrive as fragments that are individually
45+ // invalid JSON and only parseable once concatenated.
46+ func TestStreamAccumulatesToolInputFragments(t *testing.T) {
47+ c := streamServer(t, sse(
48+ `{"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5","usage":{"input_tokens":100,"cache_read_input_tokens":40}}}`,
49+ `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
50+ `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me "}}`,
51+ `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"look."}}`,
52+ `{"type":"content_block_stop","index":0}`,
53+ `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tu_1","name":"edit_file","input":{}}}`,
54+ `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}`,
55+ `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"main.go\",\"old_str\""}}`,
56+ `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":":\"a\",\"new_str\":\"b\"}"}}`,
57+ `{"type":"content_block_stop","index":1}`,
58+ `{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":55}}`,
59+ `{"type":"message_stop"}`,
60+ ))
61+
62+ var textSeen strings.Builder
63+ var toolStarts int
64+ resp, err := c.StreamMessage(context.Background(), Request{}, func(ev StreamEvent) {
65+ switch ev.Kind {
66+ case StreamText:
67+ textSeen.WriteString(ev.Text)
68+ case StreamToolStart:
69+ toolStarts++
70+ if ev.ToolName != "edit_file" || ev.ToolID != "tu_1" {
71+ t.Errorf("tool start = %+v", ev)
72+ }
73+ }
74+ })
75+ if err != nil {
76+ t.Fatalf("StreamMessage: %v", err)
77+ }
78+ if textSeen.String() != "Let me look." {
79+ t.Errorf("streamed text = %q", textSeen.String())
80+ }
81+ if toolStarts != 1 {
82+ t.Errorf("%d tool starts, want 1", toolStarts)
83+ }
84+ if resp.StopReason != StopToolUse {
85+ t.Errorf("stop_reason = %q", resp.StopReason)
86+ }
87+ if got := resp.Text(); got != "Let me look." {
88+ t.Errorf("assembled text = %q", got)
89+ }
90+
91+ uses := resp.ToolUses()
92+ if len(uses) != 1 {
93+ t.Fatalf("%d tool uses, want 1", len(uses))
94+ }
95+ var args struct{ Path, OldStr, NewStr string }
96+ if err := json.Unmarshal(uses[0].Input, &struct {
97+ Path *string `json:"path"`
98+ OldStr *string `json:"old_str"`
99+ NewStr *string `json:"new_str"`
100+ }{&args.Path, &args.OldStr, &args.NewStr}); err != nil {
101+ t.Fatalf("reassembled input is not valid JSON: %v (%s)", err, uses[0].Input)
102+ }
103+ if args.Path != "main.go" || args.OldStr != "a" || args.NewStr != "b" {
104+ t.Errorf("reassembled args = %+v", args)
105+ }
106+ if resp.Usage.InputTokens != 100 || resp.Usage.CacheReadInputTokens != 40 || resp.Usage.OutputTokens != 55 {
107+ t.Errorf("usage = %+v: input counts come from message_start, output from message_delta", resp.Usage)
108+ }
109+ }
110+
111+ func TestStreamPreservesThinkingSignature(t *testing.T) {
112+ c := streamServer(t, sse(
113+ `{"type":"message_start","message":{"id":"m","usage":{"input_tokens":1}}}`,
114+ `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`,
115+ `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-"}}`,
116+ `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"abc"}}`,
117+ `{"type":"content_block_stop","index":0}`,
118+ `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}`,
119+ ))
120+ resp, err := c.StreamMessage(context.Background(), Request{}, nil)
121+ if err != nil {
122+ t.Fatalf("StreamMessage: %v", err)
123+ }
124+ if len(resp.Content) != 1 || resp.Content[0].Signature != "sig-abc" {
125+ t.Errorf("signature = %q, want the fragments joined", resp.Content[0].Signature)
126+ }
127+ out, _ := json.Marshal(resp.Content[0])
128+ if !strings.Contains(string(out), `"signature":"sig-abc"`) {
129+ t.Errorf("signature must survive re-marshalling: %s", out)
130+ }
131+ }
132+
133+ func TestStreamErrors(t *testing.T) {
134+ tests := []struct {
135+ name string
136+ payload string
137+ want string
138+ }{
139+ {
140+ name: "mid-stream error event",
141+ payload: sse(
142+ `{"type":"message_start","message":{"id":"m"}}`,
143+ `{"type":"error","error":{"type":"overloaded_error","message":"try later"}}`),
144+ want: "try later",
145+ },
146+ {
147+ name: "truncated tool arguments",
148+ payload: sse(
149+ `{"type":"message_start","message":{"id":"m"}}`,
150+ `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t","name":"bash"}}`,
151+ `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\":"}}`,
152+ `{"type":"content_block_stop","index":0}`,
153+ `{"type":"message_delta","delta":{"stop_reason":"tool_use"}}`),
154+ want: "invalid JSON arguments",
155+ },
156+ {
157+ name: "stream ends without a stop reason",
158+ payload: sse(`{"type":"message_start","message":{"id":"m"}}`),
159+ want: "without a stop_reason",
160+ },
161+ {
162+ name: "malformed event",
163+ payload: "data: {not json\n\n",
164+ want: "malformed stream event",
165+ },
166+ }
167+ for _, tc := range tests {
168+ t.Run(tc.name, func(t *testing.T) {
169+ _, err := streamServer(t, tc.payload).StreamMessage(context.Background(), Request{}, nil)
170+ if err == nil || !strings.Contains(err.Error(), tc.want) {
171+ t.Errorf("error = %v, want it to mention %q", err, tc.want)
172+ }
173+ })
174+ }
175+ }
176+
177+ func TestStreamToolUseWithNoArguments(t *testing.T) {
178+ c := streamServer(t, sse(
179+ `{"type":"message_start","message":{"id":"m"}}`,
180+ `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t","name":"pwd"}}`,
181+ `{"type":"content_block_stop","index":0}`,
182+ `{"type":"message_delta","delta":{"stop_reason":"tool_use"}}`,
183+ ))
184+ resp, err := c.StreamMessage(context.Background(), Request{}, nil)
185+ if err != nil {
186+ t.Fatalf("StreamMessage: %v", err)
187+ }
188+ if got := string(resp.ToolUses()[0].Input); got != "{}" {
189+ t.Errorf("input = %s, want an empty object", got)
190+ }
191+ }
192+
193+ func TestStreamHTTPErrorIsAnAPIError(t *testing.T) {
194+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195+ w.WriteHeader(http.StatusBadRequest)
196+ io.WriteString(w, `{"error":{"type":"invalid_request_error","message":"bad model"}}`)
197+ }))
198+ defer srv.Close()
199+
200+ c := New(WithBaseURL(srv.URL), WithAPIKey("k"))
201+ _, err := c.StreamMessage(context.Background(), Request{}, nil)
202+ apiErr, ok := err.(*APIError)
203+ if !ok {
204+ t.Fatalf("error type = %T, want *APIError", err)
205+ }
206+ if apiErr.StatusCode != 400 || apiErr.Message != "bad model" {
207+ t.Errorf("APIError = %+v", apiErr)
208+ }
209+ }
+46−0internal/ui/diff.goaddedView File
@@ -0,0 +1,46 @@
1+ package ui
2+
3+ import (
4+ "fmt"
5+ "io"
6+ "strings"
7+ )
8+
9+ // diff colours are separate from the palette so a unified diff reads the same
10+ // way it does in git.
11+ var (
12+ green = "\x1b[32m"
13+ red = "\x1b[31m"
14+ )
15+
16+ func init() {
17+ if color.reset == "" { // not a terminal, or NO_COLOR
18+ green, red = "", ""
19+ }
20+ }
21+
22+ // WriteDiff renders a unified diff with colour. Anything that is not a diff is
23+ // written through unchanged, so a tool result that is a plain message still
24+ // reads normally.
25+ func WriteDiff(w io.Writer, s string) {
26+ for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
27+ switch {
28+ case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"):
29+ fmt.Fprintf(w, "%s%s%s\n", color.bold, line, color.reset)
30+ case strings.HasPrefix(line, "@@"):
31+ fmt.Fprintf(w, "%s%s%s\n", color.cyan, line, color.reset)
32+ case strings.HasPrefix(line, "+"):
33+ fmt.Fprintf(w, "%s%s%s\n", green, line, color.reset)
34+ case strings.HasPrefix(line, "-"):
35+ fmt.Fprintf(w, "%s%s%s\n", red, line, color.reset)
36+ default:
37+ fmt.Fprintf(w, "%s\n", line)
38+ }
39+ }
40+ }
41+
42+ // looksLikeDiff reports whether s is a unified diff, so tool output can be
43+ // rendered appropriately without the tool having to say which it is.
44+ func looksLikeDiff(s string) bool {
45+ return strings.HasPrefix(s, "--- ") && strings.Contains(s, "\n@@ ")
46+ }
+37−4internal/ui/render.goView File
@@ -31,8 +31,21 @@ var color = func() palette {
3131 type Renderer struct {
3232 W io.Writer
3333 Verbose bool // print full tool results rather than a summary line
34+
35+ midLine bool // streamed text is mid-line and needs a newline before anything else
36+ }
37+
38+ // endLine closes a partially written line of streamed text.
39+ func (r *Renderer) endLine() {
40+ if r.midLine {
41+ fmt.Fprintln(r.W)
42+ r.midLine = false
43+ }
3444 }
3545
46+ // Flush closes any open line. Call it when a turn finishes.
47+ func (r *Renderer) Flush() { r.endLine() }
48+
3649 func New(w io.Writer) *Renderer { return &Renderer{W: w} }
3750
3851 // Handle is the agent.Observe callback.
@@ -40,16 +53,30 @@ func (r *Renderer) Handle(ev agent.Event) {
4053 switch ev.Kind {
4154 case agent.EventText:
4255 fmt.Fprintf(r.W, "%s\n", strings.TrimSpace(ev.Text))
56+ case agent.EventTextDelta:
57+ // Streamed text arrives mid-line; the newline comes when the tool call
58+ // or the turn ends.
59+ fmt.Fprint(r.W, ev.Text)
60+ r.midLine = true
4361 case agent.EventToolStart:
62+ r.endLine()
4463 fmt.Fprintf(r.W, "%s⏺ %s%s%s(%s)%s\n", color.cyan, color.bold, ev.Tool, color.reset, summarizeInput(ev.Input), color.reset)
4564 case agent.EventToolResult:
65+ r.endLine()
4666 r.tool(ev)
4767 case agent.EventCompact:
68+ r.endLine()
4869 fmt.Fprintf(r.W, "%s[compacted the conversation]%s\n", color.dim, color.reset)
4970 }
5071 }
5172
5273 func (r *Renderer) tool(ev agent.Event) {
74+ // A diff is the one tool result worth showing in full and in colour: it is
75+ // the record of what actually changed.
76+ if !ev.IsError && looksLikeDiff(ev.Result) {
77+ WriteDiff(r.W, ev.Result)
78+ return
79+ }
5380 prefix, c := " ⎿ ", color.dim
5481 if ev.IsError {
5582 prefix, c = " ⎿ error: ", color.red
@@ -99,13 +126,19 @@ func firstLines(s string, n int) string {
99126 return strings.Join(lines[:n], "\n") + fmt.Sprintf("\n… %d more lines", len(lines)-n)
100127 }
101128
102- // Usage prints the running token totals for the session.
103- func (r *Renderer) Usage(u llm.Usage, turns int) {
104- fmt.Fprintf(r.W, "%s%d turns · in %d (%d cached) · out %d%s\n",
105- color.dim, turns, u.InputTokens, u.CacheReadInputTokens, u.OutputTokens, color.reset)
129+ // Usage prints the running token and cost totals for the session.
130+ func (r *Renderer) Usage(u llm.Usage, turns int, model string) {
131+ r.endLine()
132+ line := fmt.Sprintf("%d turns · in %d (%d cached) · out %d",
133+ turns, u.InputTokens, u.CacheReadInputTokens, u.OutputTokens)
134+ if cost, known := u.Cost(model); known {
135+ line += fmt.Sprintf(" · $%.4f", cost)
136+ }
137+ fmt.Fprintf(r.W, "%s%s%s\n", color.dim, line, color.reset)
106138 }
107139
108140 // Error prints a failure.
109141 func (r *Renderer) Error(err error) {
142+ r.endLine()
110143 fmt.Fprintf(r.W, "%serror: %v%s\n", color.red, err, color.reset)
111144 }
+50−3internal/ui/render_test.goView File
@@ -56,8 +56,55 @@ func TestErrorResultIsLabelled(t *testing.T) {
5656
5757 func TestUsageLine(t *testing.T) {
5858 var buf bytes.Buffer
59- New(&buf).Usage(llm.Usage{InputTokens: 1200, OutputTokens: 300, CacheReadInputTokens: 900}, 4)
60- if got := buf.String(); !strings.Contains(got, "4 turns") || !strings.Contains(got, "900 cached") {
61- t.Errorf("got %q, want turns and cache reads", got)
59+ New(&buf).Usage(llm.Usage{InputTokens: 1200, OutputTokens: 300, CacheReadInputTokens: 900}, 4, "claude-opus-5")
60+ got := buf.String()
61+ for _, want := range []string{"4 turns", "900 cached", "$"} {
62+ if !strings.Contains(got, want) {
63+ t.Errorf("usage line %q is missing %q", got, want)
64+ }
65+ }
66+ }
67+
68+ func TestUsageOmitsCostForAnUnknownModel(t *testing.T) {
69+ var buf bytes.Buffer
70+ New(&buf).Usage(llm.Usage{InputTokens: 10}, 1, "some-gateway-model")
71+ if strings.Contains(buf.String(), "$") {
72+ t.Errorf("got %q: a made-up cost is worse than none", buf.String())
73+ }
74+ }
75+
76+ func TestStreamedTextIsClosedBeforeOtherOutput(t *testing.T) {
77+ var buf bytes.Buffer
78+ r := New(&buf)
79+ r.Handle(agent.Event{Kind: agent.EventTextDelta, Text: "Let me "})
80+ r.Handle(agent.Event{Kind: agent.EventTextDelta, Text: "look."})
81+ r.Handle(agent.Event{Kind: agent.EventToolStart, Tool: "read_file", Input: []byte(`{"path":"a.go"}`)})
82+
83+ if !strings.HasPrefix(buf.String(), "Let me look.\n") {
84+ t.Errorf("streamed text should be flushed onto its own line, got %q", buf.String())
85+ }
86+ }
87+
88+ func TestDiffResultIsRenderedAsADiff(t *testing.T) {
89+ var buf bytes.Buffer
90+ diff := "--- a.go\n+++ a.go\n@@ -1,2 +1,2 @@\n-old\n+new"
91+ New(&buf).Handle(agent.Event{Kind: agent.EventToolResult, Tool: "edit_file", Result: diff})
92+ got := buf.String()
93+ if strings.Contains(got, "⎿") {
94+ t.Errorf("a diff should not be summarized like ordinary tool output:\n%s", got)
95+ }
96+ for _, want := range []string{"-old", "+new", "@@ -1,2 +1,2 @@"} {
97+ if !strings.Contains(got, want) {
98+ t.Errorf("diff missing %q:\n%s", want, got)
99+ }
100+ }
101+ }
102+
103+ func TestLooksLikeDiff(t *testing.T) {
104+ if !looksLikeDiff("--- a.go\n+++ a.go\n@@ -1 +1 @@\n-x\n+y") {
105+ t.Error("a unified diff should be recognised")
106+ }
107+ if looksLikeDiff("Created a.go (12 bytes, 1 lines).") {
108+ t.Error("ordinary tool output should not be treated as a diff")
62109 }
63110 }