Vectrum
Sign In

agent: hold between turns, continue on history, shape requests per model

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>
Jjonicommitted 1 parent 8db39adcommit68a1174

Showing 4 changed files with 574 additions and 5 deletions.

+1−1internal/agent/compact.goView File
@@ -117,7 +117,7 @@ func (a *Agent) summarize(ctx context.Context, history []llm.Message) (string, e
117117 req := llm.Request{
118118 MaxTokens: 4096,
119119 Messages: stripTrailingToolUse(msgs),
120- Thinking: llm.Adaptive(),
120+ Thinking: a.thinking(),
121121 }
122122 // This is the one request in the agent that is built by hand rather than
123123 // through (*Agent).request/systemBlocks, and it is deliberately bare — no
+157−3internal/agent/loop.goView File
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "errors"
77 "fmt"
8+ "sort"
89 "strings"
910 "sync"
1011
@@ -61,6 +62,39 @@ func skillListing(found []skills.Skill) string {
6162 return b.String()
6263 }
6364
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+
6498 // Options configures an Agent.
6599 type Options struct {
66100 MaxTurns int
@@ -79,6 +113,14 @@ type Options struct {
79113 // descriptions are appended to the system prompt; the bodies are loaded on
80114 // demand by the skill tool.
81115 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
82124 // SystemPrefix is prepended as a separate system block ahead of the
83125 // harness prompt. It carries the identity string an OAuth credential
84126 // requires, and is empty for every other credential.
@@ -97,6 +139,10 @@ const (
97139 EventCompact EventKind = "compact"
98140 // EventTextDelta is emitted instead of EventText while streaming.
99141 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"
100146 )
101147
102148 // Event is what the loop reports to an observer. It carries plain data only —
@@ -138,8 +184,68 @@ type Agent struct {
138184 lastInputTokens int
139185
140186 // 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.
142188 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+ }
143249 }
144250
145251 // 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
167273 opts.System = defaultSystem
168274 }
169275 opts.System += skillListing(opts.Skills)
276+ opts.System += modelListing(opts.Models)
170277 return &Agent{client: client, tools: reg, state: state, log: log, opts: opts}
171278 }
172279
@@ -183,8 +290,28 @@ func (a *Agent) emit(ev Event) {
183290 // asking for tools. It returns the final assistant text.
184291 func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
185292 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+ }
186305
306+ func (a *Agent) drive(ctx context.Context) (string, error) {
187307 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+
188315 a.emit(Event{Kind: EventTurnStart, Turn: turn})
189316
190317 // Compaction is checked before building the request, using the token
@@ -345,14 +472,41 @@ func (a *Agent) request() llm.Request {
345472 System: a.systemBlocks(),
346473 Messages: messages,
347474 Tools: defs,
348- Thinking: llm.Adaptive(),
475+ Thinking: a.thinking(),
349476 }
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) {
351482 req.OutputConfig = &llm.OutputConfig{Effort: effort}
352483 }
353484 return req
354485 }
355486
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+
356510 // systemBlocks builds the system prompt. The cache breakpoint goes on the last
357511 // block so everything ahead of it — tools, identity, prompt — is cached
358512 // together; on the first block it would cache the identity string and re-read
+62−1internal/agent/loop_test.goView File
@@ -13,6 +13,7 @@ import (
1313 "strconv"
1414 "strings"
1515 "testing"
16+ "time"
1617
1718 "harness/internal/llm"
1819 "harness/internal/metrics"
@@ -103,6 +104,13 @@ func toolUseBlock(id, name, input string) string {
103104 // newTestAgent wires an agent against the fake API, with read_file rooted at
104105 // a temp dir plus a couple of tools that make failure modes easy to trigger.
105106 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 {
106114 t.Helper()
107115 log := metrics.NewWriter(logBuf, "test")
108116 state := NewState()
@@ -114,7 +122,7 @@ func newTestAgent(t *testing.T, f *fakeAPI, root string, logBuf *bytes.Buffer) *
114122 return "", errors.New("detonated")
115123 }),
116124 )
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))
118126 return New(client, reg, state, log, Options{})
119127 }
120128
@@ -252,6 +260,8 @@ func TestRequestShape(t *testing.T) {
252260 if strings.ContainsAny(req.System[0].Text, "0123456789") {
253261 t.Error("system prompt should hold no digits: timestamps or ids here would break the cached prefix")
254262 }
263+ // The agent runs on llm.DefaultModel, which takes adaptive thinking: a
264+ // model that supports it must still be asked for it.
255265 if req.Thinking == nil || req.Thinking.Type != "adaptive" {
256266 t.Errorf("thinking = %+v, want adaptive (budget_tokens is a 400 on current models)", req.Thinking)
257267 }
@@ -592,3 +602,54 @@ func TestSetEffortAppliesToTheNextRequest(t *testing.T) {
592602 t.Errorf("output_config = %+v, want effort low", oc)
593603 }
594604 }
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+ }
+354−0internal/agent/pause_test.goaddedView File
@@ -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+ }