1package core
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "github.com/bnema/zut/packages/provider"
14)
15
16// turnRecoveryScriptClient replays a scripted sequence of provider turns,
17// one per Stream call. A nil-content entry emits an assistant message with
18// no content blocks; unknownStop entries exercise non-end stops.
19type turnRecoveryScriptClient struct {
20 calls int32
21 turns []turnRecoveryTurn
22}
23
24type turnRecoveryTurn struct {
25 content []provider.Content
26 stop provider.StopReason
27 err error
28}
29
30func (c *turnRecoveryScriptClient) Name() string { return "turn-recovery-script" }
31
32func (c *turnRecoveryScriptClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
33 call := int(atomic.AddInt32(&c.calls, 1)) - 1
34 turn := turnRecoveryTurn{stop: provider.StopEnd}
35 if call < len(c.turns) {
36 turn = c.turns[call]
37 }
38 out := make(chan provider.Event, 8)
39 out <- provider.EventStart{Provider: c.Name(), Model: req.Model}
40 out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 1, OutputTokens: 1}}
41 msg := provider.Message{Role: provider.RoleAssistant, Content: turn.content, Time: time.Now()}
42 out <- provider.EventDone{Stop: turn.stop, Err: turn.err, Message: msg}
43 close(out)
44 return out, nil
45}
46
47func (c *turnRecoveryScriptClient) Calls() int32 { return atomic.LoadInt32(&c.calls) }
48
49func textContent(text, phase string) provider.Content {
50 return provider.TextBlock{Text: text, Phase: phase}
51}
52
53func recoveryEvents(sink *[]AgentEvent) func(AgentEvent) {
54 return func(ev AgentEvent) {
55 *sink = append(*sink, ev)
56 }
57}
58
59func countEvents(events []AgentEvent, want func(AgentEvent) bool) int {
60 n := 0
61 for _, ev := range events {
62 if want(ev) {
63 n++
64 }
65 }
66 return n
67}
68
69func isEvDone(ev AgentEvent) bool { _, ok := ev.(EvDone); return ok }
70
71func isTurnEnd(stop provider.StopReason) func(AgentEvent) bool {
72 return func(ev AgentEvent) bool {
73 te, ok := ev.(EvTurnEnd)
74 return ok && te.Stop == stop && te.Err == nil
75 }
76}
77
78func lastAssistantMessage(t *testing.T, agent *Agent) provider.Message {
79 t.Helper()
80 msgs := agent.Messages()
81 for i := len(msgs) - 1; i >= 0; i-- {
82 if msgs[i].Role == provider.RoleAssistant {
83 return msgs[i]
84 }
85 }
86 t.Fatal("no assistant message in transcript")
87 return provider.Message{}
88}
89
90func recoverySynthetic(t *testing.T, agent *Agent) provider.Message {
91 t.Helper()
92 for _, m := range agent.Messages() {
93 if m.Role == provider.RoleUser && m.Meta[turnRecoveryMetaKey] == "true" {
94 return m
95 }
96 }
97 t.Fatal("no recovery synthetic user message in transcript")
98 return provider.Message{}
99}
100
101func runPrompt(t *testing.T, agent *Agent, events *[]AgentEvent) error {
102 t.Helper()
103 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
104 defer cancel()
105 return agent.Prompt(ctx, "start", nil, recoveryEvents(events))
106}
107
108// TestTurnRecoveryEmptyFinalAfterTool: a tool turn followed by an empty
109// normal terminal response triggers one recovery and can then finish.
110func TestTurnRecoveryEmptyFinalAfterTool(t *testing.T) {
111 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
112 {content: []provider.Content{provider.ToolCallBlock{ID: "c1", Name: "result", Arguments: json.RawMessage(`{}`)}}, stop: provider.StopToolUse},
113 {content: nil, stop: provider.StopEnd},
114 {content: []provider.Content{textContent("done", "")}, stop: provider.StopEnd},
115 }}
116 agent := NewAgent(client, "test-model", "system", Registry{
117 "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}},
118 })
119 var events []AgentEvent
120 if err := runPrompt(t, agent, &events); err != nil {
121 t.Fatalf("Prompt returned %v", err)
122 }
123 if got := client.Calls(); got != 3 {
124 t.Fatalf("provider calls = %d, want 3", got)
125 }
126 if got := countEvents(events, func(ev AgentEvent) bool {
127 r, ok := ev.(EvTurnRecovery)
128 return ok && r.Reason == TurnRecoveryReasonMissingAnswer && r.Attempt == 1
129 }); got != 1 {
130 t.Fatalf("EvTurnRecovery count = %d, want 1 missing_answer", got)
131 }
132 if got := lastAssistantMessage(t, agent).Content[0].(provider.TextBlock).Text; got != "done" {
133 t.Fatalf("final text = %q, want done", got)
134 }
135 synth := recoverySynthetic(t, agent)
136 if !strings.Contains(synth.Content[0].(provider.TextBlock).Text, "without a final answer") {
137 t.Fatalf("recovery text = %q", synth.Content[0].(provider.TextBlock).Text)
138 }
139 // Tool-result pairing survives the recovery round trip.
140 var toolResults int
141 for _, m := range agent.Messages() {
142 if m.Role == provider.RoleTool {
143 toolResults++
144 }
145 }
146 if toolResults != 1 {
147 t.Fatalf("tool messages = %d, want 1", toolResults)
148 }
149}
150
151// TestTurnRecoveryCommentaryOnly: explicitly commentary-only normal
152// completion triggers one recovery; the commentary reason is reported.
153func TestTurnRecoveryCommentaryOnly(t *testing.T) {
154 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
155 {content: []provider.Content{textContent("working on it", provider.TextPhaseCommentary)}, stop: provider.StopEnd},
156 {content: []provider.Content{textContent("result", "")}, stop: provider.StopEnd},
157 }}
158 agent := NewAgent(client, "test-model", "system", Registry{})
159 var events []AgentEvent
160 if err := runPrompt(t, agent, &events); err != nil {
161 t.Fatalf("Prompt returned %v", err)
162 }
163 if got := client.Calls(); got != 2 {
164 t.Fatalf("provider calls = %d, want 2", got)
165 }
166 if got := countEvents(events, func(ev AgentEvent) bool {
167 r, ok := ev.(EvTurnRecovery)
168 return ok && r.Reason == TurnRecoveryReasonCommentaryOnly && r.Attempt == 1
169 }); got != 1 {
170 t.Fatalf("commentary_only EvTurnRecovery count = %d, want 1", got)
171 }
172 if got := countEvents(events, isEvDone); got != 1 {
173 t.Fatalf("EvDone count = %d, want exactly 1", got)
174 }
175}
176
177// TestTurnRecoveryFinalAnswerNoRecovery covers the no-recovery cases: a
178// final answer, an unknown/absent phase, image output, and commentary-
179// then-final likewise complete without extra requests.
180func TestTurnRecoveryFinalAnswerNoRecovery(t *testing.T) {
181 cases := map[string]turnRecoveryTurn{
182 "final answer": {content: []provider.Content{textContent("here is the result", provider.TextPhaseFinal)}},
183 "unknown phase": {content: []provider.Content{textContent("answer", "draft")}},
184 "absent phase": {content: []provider.Content{textContent("answer", "")}},
185 "refusal": {content: []provider.Content{textContent("I cannot do that.", "")}},
186 "clarification": {content: []provider.Content{textContent("Which file do you mean?", "")}},
187 "image output": {content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte{1, 2, 3}}}},
188 "commentary then fin": {content: []provider.Content{textContent("progress", provider.TextPhaseCommentary), textContent("result", provider.TextPhaseFinal)}},
189 "commentary then pla": {content: []provider.Content{textContent("progress", provider.TextPhaseCommentary), textContent("result", "")}},
190 }
191 for name, turn := range cases {
192 t.Run(name, func(t *testing.T) {
193 turn.stop = provider.StopEnd
194 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{turn}}
195 agent := NewAgent(client, "test-model", "system", Registry{})
196 var events []AgentEvent
197 if err := runPrompt(t, agent, &events); err != nil {
198 t.Fatalf("Prompt returned %v", err)
199 }
200 if got := client.Calls(); got != 1 {
201 t.Fatalf("provider calls = %d, want 1 (no recovery)", got)
202 }
203 if got := countEvents(events, func(ev AgentEvent) bool {
204 _, ok := ev.(EvTurnRecovery)
205 return ok
206 }); got != 0 {
207 t.Fatalf("EvTurnRecovery count = %d, want 0", got)
208 }
209 })
210 }
211}
212
213// TestAgentTurnRecoveryReasoningOnlyThenAnswer: a reasoning-only terminal
214// message carries no answer and recovers as missing_answer.
215func TestAgentTurnRecoveryReasoningOnlyThenAnswer(t *testing.T) {
216 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
217 {content: []provider.Content{provider.ReasoningBlock{Summary: "thinking"}}, stop: provider.StopEnd},
218 {content: []provider.Content{textContent("result", "")}, stop: provider.StopEnd},
219 }}
220 agent := NewAgent(client, "test-model", "system", Registry{})
221 var events []AgentEvent
222 if err := runPrompt(t, agent, &events); err != nil {
223 t.Fatalf("Prompt returned %v", err)
224 }
225 if got := client.Calls(); got != 2 {
226 t.Fatalf("provider calls = %d, want 2", got)
227 }
228 if got := countEvents(events, func(ev AgentEvent) bool {
229 r, ok := ev.(EvTurnRecovery)
230 return ok && r.Reason == TurnRecoveryReasonMissingAnswer
231 }); got != 1 {
232 t.Fatalf("missing_answer EvTurnRecovery count = %d, want 1", got)
233 }
234}
235
236// TestAgentTurnRecoveryWhitespaceThenAnswer: a whitespace-only terminal
237// message recovers as missing_answer and can finish on the next turn.
238func TestAgentTurnRecoveryWhitespaceThenAnswer(t *testing.T) {
239 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
240 {content: []provider.Content{textContent(" \n\t ", "")}, stop: provider.StopEnd},
241 {content: []provider.Content{textContent("result", "")}, stop: provider.StopEnd},
242 }}
243 agent := NewAgent(client, "test-model", "system", Registry{})
244 var events []AgentEvent
245 if err := runPrompt(t, agent, &events); err != nil {
246 t.Fatalf("Prompt returned %v", err)
247 }
248 if got := client.Calls(); got != 2 {
249 t.Fatalf("provider calls = %d, want 2", got)
250 }
251 if got := countEvents(events, func(ev AgentEvent) bool {
252 r, ok := ev.(EvTurnRecovery)
253 return ok && r.Reason == TurnRecoveryReasonMissingAnswer
254 }); got != 1 {
255 t.Fatalf("missing_answer EvTurnRecovery count = %d, want 1", got)
256 }
257}
258
259// TestAgentTurnRecoveryTwoIncompleteEndings: a second eligible incomplete
260// StopEnd returns sentinel ErrIncompleteTurn with exactly-once terminal
261// notification and no second model EvTurnEnd for the host decision.
262func TestAgentTurnRecoveryTwoIncompleteEndings(t *testing.T) {
263 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
264 {content: nil, stop: provider.StopEnd},
265 {content: []provider.Content{textContent("still working", provider.TextPhaseCommentary)}, stop: provider.StopEnd},
266 }}
267 agent := NewAgent(client, "test-model", "system", Registry{})
268 var events []AgentEvent
269 err := runPrompt(t, agent, &events)
270 if !errors.Is(err, ErrIncompleteTurn) {
271 t.Fatalf("Prompt error = %v, want ErrIncompleteTurn", err)
272 }
273 if got := client.Calls(); got != 2 {
274 t.Fatalf("provider calls = %d, want 2 (bounded)", got)
275 }
276 if got := countEvents(events, func(ev AgentEvent) bool {
277 _, ok := ev.(EvTurnRecovery)
278 return ok
279 }); got != 1 {
280 t.Fatalf("EvTurnRecovery count = %d, want exactly 1", got)
281 }
282 if got := countEvents(events, isEvDone); got != 1 {
283 t.Fatalf("EvDone count = %d, want exactly 1", got)
284 }
285 if got := countEvents(events, isTurnEnd(provider.StopEnd)); got != 2 {
286 t.Fatalf("model StopEnd EvTurnEnd count = %d, want 2 (no second model turn for the host decision)", got)
287 }
288}
289
290// TestAgentTurnRecoveryToolsBetweenIncomplete: calls stay bounded even when
291// tools execute between the two incomplete endings.
292func TestAgentTurnRecoveryToolsBetweenIncomplete(t *testing.T) {
293 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
294 {content: nil, stop: provider.StopEnd},
295 {content: []provider.Content{provider.ToolCallBlock{ID: "c1", Name: "result", Arguments: json.RawMessage(`{}`)}}, stop: provider.StopToolUse},
296 {content: nil, stop: provider.StopEnd},
297 }}
298 agent := NewAgent(client, "test-model", "system", Registry{
299 "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}},
300 })
301 var events []AgentEvent
302 err := runPrompt(t, agent, &events)
303 if !errors.Is(err, ErrIncompleteTurn) {
304 t.Fatalf("Prompt error = %v, want ErrIncompleteTurn", err)
305 }
306 if got := client.Calls(); got != 3 {
307 t.Fatalf("provider calls = %d, want 3", got)
308 }
309 if got := countEvents(events, func(ev AgentEvent) bool {
310 _, ok := ev.(EvTurnRecovery)
311 return ok
312 }); got != 1 {
313 t.Fatalf("EvTurnRecovery count = %d, want 1 (no reset by tool use)", got)
314 }
315 if got := countEvents(events, isEvDone); got != 1 {
316 t.Fatalf("EvDone count = %d, want 1", got)
317 }
318}
319
320// TestAgentTurnRecoveryQueuedInputPriority: user input queued during an
321// incomplete turn is answered first; recovery must not create an unbounded
322// loop for later queued input.
323func TestAgentTurnRecoveryQueuedInputPriority(t *testing.T) {
324 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
325 {content: nil, stop: provider.StopEnd},
326 {content: []provider.Content{textContent("queued answer", "")}, stop: provider.StopEnd},
327 }}
328 agent := NewAgent(client, "test-model", "system", Registry{})
329 var events []AgentEvent
330 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
331 defer cancel()
332 var once sync.Once
333 err := agent.Prompt(ctx, "start", nil, func(ev AgentEvent) {
334 events = append(events, ev)
335 if te, ok := ev.(EvTurnEnd); ok && te.Stop == provider.StopEnd && te.Err == nil {
336 once.Do(func() {
337 if !agent.QueueMessage("follow-up", nil) {
338 t.Error("QueueMessage returned false")
339 }
340 })
341 }
342 if _, ok := ev.(EvTurnRecovery); ok {
343 t.Error("recovery must not fire while queued input is pending")
344 }
345 })
346 if err != nil {
347 t.Fatalf("Prompt returned %v", err)
348 }
349 if got := client.Calls(); got != 2 {
350 t.Fatalf("provider calls = %d, want 2", got)
351 }
352 var sawQueued bool
353 for _, m := range agent.Messages() {
354 if m.Role == provider.RoleUser && strings.Contains(extractText(m), "follow-up") && m.Meta[turnRecoveryMetaKey] != "true" {
355 sawQueued = true
356 }
357 }
358 if !sawQueued {
359 t.Fatal("queued input was not delivered as a user message")
360 }
361}
362
363// TestAgentTurnRecoveryCancellation: no recovery request follows a
364// cancellation; the cancellation error surfaces instead.
365func TestAgentTurnRecoveryCancellation(t *testing.T) {
366 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
367 {content: nil, stop: provider.StopEnd},
368 }}
369 agent := NewAgent(client, "test-model", "system", Registry{})
370 ctx, cancel := context.WithCancel(context.Background())
371 var events []AgentEvent
372 cancel()
373 err := agent.Prompt(ctx, "start", nil, recoveryEvents(&events))
374 if !errors.Is(err, context.Canceled) {
375 t.Fatalf("Prompt error = %v, want context.Canceled", err)
376 }
377 if got := client.Calls(); got != 1 {
378 t.Fatalf("provider calls = %d, want 1 (no recovery after cancel)", got)
379 }
380 if got := countEvents(events, func(ev AgentEvent) bool {
381 _, ok := ev.(EvTurnRecovery)
382 return ok
383 }); got != 0 {
384 t.Fatalf("EvTurnRecovery count = %d, want 0", got)
385 }
386}
387
388// TestAgentTurnRecoveryDeniedToolNoRecovery: a denied tool invocation must
389// not gain a recovery prompt urging more actions.
390func TestAgentTurnRecoveryDeniedToolNoRecovery(t *testing.T) {
391 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
392 {content: []provider.Content{provider.ToolCallBlock{ID: "c1", Name: "result", Arguments: json.RawMessage(`{}`)}}, stop: provider.StopToolUse},
393 {content: nil, stop: provider.StopEnd},
394 }}
395 agent := NewAgent(client, "test-model", "system", Registry{
396 "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}},
397 })
398 agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
399 return false, "tool call refused by user", nil
400 }
401 var events []AgentEvent
402 if err := runPrompt(t, agent, &events); err != nil {
403 t.Fatalf("Prompt returned %v", err)
404 }
405 if got := client.Calls(); got != 2 {
406 t.Fatalf("provider calls = %d, want 2", got)
407 }
408 if got := countEvents(events, func(ev AgentEvent) bool {
409 _, ok := ev.(EvTurnRecovery)
410 return ok
411 }); got != 0 {
412 t.Fatalf("EvTurnRecovery count = %d, want 0 after denial", got)
413 }
414 var sawRecoverySynthetic bool
415 for _, m := range agent.Messages() {
416 if m.Meta[turnRecoveryMetaKey] == "true" {
417 sawRecoverySynthetic = true
418 }
419 }
420 if sawRecoverySynthetic {
421 t.Fatal("recovery synthetic message appended after denial")
422 }
423}
424
425// TestAgentTurnRecoveryDeniedExecuteNoRecovery: a permission refusal
426// returned from tool Execute (not the BeforeToolExecute hook) is also a
427// denial. The model sees the refusal text as the tool result, and a later
428// incomplete normal stop must not gain a recovery prompt urging more work.
429func TestAgentTurnRecoveryDeniedExecuteNoRecovery(t *testing.T) {
430 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
431 {content: []provider.Content{provider.ToolCallBlock{ID: "c1", Name: "result", Arguments: json.RawMessage(`{}`)}}, stop: provider.StopToolUse},
432 {content: nil, stop: provider.StopEnd},
433 }}
434 agent := NewAgent(client, "test-model", "system", Registry{
435 "result": &resultTool{err: &ToolDeniedError{Reason: "permission denied: bash command \"rm\" is not in allowlist"}},
436 })
437 var events []AgentEvent
438 if err := runPrompt(t, agent, &events); err != nil {
439 t.Fatalf("Prompt returned %v", err)
440 }
441 if got := client.Calls(); got != 2 {
442 t.Fatalf("provider calls = %d, want 2", got)
443 }
444 if got := countEvents(events, func(ev AgentEvent) bool {
445 _, ok := ev.(EvTurnRecovery)
446 return ok
447 }); got != 0 {
448 t.Fatalf("EvTurnRecovery count = %d, want 0 after Execute refusal", got)
449 }
450 for _, m := range agent.Messages() {
451 if m.Meta[turnRecoveryMetaKey] == "true" {
452 t.Fatal("recovery synthetic message appended after Execute refusal")
453 }
454 }
455 // The refusal text itself must still reach the model as the tool
456 // result so it can propose a different action.
457 found := false
458 for _, m := range agent.Messages() {
459 if m.Role != provider.RoleTool {
460 continue
461 }
462 for _, c := range m.Content {
463 if tr, ok := c.(provider.ToolResultBlock); ok && tr.IsError {
464 for _, inner := range tr.Content {
465 if tb, ok := inner.(provider.TextBlock); ok && strings.Contains(tb.Text, "permission denied") {
466 found = true
467 }
468 }
469 }
470 }
471 }
472 if !found {
473 t.Fatal("refusal text missing from tool result")
474 }
475}
476
477// TestAgentTurnRecoveryDenialLatchIsSticky: the denial latch is
478// intentionally fail-closed for the whole top-level invocation. An early
479// unrelated denial suppresses later recovery too, so a silent ending
480// after a denial succeeds without urging more actions rather than
481// recovering or erroring. This pins the conservative semantic.
482func TestAgentTurnRecoveryDenialLatchIsSticky(t *testing.T) {
483 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
484 {content: []provider.Content{provider.ToolCallBlock{ID: "c1", Name: "result", Arguments: json.RawMessage(`{}`)}}, stop: provider.StopToolUse},
485 {content: nil, stop: provider.StopEnd},
486 }}
487 agent := NewAgent(client, "test-model", "system", Registry{
488 "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}},
489 })
490 agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
491 return false, "tool call refused by user", nil
492 }
493 var events []AgentEvent
494 if err := runPrompt(t, agent, &events); err != nil {
495 t.Fatalf("Prompt returned %v", err)
496 }
497 if got := countEvents(events, func(ev AgentEvent) bool {
498 _, ok := ev.(EvTurnRecovery)
499 return ok
500 }); got != 0 {
501 t.Fatalf("EvTurnRecovery count = %d, want 0: early denial suppresses later recovery", got)
502 }
503}
504
505// TestAgentTurnRecoveryGuardDenial likewise covers the BeforeTurn guard:
506// a guard-blocked turn is not a missing answer.
507func TestAgentTurnRecoveryGuardDenial(t *testing.T) {
508 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
509 {content: []provider.Content{textContent("answer", "")}, stop: provider.StopEnd},
510 }}
511 agent := NewAgent(client, "test-model", "system", Registry{})
512 agent.BeforeTurn = func(step int) (bool, string) {
513 if step > 1 {
514 return false, "blocked at step 2"
515 }
516 return true, ""
517 }
518 var events []AgentEvent
519 if err := runPrompt(t, agent, &events); err != nil {
520 t.Fatalf("Prompt returned %v", err)
521 }
522 if got := client.Calls(); got != 1 {
523 t.Fatalf("provider calls = %d, want 1", got)
524 }
525}
526
527// TestAgentTurnRecoveryLimitsNoRecovery: output and context limits never
528// recover.
529func TestAgentTurnRecoveryLimitsNoRecovery(t *testing.T) {
530 for _, stop := range []provider.StopReason{provider.StopLength, provider.StopAborted, provider.StopError} {
531 t.Run(string(stop), func(t *testing.T) {
532 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
533 {content: nil, stop: stop},
534 }}
535 agent := NewAgent(client, "test-model", "system", Registry{})
536 var events []AgentEvent
537 if err := runPrompt(t, agent, &events); err != nil {
538 t.Fatalf("Prompt returned %v", err)
539 }
540 if got := client.Calls(); got != 1 {
541 t.Fatalf("provider calls = %d, want 1", got)
542 }
543 if got := countEvents(events, func(ev AgentEvent) bool {
544 _, ok := ev.(EvTurnRecovery)
545 return ok
546 }); got != 0 {
547 t.Fatalf("EvTurnRecovery count = %d, want 0 for stop %q", got, stop)
548 }
549 })
550 }
551}
552
553// TestAgentTurnRecoveryRespectsMaxSteps: the recovery continuation runs
554// inside the existing step budget and does not reset it. With MaxSteps=1
555// the recovery is marked but the budget is exhausted, so the existing
556// typed limit error surfaces; with MaxSteps=2 two incomplete endings
557// still terminate with ErrIncompleteTurn.
558func TestAgentTurnRecoveryRespectsMaxSteps(t *testing.T) {
559 t.Run("budget exhausted", func(t *testing.T) {
560 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
561 {content: nil, stop: provider.StopEnd},
562 }}
563 agent := NewAgent(client, "test-model", "system", Registry{})
564 agent.MaxSteps = 1
565 var events []AgentEvent
566 err := runPrompt(t, agent, &events)
567 if !errors.Is(err, ErrMaxSteps) {
568 t.Fatalf("Prompt error = %v, want ErrMaxSteps", err)
569 }
570 if got := client.Calls(); got != 1 {
571 t.Fatalf("provider calls = %d, want 1 (no reset)", got)
572 }
573 })
574 t.Run("two incomplete within budget", func(t *testing.T) {
575 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
576 {content: nil, stop: provider.StopEnd},
577 {content: nil, stop: provider.StopEnd},
578 }}
579 agent := NewAgent(client, "test-model", "system", Registry{})
580 agent.MaxSteps = 2
581 var events []AgentEvent
582 err := runPrompt(t, agent, &events)
583 if !errors.Is(err, ErrIncompleteTurn) {
584 t.Fatalf("Prompt error = %v, want ErrIncompleteTurn", err)
585 }
586 if got := client.Calls(); got != 2 {
587 t.Fatalf("provider calls = %d, want 2", got)
588 }
589 })
590}
591
592// TestAgentTurnRecoverySyntheticPersistence: the recovery message is
593// appended through AppendUserContext (firing OnMessageAppended for session
594// durability) and replays to the provider on the next request.
595func TestAgentTurnRecoverySyntheticPersistence(t *testing.T) {
596 client := &turnRecoveryScriptClient{turns: []turnRecoveryTurn{
597 {content: []provider.Content{provider.ToolCallBlock{ID: "c1", Name: "result", Arguments: json.RawMessage(`{}`)}}, stop: provider.StopToolUse},
598 {content: nil, stop: provider.StopEnd},
599 {content: []provider.Content{textContent("done", "")}, stop: provider.StopEnd},
600 }}
601 agent := NewAgent(client, "test-model", "system", Registry{
602 "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}},
603 })
604 var appended []provider.Message
605 agent.OnMessageAppended = func(m provider.Message) { appended = append(appended, m) }
606 var events []AgentEvent
607 if err := runPrompt(t, agent, &events); err != nil {
608 t.Fatalf("Prompt returned %v", err)
609 }
610 var sawSynthetic bool
611 for _, m := range appended {
612 if m.Meta[turnRecoveryMetaKey] == "true" {
613 sawSynthetic = true
614 if m.Role != provider.RoleUser || len(m.Content) != 1 {
615 t.Fatalf("recovery appended message = %#v, want single-block user message", m)
616 }
617 }
618 }
619 if !sawSynthetic {
620 t.Fatal("OnMessageAppended never fired for the recovery message")
621 }
622 // The transcript keeps tool-result pairing and the recovery marker in
623 // order. An empty terminal message carries no blocks, so oneTurn does
624 // not append it: user, assistant tool call, tool result, synthetic,
625 // final answer.
626 msgs := agent.Messages()
627 if len(msgs) != 5 {
628 t.Fatalf("transcript messages = %d, want 5", len(msgs))
629 }
630 if msgs[1].Role != provider.RoleAssistant || msgs[2].Role != provider.RoleTool {
631 t.Fatalf("tool pairing broken: roles = %q, %q", msgs[1].Role, msgs[2].Role)
632 }
633 if msgs[3].Meta[turnRecoveryMetaKey] != "true" {
634 t.Fatalf("message 3 meta = %#v, want recovery marker", msgs[3].Meta)
635 }
636}