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>
Showing 15 changed files with 1076 additions and 64 deletions.
| @@ -8,8 +8,10 @@ import ( | ||
| 8 | 8 | "flag" |
| 9 | 9 | "fmt" |
| 10 | 10 | "os" |
| 11 | + "os/signal" | |
| 11 | 12 | "path/filepath" |
| 12 | 13 | "strings" |
| 14 | + "sync/atomic" | |
| 13 | 15 | "time" |
| 14 | 16 | |
| 15 | 17 | "harness/internal/agent" |
| @@ -37,8 +39,10 @@ func run() error { | ||
| 37 | 39 | contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact") |
| 38 | 40 | compactAt := flag.Float64("compact-at", agent.DefaultCompactAt, "fraction of the window at which to compact") |
| 39 | 41 | 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") | |
| 40 | 43 | logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root") |
| 41 | 44 | 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\"") | |
| 42 | 46 | verbose := flag.Bool("v", false, "print full tool output") |
| 43 | 47 | flag.Parse() |
| 44 | 48 | |
| @@ -47,15 +51,6 @@ func run() error { | ||
| 47 | 51 | return err |
| 48 | 52 | } |
| 49 | 53 | |
| 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. | |
| 59 | 54 | client := llm.New( |
| 60 | 55 | llm.WithModel(*model), |
| 61 | 56 | llm.WithBaseURL(*baseURL), |
| @@ -65,6 +60,13 @@ func run() error { | ||
| 65 | 60 | if !client.HasCredentials() { |
| 66 | 61 | return errNoCredentials |
| 67 | 62 | } |
| 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() | |
| 68 | 70 | log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{ |
| 69 | 71 | "root": root, "model": client.Model, "base_url": client.BaseURL, |
| 70 | 72 | }}) |
| @@ -91,15 +93,29 @@ func run() error { | ||
| 91 | 93 | ContextWindow: *contextWindow, |
| 92 | 94 | CompactAt: *compactAt, |
| 93 | 95 | NoCache: *noCache, |
| 96 | + Stream: !*noStream, | |
| 94 | 97 | }) |
| 95 | 98 | a.Observe = render.Handle |
| 96 | 99 | |
| 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 | + | |
| 98 | 114 | if *prompt != "" { |
| 99 | - return once(ctx, a, render, *prompt) | |
| 115 | + return once(ctx, a, render, sessionDir, session, root, client, *prompt) | |
| 100 | 116 | } |
| 101 | 117 | |
| 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", | |
| 103 | 119 | client.Model, root, strings.Join(toolNames(reg), ", ")) |
| 104 | 120 | |
| 105 | 121 | in := bufio.NewScanner(os.Stdin) |
| @@ -111,25 +127,77 @@ func run() error { | ||
| 111 | 127 | return in.Err() |
| 112 | 128 | } |
| 113 | 129 | line := strings.TrimSpace(in.Text()) |
| 114 | - if line == "" { | |
| 130 | + switch { | |
| 131 | + case line == "": | |
| 115 | 132 | continue |
| 116 | - } | |
| 117 | - if line == "/exit" || line == "/quit" { | |
| 133 | + case line == "/exit", line == "/quit": | |
| 118 | 134 | return nil |
| 119 | 135 | } |
| 120 | - if err := once(ctx, a, render, line); err != nil { | |
| 136 | + if err := once(ctx, a, render, sessionDir, session, root, client, line); err != nil { | |
| 121 | 137 | render.Error(err) |
| 122 | 138 | } |
| 123 | 139 | fmt.Println() |
| 124 | 140 | } |
| 125 | 141 | } |
| 126 | 142 | |
| 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() | |
| 130 | 150 | 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 | + } | |
| 133 | 201 | } |
| 134 | 202 | |
| 135 | 203 | // errNoCredentials spells out every way to supply one, because "unauthorized" |
| @@ -6,8 +6,6 @@ import ( | ||
| 6 | 6 | "sort" |
| 7 | 7 | "strings" |
| 8 | 8 | "text/tabwriter" |
| 9 | - | |
| 10 | - "harness/internal/llm" | |
| 11 | 9 | ) |
| 12 | 10 | |
| 13 | 11 | // report prints the numbers METRICS.md wants: the headline pass rate, and |
| @@ -61,7 +59,7 @@ func report(w io.Writer, results []Result) { | ||
| 61 | 59 | mean(results, func(r Result) float64 { return float64(r.Usage.InputTokens) }), |
| 62 | 60 | mean(results, func(r Result) float64 { return float64(r.Usage.CacheReadInputTokens) }), |
| 63 | 61 | 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)) | |
| 65 | 63 | fmt.Fprintf(w, "mean wall %.1fs\n", mean(results, func(r Result) float64 { return float64(r.WallMS) / 1000 })) |
| 66 | 64 | |
| 67 | 65 | fmt.Fprintln(w, "\ntool calls (failures in brackets)") |
| @@ -78,27 +76,36 @@ func report(w io.Writer, results []Result) { | ||
| 78 | 76 | tw.Flush() |
| 79 | 77 | |
| 80 | 78 | 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", | |
| 82 | 80 | solved, len(order), |
| 83 | 81 | rate(calls["edit_file"]-fails["edit_file"], calls["edit_file"]), |
| 84 | 82 | mean(results, func(r Result) float64 { return float64(r.Turns) }), |
| 85 | - mean(results, func(r Result) float64 { return cost(r.Usage) })) | |
| 83 | + costCell(results)) | |
| 86 | 84 | } |
| 87 | 85 | |
| 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 | + } | |
| 96 | 103 | |
| 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)) | |
| 102 | 109 | } |
| 103 | 110 | |
| 104 | 111 | func toolStats(results []Result) (calls, fails map[string]int) { |
| @@ -37,6 +37,7 @@ type Task struct { | ||
| 37 | 37 | type Result struct { |
| 38 | 38 | Task string `json:"task"` |
| 39 | 39 | Run int `json:"run"` |
| 40 | + Model string `json:"model"` | |
| 40 | 41 | Pass bool `json:"pass"` |
| 41 | 42 | Turns int `json:"turns"` |
| 42 | 43 | Usage llm.Usage `json:"usage"` |
| @@ -71,6 +72,7 @@ func run() error { | ||
| 71 | 72 | maxTurns := flag.Int("max-turns", 40, "turn cap per run") |
| 72 | 73 | contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window") |
| 73 | 74 | 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") | |
| 74 | 76 | timeout := flag.Duration("timeout", 10*time.Minute, "wall-clock cap per run") |
| 75 | 77 | tasksDir := flag.String("dir", "evals/tasks", "directory of task definitions") |
| 76 | 78 | outDir := flag.String("out", "evals/runs", "where to persist transcripts") |
| @@ -125,6 +127,7 @@ func run() error { | ||
| 125 | 127 | Effort: *effort, |
| 126 | 128 | ContextWindow: *contextWindow, |
| 127 | 129 | NoCache: *noCache, |
| 130 | + Stream: !*noStream, | |
| 128 | 131 | }, *timeout, *keep) |
| 129 | 132 | mu.Lock() |
| 130 | 133 | results = append(results, res) |
| @@ -159,7 +162,7 @@ func run() error { | ||
| 159 | 162 | // The return value is named so the deferred timing assignment lands in the |
| 160 | 163 | // value actually returned rather than in a copy already made. |
| 161 | 164 | 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} | |
| 163 | 166 | start := time.Now() |
| 164 | 167 | defer func() { res.WallMS = time.Since(start).Milliseconds() }() |
| 165 | 168 | |
| @@ -50,6 +50,8 @@ type Options struct { | ||
| 50 | 50 | CompactAt float64 |
| 51 | 51 | // NoCache disables prompt cache breakpoints, for measuring what they buy. |
| 52 | 52 | NoCache bool |
| 53 | + // Stream uses the SSE endpoint so output appears as it is produced. | |
| 54 | + Stream bool | |
| 53 | 55 | } |
| 54 | 56 | |
| 55 | 57 | // EventKind identifies what happened, for an observer to render. |
| @@ -62,6 +64,8 @@ const ( | ||
| 62 | 64 | EventToolResult EventKind = "tool_result" |
| 63 | 65 | EventUsage EventKind = "usage" |
| 64 | 66 | EventCompact EventKind = "compact" |
| 67 | + // EventTextDelta is emitted instead of EventText while streaming. | |
| 68 | + EventTextDelta EventKind = "text_delta" | |
| 65 | 69 | ) |
| 66 | 70 | |
| 67 | 71 | // 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) { | ||
| 129 | 133 | return "", err |
| 130 | 134 | } |
| 131 | 135 | |
| 132 | - resp, err := a.client.CreateMessage(ctx, a.request()) | |
| 136 | + resp, err := a.send(ctx, turn) | |
| 133 | 137 | if err != nil { |
| 134 | 138 | return "", err |
| 135 | 139 | } |
| @@ -150,7 +154,8 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { | ||
| 150 | 154 | // all: their signatures must come back unchanged on the next request. |
| 151 | 155 | a.state.Append(resp.Message()) |
| 152 | 156 | |
| 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 { | |
| 154 | 159 | a.emit(Event{Kind: EventText, Turn: turn, Text: text}) |
| 155 | 160 | } |
| 156 | 161 | |
| @@ -185,6 +190,19 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { | ||
| 185 | 190 | ErrMaxTurns, a.opts.MaxTurns) |
| 186 | 191 | } |
| 187 | 192 | |
| 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 | + | |
| 188 | 206 | // dispatch runs each tool call in order and builds one tool_result per |
| 189 | 207 | // tool_use id. A tool that fails still produces a result — a missing one is an |
| 190 | 208 | // API error, and the model can usually recover from the error text. |
| @@ -394,3 +394,53 @@ func containsKind(kinds []EventKind, want EventKind) bool { | ||
| 394 | 394 | } |
| 395 | 395 | return false |
| 396 | 396 | } |
| 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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -145,6 +145,15 @@ func (s *State) AddUsage(u llm.Usage) { | ||
| 145 | 145 | s.turns++ |
| 146 | 146 | } |
| 147 | 147 | |
| 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 | + | |
| 148 | 157 | // Usage is the running total for the session, and the number of model turns it |
| 149 | 158 | // took to get there. |
| 150 | 159 | func (s *State) Usage() (llm.Usage, int) { |
| @@ -214,21 +214,7 @@ func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) { | ||
| 214 | 214 | return nil, fmt.Errorf("llm: reading response: %w", err) |
| 215 | 215 | } |
| 216 | 216 | 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) | |
| 232 | 218 | } |
| 233 | 219 | |
| 234 | 220 | var out Response |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -31,8 +31,21 @@ var color = func() palette { | ||
| 31 | 31 | type Renderer struct { |
| 32 | 32 | W io.Writer |
| 33 | 33 | 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 | + } | |
| 34 | 44 | } |
| 35 | 45 | |
| 46 | + // Flush closes any open line. Call it when a turn finishes. | |
| 47 | + func (r *Renderer) Flush() { r.endLine() } | |
| 48 | + | |
| 36 | 49 | func New(w io.Writer) *Renderer { return &Renderer{W: w} } |
| 37 | 50 | |
| 38 | 51 | // Handle is the agent.Observe callback. |
| @@ -40,16 +53,30 @@ func (r *Renderer) Handle(ev agent.Event) { | ||
| 40 | 53 | switch ev.Kind { |
| 41 | 54 | case agent.EventText: |
| 42 | 55 | 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 | |
| 43 | 61 | case agent.EventToolStart: |
| 62 | + r.endLine() | |
| 44 | 63 | fmt.Fprintf(r.W, "%s⏺ %s%s%s(%s)%s\n", color.cyan, color.bold, ev.Tool, color.reset, summarizeInput(ev.Input), color.reset) |
| 45 | 64 | case agent.EventToolResult: |
| 65 | + r.endLine() | |
| 46 | 66 | r.tool(ev) |
| 47 | 67 | case agent.EventCompact: |
| 68 | + r.endLine() | |
| 48 | 69 | fmt.Fprintf(r.W, "%s[compacted the conversation]%s\n", color.dim, color.reset) |
| 49 | 70 | } |
| 50 | 71 | } |
| 51 | 72 | |
| 52 | 73 | 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 | + } | |
| 53 | 80 | prefix, c := " ⎿ ", color.dim |
| 54 | 81 | if ev.IsError { |
| 55 | 82 | prefix, c = " ⎿ error: ", color.red |
| @@ -99,13 +126,19 @@ func firstLines(s string, n int) string { | ||
| 99 | 126 | return strings.Join(lines[:n], "\n") + fmt.Sprintf("\n… %d more lines", len(lines)-n) |
| 100 | 127 | } |
| 101 | 128 | |
| 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) | |
| 106 | 138 | } |
| 107 | 139 | |
| 108 | 140 | // Error prints a failure. |
| 109 | 141 | func (r *Renderer) Error(err error) { |
| 142 | + r.endLine() | |
| 110 | 143 | fmt.Fprintf(r.W, "%serror: %v%s\n", color.red, err, color.reset) |
| 111 | 144 | } |
| @@ -56,8 +56,55 @@ func TestErrorResultIsLabelled(t *testing.T) { | ||
| 56 | 56 | |
| 57 | 57 | func TestUsageLine(t *testing.T) { |
| 58 | 58 | 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") | |
| 62 | 109 | } |
| 63 | 110 | } |