Three things the subagents that follow need from the loop. Pause holds the agent at its next turn boundary and nowhere else: mid-turn there is a tool_use whose tool_result has not been produced, and a request missing one is an API error rather than a pause. The turn is announced after the hold, so nothing counts a turn that has not been spent. A cancelled context releases a held agent, because killing one you paused is the ordinary case. Continue drives the loop on the conversation as it stands, for a message the front end put there itself. A subagent's report is not something the user typed, and gluing it onto what they type next leaves the model unable to tell which words were whose. And the request is now shaped for the model it is going to: adaptive thinking and effort are only sent where they are taken. A cheap subagent under a session set to high effort used to die on its first request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 4 changed files with 574 additions and 5 deletions.
| @@ -117,7 +117,7 @@ func (a *Agent) summarize(ctx context.Context, history []llm.Message) (string, e | ||
| 117 | 117 | req := llm.Request{ |
| 118 | 118 | MaxTokens: 4096, |
| 119 | 119 | Messages: stripTrailingToolUse(msgs), |
| 120 | - Thinking: llm.Adaptive(), | |
| 120 | + Thinking: a.thinking(), | |
| 121 | 121 | } |
| 122 | 122 | // This is the one request in the agent that is built by hand rather than |
| 123 | 123 | // through (*Agent).request/systemBlocks, and it is deliberately bare — no |
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "encoding/json" |
| 6 | 6 | "errors" |
| 7 | 7 | "fmt" |
| 8 | + "sort" | |
| 8 | 9 | "strings" |
| 9 | 10 | "sync" |
| 10 | 11 | |
| @@ -61,6 +62,39 @@ func skillListing(found []skills.Skill) string { | ||
| 61 | 62 | return b.String() |
| 62 | 63 | } |
| 63 | 64 | |
| 65 | + // modelListing tells the agent which models it may put a subagent on. Like the | |
| 66 | + // skill listing it is constant for the session, so the cached prefix still | |
| 67 | + // matches request to request. | |
| 68 | + // | |
| 69 | + // The newest is named as such because that is the rule: reach for it unless | |
| 70 | + // the user asked for something else or it has been failing. An agent guessing | |
| 71 | + // at model ids is how a subagent dies before its first turn on an id this | |
| 72 | + // endpoint never served. | |
| 73 | + func modelListing(models []llm.Model) string { | |
| 74 | + if len(models) == 0 { | |
| 75 | + return "" | |
| 76 | + } | |
| 77 | + // Newest first. The API answers in that order already; sorting is for the | |
| 78 | + // endpoints that do not. | |
| 79 | + sorted := append([]llm.Model(nil), models...) | |
| 80 | + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) }) | |
| 81 | + | |
| 82 | + var b strings.Builder | |
| 83 | + b.WriteString("\n\nModels this endpoint serves, newest first:") | |
| 84 | + for i, m := range sorted { | |
| 85 | + fmt.Fprintf(&b, "\n %s", m.ID) | |
| 86 | + if i == 0 { | |
| 87 | + b.WriteString(" (the newest)") | |
| 88 | + } | |
| 89 | + } | |
| 90 | + b.WriteString("\n\nWhen you put a subagent on a model, use the newest one. Choose differently\n" + | |
| 91 | + "only when the user asked for a particular model, or when the newest has already\n" + | |
| 92 | + "failed for you this session — a subagent on a model this endpoint will not serve\n" + | |
| 93 | + "dies before its first turn and reports nothing back. Naming no model at all runs\n" + | |
| 94 | + "it on whatever this session is currently using, which is never a wrong answer.") | |
| 95 | + return b.String() | |
| 96 | + } | |
| 97 | + | |
| 64 | 98 | // Options configures an Agent. |
| 65 | 99 | type Options struct { |
| 66 | 100 | MaxTurns int |
| @@ -79,6 +113,14 @@ type Options struct { | ||
| 79 | 113 | // descriptions are appended to the system prompt; the bodies are loaded on |
| 80 | 114 | // demand by the skill tool. |
| 81 | 115 | Skills []skills.Skill |
| 116 | + // Models are what this endpoint serves, for the agent to choose between | |
| 117 | + // when it delegates work to a subagent. Empty means the listing is left | |
| 118 | + // out — an endpoint that does not answer GET /v1/models is not one to | |
| 119 | + // make claims about. | |
| 120 | + // | |
| 121 | + // It goes only to the agent that can spawn: a subagent has no such choice | |
| 122 | + // to make, and the paragraph would be dead weight in its prompt. | |
| 123 | + Models []llm.Model | |
| 82 | 124 | // SystemPrefix is prepended as a separate system block ahead of the |
| 83 | 125 | // harness prompt. It carries the identity string an OAuth credential |
| 84 | 126 | // requires, and is empty for every other credential. |
| @@ -97,6 +139,10 @@ const ( | ||
| 97 | 139 | EventCompact EventKind = "compact" |
| 98 | 140 | // EventTextDelta is emitted instead of EventText while streaming. |
| 99 | 141 | EventTextDelta EventKind = "text_delta" |
| 142 | + // EventPaused is emitted when the loop stops at a turn boundary, and | |
| 143 | + // EventResumed when it is let go again. | |
| 144 | + EventPaused EventKind = "paused" | |
| 145 | + EventResumed EventKind = "resumed" | |
| 100 | 146 | ) |
| 101 | 147 | |
| 102 | 148 | // Event is what the loop reports to an observer. It carries plain data only — |
| @@ -138,8 +184,68 @@ type Agent struct { | ||
| 138 | 184 | lastInputTokens int |
| 139 | 185 | |
| 140 | 186 | // mu guards the settings a front end can change while the loop is |
| 141 | - // running. Only effort is one today; the loop reads it once per request. | |
| 187 | + // running: the effort level, and the pause gate below. | |
| 142 | 188 | mu sync.Mutex |
| 189 | + // resume is non-nil exactly while the agent is paused, and is closed to | |
| 190 | + // release it. A channel rather than a flag because the loop has to be able | |
| 191 | + // to wait on it alongside the context. | |
| 192 | + resume chan struct{} | |
| 193 | + } | |
| 194 | + | |
| 195 | + // Pause asks the loop to stop at the next turn boundary. It is safe to call | |
| 196 | + // from another goroutine and from inside an Observe callback, and calling it | |
| 197 | + // twice is the same as calling it once. | |
| 198 | + func (a *Agent) Pause() { | |
| 199 | + a.mu.Lock() | |
| 200 | + defer a.mu.Unlock() | |
| 201 | + if a.resume == nil { | |
| 202 | + a.resume = make(chan struct{}) | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 206 | + // Resume releases a paused loop. Resuming an agent that is not paused does | |
| 207 | + // nothing. | |
| 208 | + func (a *Agent) Resume() { | |
| 209 | + a.mu.Lock() | |
| 210 | + ch := a.resume | |
| 211 | + a.resume = nil | |
| 212 | + a.mu.Unlock() | |
| 213 | + if ch != nil { | |
| 214 | + close(ch) | |
| 215 | + } | |
| 216 | + } | |
| 217 | + | |
| 218 | + // Paused reports whether the loop is being held. | |
| 219 | + func (a *Agent) Paused() bool { | |
| 220 | + a.mu.Lock() | |
| 221 | + defer a.mu.Unlock() | |
| 222 | + return a.resume != nil | |
| 223 | + } | |
| 224 | + | |
| 225 | + // hold blocks while the agent is paused. It is called at the top of each turn | |
| 226 | + // and nowhere else: between turns the conversation is consistent, where | |
| 227 | + // mid-turn there is a tool_use block whose tool_result has not been produced | |
| 228 | + // yet — and a request missing one is an API error, not a pause. | |
| 229 | + func (a *Agent) hold(ctx context.Context, turn int) error { | |
| 230 | + a.mu.Lock() | |
| 231 | + ch := a.resume | |
| 232 | + a.mu.Unlock() | |
| 233 | + if ch == nil { | |
| 234 | + return ctx.Err() | |
| 235 | + } | |
| 236 | + | |
| 237 | + a.emit(Event{Kind: EventPaused, Turn: turn}) | |
| 238 | + a.log.Log(metrics.Event{Type: "paused", Turn: turn}) | |
| 239 | + select { | |
| 240 | + case <-ch: | |
| 241 | + a.emit(Event{Kind: EventResumed, Turn: turn}) | |
| 242 | + a.log.Log(metrics.Event{Type: "resumed", Turn: turn}) | |
| 243 | + return nil | |
| 244 | + case <-ctx.Done(): | |
| 245 | + // The agent is going away while held. Nothing resumed it, so the flag | |
| 246 | + // stays set; the run is over either way. | |
| 247 | + return ctx.Err() | |
| 248 | + } | |
| 143 | 249 | } |
| 144 | 250 | |
| 145 | 251 | // SetEffort changes the effort level the turns that follow will ask for. An |
| @@ -167,6 +273,7 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log | ||
| 167 | 273 | opts.System = defaultSystem |
| 168 | 274 | } |
| 169 | 275 | opts.System += skillListing(opts.Skills) |
| 276 | + opts.System += modelListing(opts.Models) | |
| 170 | 277 | return &Agent{client: client, tools: reg, state: state, log: log, opts: opts} |
| 171 | 278 | } |
| 172 | 279 | |
| @@ -183,8 +290,28 @@ func (a *Agent) emit(ev Event) { | ||
| 183 | 290 | // asking for tools. It returns the final assistant text. |
| 184 | 291 | func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { |
| 185 | 292 | a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(prompt)}}) |
| 293 | + return a.drive(ctx) | |
| 294 | + } | |
| 295 | + | |
| 296 | + // Continue drives the loop on the conversation as it already stands, for a | |
| 297 | + // message the front end put there itself — a subagent's report, which is not | |
| 298 | + // something the user typed and should not be glued onto what they type next. | |
| 299 | + func (a *Agent) Continue(ctx context.Context) (string, error) { | |
| 300 | + if a.state.Len() == 0 { | |
| 301 | + return "", errors.New("nothing to continue: the conversation is empty") | |
| 302 | + } | |
| 303 | + return a.drive(ctx) | |
| 304 | + } | |
| 186 | 305 | |
| 306 | + func (a *Agent) drive(ctx context.Context) (string, error) { | |
| 187 | 307 | for turn := 1; turn <= a.opts.MaxTurns; turn++ { |
| 308 | + // A pause is honoured before the turn is announced, let alone spent: | |
| 309 | + // a held agent has not started this turn, and anything counting turns | |
| 310 | + // would otherwise show one that never happened. | |
| 311 | + if err := a.hold(ctx, turn); err != nil { | |
| 312 | + return "", err | |
| 313 | + } | |
| 314 | + | |
| 188 | 315 | a.emit(Event{Kind: EventTurnStart, Turn: turn}) |
| 189 | 316 | |
| 190 | 317 | // Compaction is checked before building the request, using the token |
| @@ -345,14 +472,41 @@ func (a *Agent) request() llm.Request { | ||
| 345 | 472 | System: a.systemBlocks(), |
| 346 | 473 | Messages: messages, |
| 347 | 474 | Tools: defs, |
| 348 | - Thinking: llm.Adaptive(), | |
| 475 | + Thinking: a.thinking(), | |
| 349 | 476 | } |
| 350 | - if effort := a.Effort(); effort != "" { | |
| 477 | + // Effort is a per-model capability, not a universal parameter: a model | |
| 478 | + // that does not take this level rejects the whole request with a 400 | |
| 479 | + // rather than ignoring the field. Dropping it here is what lets a cheap | |
| 480 | + // subagent run under a session set to a level its model never had. | |
| 481 | + if effort := a.Effort(); effort != "" && llm.SupportsEffort(a.modelID(), effort) { | |
| 351 | 482 | req.OutputConfig = &llm.OutputConfig{Effort: effort} |
| 352 | 483 | } |
| 353 | 484 | return req |
| 354 | 485 | } |
| 355 | 486 | |
| 487 | + // thinking is the thinking parameter for the model this agent is running on, | |
| 488 | + // or nil — which the field omits — when that model does not take one. It is a | |
| 489 | + // method rather than a constant because a subagent runs the same loop against a | |
| 490 | + // cloned client on its own model: the explore preset pins claude-haiku-4-5, | |
| 491 | + // which 400s on adaptive thinking, so asking the client which model it is | |
| 492 | + // pointed at is the only way to get this right for both. | |
| 493 | + func (a *Agent) thinking() *llm.Thinking { | |
| 494 | + if !llm.SupportsAdaptiveThinking(a.modelID()) { | |
| 495 | + return nil | |
| 496 | + } | |
| 497 | + return llm.Adaptive() | |
| 498 | + } | |
| 499 | + | |
| 500 | + // modelID is the model this agent's requests ask for. The tests that check the | |
| 501 | + // shape of a request build an agent with no client at all, so this answers for | |
| 502 | + // them rather than making every per-model decision guard its own nil. | |
| 503 | + func (a *Agent) modelID() string { | |
| 504 | + if a.client == nil { | |
| 505 | + return "" | |
| 506 | + } | |
| 507 | + return a.client.Model() | |
| 508 | + } | |
| 509 | + | |
| 356 | 510 | // systemBlocks builds the system prompt. The cache breakpoint goes on the last |
| 357 | 511 | // block so everything ahead of it — tools, identity, prompt — is cached |
| 358 | 512 | // together; on the first block it would cache the identity string and re-read |
| @@ -13,6 +13,7 @@ import ( | ||
| 13 | 13 | "strconv" |
| 14 | 14 | "strings" |
| 15 | 15 | "testing" |
| 16 | + "time" | |
| 16 | 17 | |
| 17 | 18 | "harness/internal/llm" |
| 18 | 19 | "harness/internal/metrics" |
| @@ -103,6 +104,13 @@ func toolUseBlock(id, name, input string) string { | ||
| 103 | 104 | // newTestAgent wires an agent against the fake API, with read_file rooted at |
| 104 | 105 | // a temp dir plus a couple of tools that make failure modes easy to trigger. |
| 105 | 106 | func newTestAgent(t *testing.T, f *fakeAPI, root string, logBuf *bytes.Buffer) *Agent { |
| 107 | + t.Helper() | |
| 108 | + return newTestAgentOnModel(t, f, root, logBuf, llm.DefaultModel) | |
| 109 | + } | |
| 110 | + | |
| 111 | + // newTestAgentOnModel is newTestAgent pinned to a model, for the parts of a | |
| 112 | + // request whose shape depends on which model it is going to. | |
| 113 | + func newTestAgentOnModel(t *testing.T, f *fakeAPI, root string, logBuf *bytes.Buffer, model string) *Agent { | |
| 106 | 114 | t.Helper() |
| 107 | 115 | log := metrics.NewWriter(logBuf, "test") |
| 108 | 116 | state := NewState() |
| @@ -114,7 +122,7 @@ func newTestAgent(t *testing.T, f *fakeAPI, root string, logBuf *bytes.Buffer) * | ||
| 114 | 122 | return "", errors.New("detonated") |
| 115 | 123 | }), |
| 116 | 124 | ) |
| 117 | - client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k")) | |
| 125 | + client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k"), llm.WithModel(model)) | |
| 118 | 126 | return New(client, reg, state, log, Options{}) |
| 119 | 127 | } |
| 120 | 128 | |
| @@ -252,6 +260,8 @@ func TestRequestShape(t *testing.T) { | ||
| 252 | 260 | if strings.ContainsAny(req.System[0].Text, "0123456789") { |
| 253 | 261 | t.Error("system prompt should hold no digits: timestamps or ids here would break the cached prefix") |
| 254 | 262 | } |
| 263 | + // The agent runs on llm.DefaultModel, which takes adaptive thinking: a | |
| 264 | + // model that supports it must still be asked for it. | |
| 255 | 265 | if req.Thinking == nil || req.Thinking.Type != "adaptive" { |
| 256 | 266 | t.Errorf("thinking = %+v, want adaptive (budget_tokens is a 400 on current models)", req.Thinking) |
| 257 | 267 | } |
| @@ -592,3 +602,54 @@ func TestSetEffortAppliesToTheNextRequest(t *testing.T) { | ||
| 592 | 602 | t.Errorf("output_config = %+v, want effort low", oc) |
| 593 | 603 | } |
| 594 | 604 | } |
| 605 | + | |
| 606 | + // The agent chooses which model each subagent runs on, so it has to be told | |
| 607 | + // what this endpoint actually serves — and which of them is the newest, which | |
| 608 | + // is the one to reach for unless there is a reason not to. | |
| 609 | + func TestModelListingNamesWhatTheEndpointServes(t *testing.T) { | |
| 610 | + models := []llm.Model{ | |
| 611 | + {ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}, | |
| 612 | + {ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1", CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)}, | |
| 613 | + {ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5", CreatedAt: time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC)}, | |
| 614 | + } | |
| 615 | + got := modelListing(models) | |
| 616 | + | |
| 617 | + for _, m := range models { | |
| 618 | + if !strings.Contains(got, m.ID) { | |
| 619 | + t.Errorf("the listing never names %q:\n%s", m.ID, got) | |
| 620 | + } | |
| 621 | + } | |
| 622 | + // Newest first, so "the newest" is not a claim the agent has to work out. | |
| 623 | + fable, haiku := strings.Index(got, "claude-fable-5-1"), strings.Index(got, "claude-haiku-4-5") | |
| 624 | + if fable > haiku { | |
| 625 | + t.Errorf("the listing is not newest first:\n%s", got) | |
| 626 | + } | |
| 627 | + for _, want := range []string{"newest", "this session is currently using", "the user asked"} { | |
| 628 | + if !strings.Contains(got, want) { | |
| 629 | + t.Errorf("the listing does not say %q:\n%s", want, got) | |
| 630 | + } | |
| 631 | + } | |
| 632 | + } | |
| 633 | + | |
| 634 | + // Nothing to list — an endpoint that does not answer GET /v1/models — must add | |
| 635 | + // nothing at all rather than a paragraph about an empty set. | |
| 636 | + func TestModelListingIsEmptyWithoutModels(t *testing.T) { | |
| 637 | + if got := modelListing(nil); got != "" { | |
| 638 | + t.Errorf("modelListing(nil) = %q", got) | |
| 639 | + } | |
| 640 | + } | |
| 641 | + | |
| 642 | + // The listing is part of the cached prefix, so it must be identical between | |
| 643 | + // two requests in one session. | |
| 644 | + func TestModelListingIsStableAcrossRequests(t *testing.T) { | |
| 645 | + models := []llm.Model{{ID: "claude-opus-5"}, {ID: "claude-haiku-4-5"}} | |
| 646 | + a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{Models: models}) | |
| 647 | + a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("hi")}}) | |
| 648 | + | |
| 649 | + if first, second := a.request().System[0].Text, a.request().System[0].Text; first != second { | |
| 650 | + t.Error("the model listing changed between two requests in one session") | |
| 651 | + } | |
| 652 | + if !strings.Contains(a.request().System[0].Text, "claude-haiku-4-5") { | |
| 653 | + t.Error("the listing never reached the system prompt") | |
| 654 | + } | |
| 655 | + } | |
| @@ -0,0 +1,354 @@ | ||
| 1 | + package agent | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "errors" | |
| 8 | + "io" | |
| 9 | + "net/http" | |
| 10 | + "net/http/httptest" | |
| 11 | + "os" | |
| 12 | + "path/filepath" | |
| 13 | + "sync" | |
| 14 | + "testing" | |
| 15 | + "time" | |
| 16 | + | |
| 17 | + "harness/internal/llm" | |
| 18 | + "harness/internal/metrics" | |
| 19 | + "harness/internal/tools" | |
| 20 | + ) | |
| 21 | + | |
| 22 | + // settle is how long a test waits before concluding that nothing is going to | |
| 23 | + // happen. A paused agent proves itself by silence, which can only be measured | |
| 24 | + // against a clock. | |
| 25 | + const settle = 150 * time.Millisecond | |
| 26 | + | |
| 27 | + // pausableAPI announces each request on a channel so a test can tell the | |
| 28 | + // difference between "not sent yet" and "never sent". | |
| 29 | + type pausableAPI struct { | |
| 30 | + mu sync.Mutex | |
| 31 | + responses []string | |
| 32 | + bodies [][]byte | |
| 33 | + n int | |
| 34 | + sent chan struct{} | |
| 35 | + srv *httptest.Server | |
| 36 | + } | |
| 37 | + | |
| 38 | + func newPausableAPI(t *testing.T, responses ...string) *pausableAPI { | |
| 39 | + t.Helper() | |
| 40 | + f := &pausableAPI{responses: responses, sent: make(chan struct{}, len(responses)+4)} | |
| 41 | + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 42 | + body, _ := io.ReadAll(r.Body) | |
| 43 | + f.mu.Lock() | |
| 44 | + i := f.n | |
| 45 | + f.n++ | |
| 46 | + f.bodies = append(f.bodies, body) | |
| 47 | + f.mu.Unlock() | |
| 48 | + f.sent <- struct{}{} | |
| 49 | + if i >= len(f.responses) { | |
| 50 | + w.WriteHeader(http.StatusInternalServerError) | |
| 51 | + return | |
| 52 | + } | |
| 53 | + io.WriteString(w, f.responses[i]) | |
| 54 | + })) | |
| 55 | + t.Cleanup(f.srv.Close) | |
| 56 | + return f | |
| 57 | + } | |
| 58 | + | |
| 59 | + // lastRequest is the body of the most recent request, decoded. | |
| 60 | + func (f *pausableAPI) lastRequest(t *testing.T) llm.Request { | |
| 61 | + t.Helper() | |
| 62 | + f.mu.Lock() | |
| 63 | + defer f.mu.Unlock() | |
| 64 | + if len(f.bodies) == 0 { | |
| 65 | + t.Fatal("no request was made") | |
| 66 | + } | |
| 67 | + var req llm.Request | |
| 68 | + if err := json.Unmarshal(f.bodies[len(f.bodies)-1], &req); err != nil { | |
| 69 | + t.Fatalf("request body does not decode: %v", err) | |
| 70 | + } | |
| 71 | + return req | |
| 72 | + } | |
| 73 | + | |
| 74 | + func (f *pausableAPI) count() int { | |
| 75 | + f.mu.Lock() | |
| 76 | + defer f.mu.Unlock() | |
| 77 | + return f.n | |
| 78 | + } | |
| 79 | + | |
| 80 | + // waitForRequest blocks until one more request arrives, or fails the test. | |
| 81 | + func (f *pausableAPI) waitForRequest(t *testing.T) { | |
| 82 | + t.Helper() | |
| 83 | + select { | |
| 84 | + case <-f.sent: | |
| 85 | + case <-time.After(2 * time.Second): | |
| 86 | + t.Fatal("timed out waiting for a request") | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + func newPausableAgent(t *testing.T, f *pausableAPI, root string) *Agent { | |
| 91 | + t.Helper() | |
| 92 | + log := metrics.NewWriter(&bytes.Buffer{}, "test") | |
| 93 | + state := NewState() | |
| 94 | + reg := tools.NewRegistry(log).Register(tools.NewRead(root, state)) | |
| 95 | + client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k")) | |
| 96 | + return New(client, reg, state, log, Options{}) | |
| 97 | + } | |
| 98 | + | |
| 99 | + // A paused agent sends nothing until it is resumed. This is the whole of what | |
| 100 | + // pause means: the conversation is intact, it is simply not being spent on. | |
| 101 | + func TestPauseHoldsTheNextRequestUntilResume(t *testing.T) { | |
| 102 | + root := t.TempDir() | |
| 103 | + os.WriteFile(filepath.Join(root, "a.go"), []byte("A\n"), 0o644) | |
| 104 | + | |
| 105 | + f := newPausableAPI(t, | |
| 106 | + msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`)), | |
| 107 | + msg("end_turn", textBlock("done")), | |
| 108 | + ) | |
| 109 | + a := newPausableAgent(t, f, root) | |
| 110 | + | |
| 111 | + paused := make(chan struct{}, 1) | |
| 112 | + a.Observe = func(ev Event) { | |
| 113 | + if ev.Kind == EventToolResult { | |
| 114 | + a.Pause() | |
| 115 | + } | |
| 116 | + if ev.Kind == EventPaused { | |
| 117 | + paused <- struct{}{} | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 121 | + out := make(chan string, 1) | |
| 122 | + go func() { | |
| 123 | + text, err := a.Run(context.Background(), "go") | |
| 124 | + if err != nil { | |
| 125 | + t.Errorf("Run: %v", err) | |
| 126 | + } | |
| 127 | + out <- text | |
| 128 | + }() | |
| 129 | + | |
| 130 | + f.waitForRequest(t) | |
| 131 | + select { | |
| 132 | + case <-paused: | |
| 133 | + case <-time.After(2 * time.Second): | |
| 134 | + t.Fatal("no EventPaused was emitted") | |
| 135 | + } | |
| 136 | + | |
| 137 | + time.Sleep(settle) | |
| 138 | + if n := f.count(); n != 1 { | |
| 139 | + t.Fatalf("a paused agent sent %d requests, want 1", n) | |
| 140 | + } | |
| 141 | + | |
| 142 | + a.Resume() | |
| 143 | + select { | |
| 144 | + case text := <-out: | |
| 145 | + if text != "done" { | |
| 146 | + t.Errorf("after resume the run returned %q, want %q", text, "done") | |
| 147 | + } | |
| 148 | + case <-time.After(2 * time.Second): | |
| 149 | + t.Fatal("the run never finished after Resume") | |
| 150 | + } | |
| 151 | + if n := f.count(); n != 2 { | |
| 152 | + t.Errorf("made %d requests overall, want 2", n) | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + // Pausing mid-turn does not abandon the work in flight: the tool calls the | |
| 157 | + // model already asked for still run, and their results are still what the | |
| 158 | + // resumed request carries. Holding anywhere else would leave a tool_use with | |
| 159 | + // no tool_result, which is an API error, not a pause. | |
| 160 | + func TestPauseWaitsForTheTurnInFlightToFinish(t *testing.T) { | |
| 161 | + root := t.TempDir() | |
| 162 | + os.WriteFile(filepath.Join(root, "a.go"), []byte("A\n"), 0o644) | |
| 163 | + | |
| 164 | + f := newPausableAPI(t, | |
| 165 | + msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`)), | |
| 166 | + msg("end_turn", textBlock("done")), | |
| 167 | + ) | |
| 168 | + a := newPausableAgent(t, f, root) | |
| 169 | + | |
| 170 | + var order []EventKind | |
| 171 | + var mu sync.Mutex | |
| 172 | + a.Observe = func(ev Event) { | |
| 173 | + mu.Lock() | |
| 174 | + defer mu.Unlock() | |
| 175 | + switch ev.Kind { | |
| 176 | + case EventToolStart: | |
| 177 | + a.Pause() | |
| 178 | + order = append(order, ev.Kind) | |
| 179 | + case EventToolResult, EventPaused, EventResumed: | |
| 180 | + order = append(order, ev.Kind) | |
| 181 | + } | |
| 182 | + } | |
| 183 | + | |
| 184 | + done := make(chan struct{}) | |
| 185 | + go func() { | |
| 186 | + if _, err := a.Run(context.Background(), "go"); err != nil { | |
| 187 | + t.Errorf("Run: %v", err) | |
| 188 | + } | |
| 189 | + close(done) | |
| 190 | + }() | |
| 191 | + | |
| 192 | + // Give the hold time to land, then release it. | |
| 193 | + time.Sleep(settle) | |
| 194 | + a.Resume() | |
| 195 | + select { | |
| 196 | + case <-done: | |
| 197 | + case <-time.After(2 * time.Second): | |
| 198 | + t.Fatal("the run never finished after Resume") | |
| 199 | + } | |
| 200 | + | |
| 201 | + mu.Lock() | |
| 202 | + defer mu.Unlock() | |
| 203 | + want := []EventKind{EventToolStart, EventToolResult, EventPaused, EventResumed} | |
| 204 | + if len(order) != len(want) { | |
| 205 | + t.Fatalf("events = %v, want %v", order, want) | |
| 206 | + } | |
| 207 | + for i := range want { | |
| 208 | + if order[i] != want[i] { | |
| 209 | + t.Fatalf("events = %v, want %v", order, want) | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| 213 | + | |
| 214 | + // Killing a paused agent is the ordinary case — you pause one to look at it and | |
| 215 | + // then decide it is not worth continuing. It must not sit on the channel | |
| 216 | + // forever waiting for a Resume that is never coming. | |
| 217 | + func TestCancelWhilePausedEndsTheRun(t *testing.T) { | |
| 218 | + f := newPausableAPI(t, msg("end_turn", textBlock("hi"))) | |
| 219 | + a := newPausableAgent(t, f, t.TempDir()) | |
| 220 | + a.Pause() | |
| 221 | + | |
| 222 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 223 | + errc := make(chan error, 1) | |
| 224 | + go func() { | |
| 225 | + _, err := a.Run(ctx, "go") | |
| 226 | + errc <- err | |
| 227 | + }() | |
| 228 | + | |
| 229 | + time.Sleep(settle) | |
| 230 | + if n := f.count(); n != 0 { | |
| 231 | + t.Fatalf("an agent paused before its first turn sent %d requests", n) | |
| 232 | + } | |
| 233 | + cancel() | |
| 234 | + | |
| 235 | + select { | |
| 236 | + case err := <-errc: | |
| 237 | + if !errors.Is(err, context.Canceled) { | |
| 238 | + t.Errorf("Run returned %v, want context.Canceled", err) | |
| 239 | + } | |
| 240 | + case <-time.After(2 * time.Second): | |
| 241 | + t.Fatal("a cancelled pause never unblocked") | |
| 242 | + } | |
| 243 | + } | |
| 244 | + | |
| 245 | + // A subagent's report reaches the parent as a user message the front end put | |
| 246 | + // into the conversation directly, not as something the human typed. Continue | |
| 247 | + // drives the loop on the history as it stands, so the report is one message | |
| 248 | + // and whatever the user says next is another. | |
| 249 | + func TestContinueRunsOnTheHistoryWithoutAddingAMessage(t *testing.T) { | |
| 250 | + f := newPausableAPI(t, msg("end_turn", textBlock("acknowledged"))) | |
| 251 | + a := newPausableAgent(t, f, t.TempDir()) | |
| 252 | + | |
| 253 | + a.State().Append(llm.Message{ | |
| 254 | + Role: llm.RoleUser, | |
| 255 | + Content: []llm.ContentBlock{llm.TextBlock("[subagent a1 finished] it is in loop.go")}, | |
| 256 | + }) | |
| 257 | + | |
| 258 | + text, err := a.Continue(context.Background()) | |
| 259 | + if err != nil { | |
| 260 | + t.Fatalf("Continue: %v", err) | |
| 261 | + } | |
| 262 | + if text != "acknowledged" { | |
| 263 | + t.Errorf("Continue returned %q", text) | |
| 264 | + } | |
| 265 | + | |
| 266 | + history := a.State().History() | |
| 267 | + if len(history) != 2 { | |
| 268 | + t.Fatalf("history has %d messages, want the report and the answer", len(history)) | |
| 269 | + } | |
| 270 | + if history[0].Content[0].Text != "[subagent a1 finished] it is in loop.go" { | |
| 271 | + t.Errorf("first message = %+v, want the report untouched", history[0]) | |
| 272 | + } | |
| 273 | + } | |
| 274 | + | |
| 275 | + // Continuing an empty conversation would send a request with no messages, | |
| 276 | + // which the API rejects; saying so here is clearer than the error that comes | |
| 277 | + // back from the endpoint. | |
| 278 | + func TestContinueOnAnEmptyConversationIsRefused(t *testing.T) { | |
| 279 | + f := newPausableAPI(t) | |
| 280 | + a := newPausableAgent(t, f, t.TempDir()) | |
| 281 | + if _, err := a.Continue(context.Background()); err == nil { | |
| 282 | + t.Fatal("continuing an empty conversation succeeded") | |
| 283 | + } | |
| 284 | + if f.count() != 0 { | |
| 285 | + t.Error("it sent a request anyway") | |
| 286 | + } | |
| 287 | + } | |
| 288 | + | |
| 289 | + // A paused agent has not spent the turn it is holding before, so nothing | |
| 290 | + // should be counting it yet: the sidebar would show a turn that never | |
| 291 | + // happened. | |
| 292 | + func TestNoTurnIsAnnouncedUntilThePauseIsReleased(t *testing.T) { | |
| 293 | + f := newPausableAPI(t, msg("end_turn", textBlock("done"))) | |
| 294 | + a := newPausableAgent(t, f, t.TempDir()) | |
| 295 | + a.Pause() | |
| 296 | + | |
| 297 | + turns := make(chan int, 8) | |
| 298 | + a.Observe = func(ev Event) { | |
| 299 | + if ev.Kind == EventTurnStart { | |
| 300 | + turns <- ev.Turn | |
| 301 | + } | |
| 302 | + } | |
| 303 | + | |
| 304 | + go a.Run(context.Background(), "go") | |
| 305 | + time.Sleep(settle) | |
| 306 | + select { | |
| 307 | + case turn := <-turns: | |
| 308 | + t.Fatalf("turn %d was announced while the agent was still held", turn) | |
| 309 | + default: | |
| 310 | + } | |
| 311 | + | |
| 312 | + a.Resume() | |
| 313 | + select { | |
| 314 | + case <-turns: | |
| 315 | + case <-time.After(2 * time.Second): | |
| 316 | + t.Fatal("no turn was announced after the pause was released") | |
| 317 | + } | |
| 318 | + } | |
| 319 | + | |
| 320 | + // Effort is not a universal parameter. Sending the session's level to a model | |
| 321 | + // that rejects it fails the whole run with a 400 — which is how a Haiku | |
| 322 | + // subagent spawned from a high-effort session used to die before its first | |
| 323 | + // turn. | |
| 324 | + func TestEffortIsOmittedForAModelThatRejectsIt(t *testing.T) { | |
| 325 | + cases := []struct { | |
| 326 | + model, effort string | |
| 327 | + wantEffort string | |
| 328 | + }{ | |
| 329 | + {"claude-opus-5", "high", "high"}, | |
| 330 | + {"claude-haiku-4-5", "high", ""}, | |
| 331 | + {"claude-opus-4-6", "xhigh", ""}, | |
| 332 | + {"claude-opus-4-6", "max", "max"}, | |
| 333 | + } | |
| 334 | + for _, c := range cases { | |
| 335 | + f := newPausableAPI(t, msg("end_turn", textBlock("ok"))) | |
| 336 | + a := newPausableAgent(t, f, t.TempDir()) | |
| 337 | + a.client.SetModel(c.model) | |
| 338 | + a.SetEffort(c.effort) | |
| 339 | + | |
| 340 | + if _, err := a.Run(context.Background(), "go"); err != nil { | |
| 341 | + t.Fatalf("%s: Run: %v", c.model, err) | |
| 342 | + } | |
| 343 | + | |
| 344 | + req := f.lastRequest(t) | |
| 345 | + switch { | |
| 346 | + case c.wantEffort == "" && req.OutputConfig != nil: | |
| 347 | + t.Errorf("%s at %s: sent output_config %+v, which the model rejects", | |
| 348 | + c.model, c.effort, req.OutputConfig) | |
| 349 | + case c.wantEffort != "" && (req.OutputConfig == nil || req.OutputConfig.Effort != c.wantEffort): | |
| 350 | + t.Errorf("%s at %s: output_config = %+v, want effort %q", | |
| 351 | + c.model, c.effort, req.OutputConfig, c.wantEffort) | |
| 352 | + } | |
| 353 | + } | |
| 354 | + } | |