v0.43.0 63d78c8

turn_recovery_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close