v0.28.0 f592943

compact_queue_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"sync"
8	"testing"
9	"time"
10
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/provider"
13	"github.com/bnema/zut/packages/tui"
14)
15
16type compactQueueClient struct {
17	compactionStarted chan struct{}
18	releaseCompaction chan struct{}
19	followUpRequest   chan provider.Request
20	onFollowUp        func()
21
22	mu    sync.Mutex
23	calls int
24}
25
26func (c *compactQueueClient) Name() string { return "compact-queue-test" }
27
28func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
29	c.mu.Lock()
30	c.calls++
31	call := c.calls
32	c.mu.Unlock()
33
34	out := make(chan provider.Event, 2)
35	go func() {
36		defer close(out)
37		if call == 1 {
38			close(c.compactionStarted)
39			select {
40			case <-ctx.Done():
41				out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
42			case <-c.releaseCompaction:
43				out <- provider.EventTextDelta{Delta: "summary"}
44				out <- provider.EventDone{Stop: provider.StopEnd}
45			}
46			return
47		}
48
49		if c.onFollowUp != nil {
50			c.onFollowUp()
51		}
52		c.followUpRequest <- req
53		out <- provider.EventDone{
54			Stop: provider.StopEnd,
55			Message: provider.Message{
56				Role:    provider.RoleAssistant,
57				Content: []provider.Content{provider.TextBlock{Text: "done"}},
58			},
59		}
60	}()
61	return out, nil
62}
63
64type autoCompactPacerClient struct {
65	compactionStarted chan struct{}
66	releaseCompaction chan struct{}
67
68	mu    sync.Mutex
69	calls int
70}
71
72func (c *autoCompactPacerClient) Name() string { return "auto-compact-pacer-test" }
73
74func (c *autoCompactPacerClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
75	c.mu.Lock()
76	c.calls++
77	call := c.calls
78	c.mu.Unlock()
79
80	out := make(chan provider.Event, 3)
81	go func() {
82		defer close(out)
83		switch call {
84		case 1:
85			const response = "a final response that is still buffered for typewriter rendering"
86			out <- provider.EventTextDelta{Delta: response}
87			out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
88			out <- provider.EventDone{
89				Stop: provider.StopEnd,
90				Message: provider.Message{
91					Role:    provider.RoleAssistant,
92					Content: []provider.Content{provider.TextBlock{Text: response}},
93				},
94			}
95		case 2:
96			close(c.compactionStarted)
97			select {
98			case <-ctx.Done():
99				out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
100			case <-c.releaseCompaction:
101				out <- provider.EventTextDelta{Delta: "summary"}
102				out <- provider.EventDone{Stop: provider.StopEnd}
103			}
104		}
105	}()
106	return out, nil
107}
108
109func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T) {
110	client := &autoCompactPacerClient{
111		compactionStarted: make(chan struct{}),
112		releaseCompaction: make(chan struct{}),
113	}
114	agent := core.NewAgent(client, "test-model", "", nil)
115	threshold := 70
116	interactive := NewInteractive(InteractiveConfig{
117		Agent:                agent,
118		Provider:             "anthropic",
119		Model:                "claude-sonnet-4-5-20250929",
120		AutoCompactThreshold: &threshold,
121	})
122	interactive.runCtx = context.Background()
123
124	// Do not start the pacer: the test controls the unpainted final delta so
125	// it can prove compaction never races with a still-live streaming frame.
126	interactive.startTurn(context.Background(), "initial request")
127	select {
128	case <-client.compactionStarted:
129	case <-time.After(2 * time.Second):
130		t.Fatal("auto-compaction did not start")
131	}
132
133	interactive.mu.Lock()
134	streaming, pending := interactive.streaming.Len(), len(interactive.streamPending)
135	streamOn, flushPending := interactive.streamOn, interactive.streamFlushPending
136	interactive.mu.Unlock()
137	if streaming != 0 || pending != 0 || streamOn || flushPending {
138		t.Fatalf("auto-compaction started with live stream state: rendered=%d pending=%d on=%t flush=%t", streaming, pending, streamOn, flushPending)
139	}
140
141	close(client.releaseCompaction)
142}
143
144func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) {
145	var goalMu sync.Mutex
146	goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
147	currentGoal := func() *core.SessionGoal {
148		goalMu.Lock()
149		defer goalMu.Unlock()
150		return cloneSessionGoal(goal)
151	}
152	persistGoal := func(next *core.SessionGoal) error {
153		goalMu.Lock()
154		goal = cloneSessionGoal(next)
155		goalMu.Unlock()
156		return nil
157	}
158	client := &compactQueueClient{
159		compactionStarted: make(chan struct{}),
160		releaseCompaction: make(chan struct{}),
161		followUpRequest:   make(chan provider.Request, 1),
162		onFollowUp: func() {
163			// Pause before publishing the follow-up so the loop stops after one turn.
164			_ = persistGoal(&core.SessionGoal{Objective: "finish the goal", Status: core.GoalPaused})
165		},
166	}
167	agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
168	agent.SetMessages([]provider.Message{
169		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}},
170		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first result"}}},
171	})
172	interactive := NewInteractive(InteractiveConfig{Agent: agent, CurrentGoal: currentGoal, PersistGoal: persistGoal})
173	interactive.runCtx = context.Background()
174
175	interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginAfterTurnThreshold, lastStop: provider.StopEnd})
176	select {
177	case <-client.compactionStarted:
178	case <-time.After(2 * time.Second):
179		t.Fatal("automatic compaction did not start")
180	}
181	close(client.releaseCompaction)
182
183	select {
184	case req := <-client.followUpRequest:
185		if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Meta[goalContinueMetaKey] != "true" {
186			t.Fatalf("goal continuation tail = %#v", req.Messages)
187		}
188	case <-time.After(2 * time.Second):
189		t.Fatal("active goal did not continue after automatic compaction")
190	}
191	waitInteractiveIdle(t, interactive)
192}
193
194func TestActiveGoalDoesNotContinueAfterManualCompaction(t *testing.T) {
195	goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
196	client := &compactQueueClient{
197		compactionStarted: make(chan struct{}),
198		releaseCompaction: make(chan struct{}),
199		followUpRequest:   make(chan provider.Request, 1),
200	}
201	agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
202	agent.SetMessages([]provider.Message{
203		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}},
204		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "result"}}},
205	})
206	interactive := NewInteractive(InteractiveConfig{
207		Agent:       agent,
208		CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
209		PersistGoal: func(next *core.SessionGoal) error {
210			goal = cloneSessionGoal(next)
211			return nil
212		},
213	})
214	interactive.runCtx = context.Background()
215
216	interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
217	select {
218	case <-client.compactionStarted:
219	case <-time.After(2 * time.Second):
220		t.Fatal("manual compaction did not start")
221	}
222	close(client.releaseCompaction)
223	waitInteractiveIdle(t, interactive)
224	select {
225	case req := <-client.followUpRequest:
226		t.Fatalf("manual compaction started goal continuation: %#v", req)
227	default:
228	}
229}
230
231func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
232	client := &compactQueueClient{
233		compactionStarted: make(chan struct{}),
234		releaseCompaction: make(chan struct{}),
235		followUpRequest:   make(chan provider.Request, 1),
236	}
237	agent := core.NewAgent(client, "test-model", "", nil)
238	agent.SetMessages([]provider.Message{
239		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
240		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
241		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
242		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
243		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
244	})
245	interactive := NewInteractive(InteractiveConfig{Agent: agent})
246	interactive.runCtx = context.Background()
247
248	interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
249	select {
250	case <-client.compactionStarted:
251	case <-time.After(2 * time.Second):
252		t.Fatal("compaction did not start")
253	}
254
255	interactive.ed.SetValue("follow up")
256	interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
257	close(client.releaseCompaction)
258
259	select {
260	case req := <-client.followUpRequest:
261		if !requestContainsUserText(req, "follow up") {
262			t.Fatalf("follow-up request does not contain queued prompt: %#v", req.Messages)
263		}
264	case <-time.After(2 * time.Second):
265		t.Fatal("queued prompt did not start a turn after compaction")
266	}
267}
268
269type thresholdAutoCompactClient struct {
270	compactionStarted chan struct{}
271	releaseCompaction chan struct{}
272	followUpRequest   chan provider.Request
273	firstStop         provider.StopReason
274	firstContent      []provider.Content
275	followUpContents  []string
276	blockFollowUpCall int
277	releaseFollowUp   <-chan struct{}
278
279	mu        sync.Mutex
280	calls     int
281	followUps int
282}
283
284func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }
285
286func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
287	c.mu.Lock()
288	c.calls++
289	call := c.calls
290	c.mu.Unlock()
291
292	out := make(chan provider.Event, 3)
293	go func() {
294		defer close(out)
295		switch call {
296		case 1:
297			stop := c.firstStop
298			if stop == "" {
299				stop = provider.StopEnd
300			}
301			content := c.firstContent
302			if len(content) == 0 {
303				content = []provider.Content{provider.ReasoningBlock{Summary: "still working"}}
304			}
305			out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
306			out <- provider.EventDone{
307				Stop: stop,
308				Message: provider.Message{
309					Role:    provider.RoleAssistant,
310					Content: content,
311				},
312			}
313		case 2:
314			close(c.compactionStarted)
315			select {
316			case <-ctx.Done():
317				out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
318			case <-c.releaseCompaction:
319				out <- provider.EventTextDelta{Delta: "summary"}
320				out <- provider.EventDone{Stop: provider.StopEnd}
321			}
322		default:
323			c.mu.Lock()
324			c.followUps++
325			followUp := call - 2
326			text := "continued"
327			if followUp > 0 && followUp <= len(c.followUpContents) {
328				text = c.followUpContents[followUp-1]
329			}
330			c.mu.Unlock()
331			c.followUpRequest <- req
332			if c.blockFollowUpCall == call {
333				select {
334				case <-ctx.Done():
335					out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
336					return
337				case <-c.releaseFollowUp:
338				}
339			}
340			out <- provider.EventDone{
341				Stop: provider.StopEnd,
342				Message: provider.Message{
343					Role:    provider.RoleAssistant,
344					Content: []provider.Content{provider.TextBlock{Text: text}},
345				},
346			}
347		}
348	}()
349	return out, nil
350}
351
352type restoredHandoffClient struct {
353	requests chan provider.Request
354	replies  []string
355
356	mu    sync.Mutex
357	calls int
358}
359
360func (c *restoredHandoffClient) Name() string { return "restored-handoff-test" }
361
362func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
363	c.mu.Lock()
364	call := c.calls
365	c.calls++
366	text := "done"
367	if call < len(c.replies) {
368		text = c.replies[call]
369	}
370	c.mu.Unlock()
371
372	out := make(chan provider.Event, 1)
373	go func() {
374		defer close(out)
375		c.requests <- req
376		out <- provider.EventDone{
377			Stop: provider.StopEnd,
378			Message: provider.Message{
379				Role:    provider.RoleAssistant,
380				Content: []provider.Content{provider.TextBlock{Text: text}},
381			},
382		}
383	}()
384	return out, nil
385}
386
387type heldCompletionClient struct {
388	started chan struct{}
389	release chan struct{}
390}
391
392func (c *heldCompletionClient) Name() string { return "held-completion-test" }
393
394func (c *heldCompletionClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
395	out := make(chan provider.Event, 1)
396	go func() {
397		defer close(out)
398		close(c.started)
399		select {
400		case <-ctx.Done():
401			out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
402		case <-c.release:
403			out <- provider.EventDone{
404				Stop: provider.StopEnd,
405				Message: provider.Message{
406					Role:    provider.RoleAssistant,
407					Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
408				},
409			}
410		}
411	}()
412	return out, nil
413}
414
415func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) {
416	client := &restoredHandoffClient{
417		requests: make(chan provider.Request, 2),
418		replies:  []string{"Next I will run the targeted tests.", "All required work is complete."},
419	}
420	agent := core.NewAgent(client, "test-model", "", nil)
421	agent.SetMessages([]provider.Message{{
422		Role:    provider.RoleUser,
423		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
424		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
425	}})
426	interactive := NewInteractive(InteractiveConfig{
427		Agent:                 agent,
428		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
429	})
430	interactive.runCtx = context.Background()
431
432	interactive.startRestoredCompactHandoff(context.Background())
433	for attempt := 1; attempt <= 2; attempt++ {
434		select {
435		case req := <-client.requests:
436			if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
437				t.Fatalf("request %d continuation prompt count = %d, want %d: %#v", attempt, got, attempt, req.Messages)
438			}
439		case <-time.After(2 * time.Second):
440			t.Fatalf("restored handoff request %d did not start", attempt)
441		}
442	}
443	waitInteractiveIdle(t, interactive)
444}
445
446func TestRestoredHandoffAppendsMissingContinuationPrompt(t *testing.T) {
447	client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
448	agent := core.NewAgent(client, "test-model", "", nil)
449	interactive := NewInteractive(InteractiveConfig{
450		Agent:                 agent,
451		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"structural_tail"}`),
452	})
453	interactive.runCtx = context.Background()
454
455	interactive.startRestoredCompactHandoff(context.Background())
456	select {
457	case req := <-client.requests:
458		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
459			t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
460		}
461	case <-time.After(2 * time.Second):
462		t.Fatal("restored handoff did not append a missing continuation prompt")
463	}
464	waitInteractiveIdle(t, interactive)
465}
466
467func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
468	persisted := make(chan string, 1)
469	client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
470	agent := core.NewAgent(client, "test-model", "", nil)
471	agent.SetMessages([]provider.Message{
472		{
473			Role:    provider.RoleUser,
474			Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
475			Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
476		},
477		{
478			Role:    provider.RoleAssistant,
479			Content: []provider.Content{provider.TextBlock{Text: "All required work is complete."}},
480		},
481	})
482	interactive := NewInteractive(InteractiveConfig{
483		Agent:                 agent,
484		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
485		PersistCompactHandoff: func(state json.RawMessage) error {
486			select {
487			case persisted <- string(state):
488			default:
489			}
490			return nil
491		},
492	})
493
494	interactive.startRestoredCompactHandoff(context.Background())
495	interactive.mu.Lock()
496	busy := interactive.busy
497	interactive.mu.Unlock()
498	if busy {
499		t.Fatal("discarded restored handoff left the turn slot reserved")
500	}
501	select {
502	case state := <-persisted:
503		if state != "" {
504			t.Fatalf("cleared handoff = %q, want empty", state)
505		}
506	case <-time.After(2 * time.Second):
507		t.Fatal("stale handoff was not cleared")
508	}
509	select {
510	case req := <-client.requests:
511		t.Fatalf("stale handoff started a provider request: %#v", req.Messages)
512	default:
513	}
514}
515
516func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) {
517	client := &restoredHandoffClient{
518		requests: make(chan provider.Request, 1),
519		replies:  []string{"All required work is complete."},
520	}
521	agent := core.NewAgent(client, "test-model", "", nil)
522	agent.SetMessages([]provider.Message{{
523		Role:    provider.RoleUser,
524		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
525		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
526	}})
527	persistenceStarted := make(chan struct{})
528	releasePersistence := make(chan struct{})
529	released := false
530	defer func() {
531		if !released {
532			close(releasePersistence)
533		}
534	}()
535	interactive := NewInteractive(InteractiveConfig{
536		Agent:                 agent,
537		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
538		PersistCompactHandoff: func(json.RawMessage) error {
539			close(persistenceStarted)
540			<-releasePersistence
541			return nil
542		},
543	})
544	interactive.runCtx = context.Background()
545	interactive.mu.Lock()
546	interactive.queued = []core.QueuedMessage{{Text: "newer user request"}}
547	interactive.mu.Unlock()
548
549	done := make(chan struct{})
550	go func() {
551		interactive.startRestoredCompactHandoff(context.Background())
552		close(done)
553	}()
554	select {
555	case <-persistenceStarted:
556	case <-time.After(2 * time.Second):
557		t.Fatal("restored handoff did not reach persistence")
558	}
559	interactive.mu.Lock()
560	busy := interactive.busy
561	interactive.mu.Unlock()
562	if !busy {
563		t.Fatal("restored handoff did not reserve the turn slot before persistence")
564	}
565	close(releasePersistence)
566	released = true
567	<-done
568	select {
569	case <-client.requests:
570	case <-time.After(2 * time.Second):
571		t.Fatal("restored handoff did not continue after persistence")
572	}
573	waitInteractiveIdle(t, interactive)
574}
575
576func TestStatusRescueClearsHandoffWhenAgentIsRemovedDuringTurn(t *testing.T) {
577	client := &heldCompletionClient{started: make(chan struct{}), release: make(chan struct{})}
578	agent := core.NewAgent(client, "test-model", "", nil)
579	agent.SetMessages([]provider.Message{{
580		Role:    provider.RoleUser,
581		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
582		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
583	}})
584	persisted := make(chan string, 1)
585	interactive := NewInteractive(InteractiveConfig{
586		Agent:                 agent,
587		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
588		PersistCompactHandoff: func(state json.RawMessage) error {
589			select {
590			case persisted <- string(state):
591			default:
592			}
593			return nil
594		},
595	})
596	interactive.runCtx = context.Background()
597
598	interactive.startTurnRequest(context.Background(), "", nil, true, false)
599	select {
600	case <-client.started:
601	case <-time.After(2 * time.Second):
602		t.Fatal("continuation did not start")
603	}
604	interactive.mu.Lock()
605	interactive.agent = nil
606	interactive.mu.Unlock()
607	close(client.release)
608
609	select {
610	case state := <-persisted:
611		if state != "" {
612			t.Fatalf("cleared handoff = %q, want empty", state)
613		}
614	case <-time.After(2 * time.Second):
615		t.Fatal("agent removal did not clear the persisted handoff")
616	}
617	waitInteractiveIdle(t, interactive)
618}
619
620func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
621	client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
622	agent := core.NewAgent(client, "test-model", "", nil)
623	agent.SetMessages([]provider.Message{{
624		Role:    provider.RoleUser,
625		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
626		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
627	}})
628	interactive := NewInteractive(InteractiveConfig{
629		Agent:                 agent,
630		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
631	})
632	interactive.runCtx = context.Background()
633	interactive.mu.Lock()
634	interactive.queued = []core.QueuedMessage{{Text: "newer user request"}}
635	interactive.mu.Unlock()
636
637	interactive.startRestoredCompactHandoff(context.Background())
638	select {
639	case req := <-client.requests:
640		if got := requestUserTextCount(req, "newer user request"); got != 1 {
641			t.Fatalf("newer user request count = %d, want 1: %#v", got, req.Messages)
642		}
643		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
644			t.Fatalf("restored continuation prompt count = %d, want only the existing prompt: %#v", got, req.Messages)
645		}
646	case <-time.After(2 * time.Second):
647		t.Fatal("queued user prompt did not run instead of restored handoff")
648	}
649	waitInteractiveIdle(t, interactive)
650}
651
652func TestRestoredExhaustedStatusHandoffDoesNotContinueAgain(t *testing.T) {
653	client := &restoredHandoffClient{
654		requests: make(chan provider.Request, 2),
655		replies:  []string{"Next I will run the targeted tests."},
656	}
657	agent := core.NewAgent(client, "test-model", "", nil)
658	agent.SetMessages([]provider.Message{{
659		Role:    provider.RoleUser,
660		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
661		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
662	}})
663	interactive := NewInteractive(InteractiveConfig{
664		Agent:                 agent,
665		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":2}`),
666	})
667	interactive.runCtx = context.Background()
668
669	interactive.startRestoredCompactHandoff(context.Background())
670	select {
671	case req := <-client.requests:
672		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
673			t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
674		}
675	case <-time.After(2 * time.Second):
676		t.Fatal("restored handoff did not start")
677	}
678	waitInteractiveIdle(t, interactive)
679	select {
680	case req := <-client.requests:
681		t.Fatalf("exhausted handoff started another request: %#v", req.Messages)
682	default:
683	}
684}
685
686func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
687	client := &thresholdAutoCompactClient{
688		compactionStarted: make(chan struct{}),
689		releaseCompaction: make(chan struct{}),
690		followUpRequest:   make(chan provider.Request, 1),
691	}
692	agent := core.NewAgent(client, "test-model", "", nil)
693	threshold := 70
694	interactive := NewInteractive(InteractiveConfig{
695		Agent:                agent,
696		Provider:             "anthropic",
697		Model:                "claude-sonnet-4-5-20250929",
698		AutoCompactThreshold: &threshold,
699	})
700	interactive.runCtx = context.Background()
701
702	interactive.startTurn(context.Background(), "initial request")
703	select {
704	case <-client.compactionStarted:
705	case <-time.After(2 * time.Second):
706		t.Fatal("threshold auto-compaction did not start")
707	}
708	close(client.releaseCompaction)
709
710	select {
711	case req := <-client.followUpRequest:
712		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
713			t.Fatalf("continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
714		}
715	case <-time.After(2 * time.Second):
716		t.Fatal("threshold auto-compaction did not continue the most recent intent")
717	}
718}
719
720func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
721	persistedHandoff := make(chan string, 1)
722	client := &thresholdAutoCompactClient{
723		compactionStarted: make(chan struct{}),
724		releaseCompaction: make(chan struct{}),
725		followUpRequest:   make(chan provider.Request, 1),
726		firstContent: []provider.Content{
727			provider.TextBlock{Text: "I have completed this pass. Next I will inspect the remaining call sites."},
728		},
729	}
730	agent := core.NewAgent(client, "test-model", "", nil)
731	threshold := 70
732	interactive := NewInteractive(InteractiveConfig{
733		Agent:                agent,
734		Provider:             "anthropic",
735		Model:                "claude-sonnet-4-5-20250929",
736		AutoCompactThreshold: &threshold,
737		PersistCompactHandoff: func(state json.RawMessage) error {
738			select {
739			case persistedHandoff <- string(state):
740			default:
741			}
742			return nil
743		},
744	})
745	interactive.runCtx = context.Background()
746
747	interactive.startTurn(context.Background(), "initial request")
748	select {
749	case <-client.compactionStarted:
750	case <-time.After(2 * time.Second):
751		t.Fatal("threshold auto-compaction did not start")
752	}
753	close(client.releaseCompaction)
754
755	select {
756	case state := <-persistedHandoff:
757		want := `{"version":1,"reason":"status_rescue","rescue_attempts":1}`
758		if state != want {
759			t.Fatalf("persisted handoff = %q, want %q", state, want)
760		}
761	case <-time.After(2 * time.Second):
762		t.Fatal("status rescue handoff was not persisted")
763	}
764
765	select {
766	case req := <-client.followUpRequest:
767		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
768			t.Fatalf("status rescue request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
769		}
770	case <-time.After(2 * time.Second):
771		t.Fatal("status rescue continuation did not start")
772	}
773	waitInteractiveIdle(t, interactive)
774
775	client.mu.Lock()
776	calls := client.calls
777	client.mu.Unlock()
778	if calls != 3 {
779		t.Fatalf("provider called %d times, want initial request, compaction, and one status rescue", calls)
780	}
781}
782
783func TestThresholdAutoCompactionBoundsSecondStatusRescue(t *testing.T) {
784	client := &thresholdAutoCompactClient{
785		compactionStarted: make(chan struct{}),
786		releaseCompaction: make(chan struct{}),
787		followUpRequest:   make(chan provider.Request, 2),
788		firstContent: []provider.Content{
789			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
790		},
791		followUpContents: []string{
792			"Then I will run the targeted tests.",
793			"done",
794		},
795	}
796	agent := core.NewAgent(client, "test-model", "", nil)
797	threshold := 70
798	interactive := NewInteractive(InteractiveConfig{
799		Agent:                agent,
800		Provider:             "anthropic",
801		Model:                "claude-sonnet-4-5-20250929",
802		AutoCompactThreshold: &threshold,
803	})
804	interactive.runCtx = context.Background()
805
806	interactive.startTurn(context.Background(), "initial request")
807	select {
808	case <-client.compactionStarted:
809	case <-time.After(2 * time.Second):
810		t.Fatal("threshold auto-compaction did not start")
811	}
812	close(client.releaseCompaction)
813
814	for attempt := 1; attempt <= 2; attempt++ {
815		select {
816		case req := <-client.followUpRequest:
817			if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
818				t.Fatalf("status rescue %d contains auto-continue prompt %d times, want %d: %#v", attempt, got, attempt, req.Messages)
819			}
820		case <-time.After(2 * time.Second):
821			t.Fatalf("bounded status rescue %d did not start", attempt)
822		}
823	}
824	waitInteractiveIdle(t, interactive)
825
826	client.mu.Lock()
827	calls, followUps := client.calls, client.followUps
828	client.mu.Unlock()
829	if calls != 4 || followUps != 2 {
830		t.Fatalf("provider calls/follow-ups = %d/%d, want 4/2", calls, followUps)
831	}
832}
833
834func TestThresholdAutoCompactionStopsAfterStatusRescueCompletion(t *testing.T) {
835	client := &thresholdAutoCompactClient{
836		compactionStarted: make(chan struct{}),
837		releaseCompaction: make(chan struct{}),
838		followUpRequest:   make(chan provider.Request, 2),
839		firstContent: []provider.Content{
840			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
841		},
842		followUpContents: []string{"Then I will run the targeted tests.", "All required work is complete."},
843	}
844	agent := core.NewAgent(client, "test-model", "", nil)
845	threshold := 70
846	interactive := NewInteractive(InteractiveConfig{
847		Agent:                agent,
848		Provider:             "anthropic",
849		Model:                "claude-sonnet-4-5-20250929",
850		AutoCompactThreshold: &threshold,
851	})
852	interactive.runCtx = context.Background()
853
854	interactive.startTurn(context.Background(), "initial request")
855	select {
856	case <-client.compactionStarted:
857	case <-time.After(2 * time.Second):
858		t.Fatal("threshold auto-compaction did not start")
859	}
860	close(client.releaseCompaction)
861	for attempt := 1; attempt <= 2; attempt++ {
862		select {
863		case <-client.followUpRequest:
864		case <-time.After(2 * time.Second):
865			t.Fatalf("status rescue %d did not start", attempt)
866		}
867	}
868	waitInteractiveIdle(t, interactive)
869
870	client.mu.Lock()
871	calls := client.calls
872	client.mu.Unlock()
873	if calls != 4 {
874		t.Fatalf("provider called %d times after completion, want 4", calls)
875	}
876	select {
877	case <-client.followUpRequest:
878		t.Fatal("completion after bounded rescue started an extra request")
879	default:
880	}
881}
882
883func TestThresholdStatusRescueDefersToNewerUserPrompt(t *testing.T) {
884	client := &thresholdAutoCompactClient{
885		compactionStarted: make(chan struct{}),
886		releaseCompaction: make(chan struct{}),
887		followUpRequest:   make(chan provider.Request, 1),
888		firstContent: []provider.Content{
889			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
890		},
891	}
892	agent := core.NewAgent(client, "test-model", "", nil)
893	threshold := 70
894	interactive := NewInteractive(InteractiveConfig{
895		Agent:                agent,
896		Provider:             "anthropic",
897		Model:                "claude-sonnet-4-5-20250929",
898		AutoCompactThreshold: &threshold,
899	})
900	interactive.runCtx = context.Background()
901
902	interactive.startTurn(context.Background(), "initial request")
903	select {
904	case <-client.compactionStarted:
905	case <-time.After(2 * time.Second):
906		t.Fatal("threshold auto-compaction did not start")
907	}
908	interactive.SubmitOrQueue("newer user request", nil)
909	close(client.releaseCompaction)
910
911	select {
912	case req := <-client.followUpRequest:
913		if got := requestUserTextCount(req, "newer user request"); got != 1 {
914			t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
915		}
916		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 0 {
917			t.Fatalf("superseded status rescue count = %d, want 0: %#v", got, req.Messages)
918		}
919	case <-time.After(2 * time.Second):
920		t.Fatal("newer user request did not run")
921	}
922	waitInteractiveIdle(t, interactive)
923}
924
925func TestThresholdStatusRescueQueuedUserCancelsFollowUpHandoff(t *testing.T) {
926	releaseFollowUp := make(chan struct{})
927	client := &thresholdAutoCompactClient{
928		compactionStarted: make(chan struct{}),
929		releaseCompaction: make(chan struct{}),
930		followUpRequest:   make(chan provider.Request, 3),
931		firstContent: []provider.Content{
932			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
933		},
934		followUpContents: []string{
935			"Then I will run the targeted tests.",
936			"Next I will inspect the user's newly requested work.",
937		},
938		blockFollowUpCall: 3,
939		releaseFollowUp:   releaseFollowUp,
940	}
941	agent := core.NewAgent(client, "test-model", "", nil)
942	threshold := 70
943	interactive := NewInteractive(InteractiveConfig{
944		Agent:                agent,
945		Provider:             "anthropic",
946		Model:                "claude-sonnet-4-5-20250929",
947		AutoCompactThreshold: &threshold,
948	})
949	interactive.runCtx = context.Background()
950
951	interactive.startTurn(context.Background(), "initial request")
952	select {
953	case <-client.compactionStarted:
954	case <-time.After(2 * time.Second):
955		t.Fatal("threshold auto-compaction did not start")
956	}
957	close(client.releaseCompaction)
958
959	select {
960	case req := <-client.followUpRequest:
961		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
962			t.Fatalf("first status rescue contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
963		}
964	case <-time.After(2 * time.Second):
965		t.Fatal("first status rescue did not start")
966	}
967
968	interactive.SubmitOrQueue("newer user request", nil)
969	close(releaseFollowUp)
970
971	select {
972	case req := <-client.followUpRequest:
973		if got := requestUserTextCount(req, "newer user request"); got != 1 {
974			t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
975		}
976		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
977			t.Fatalf("superseded follow-up rescue count = %d, want 1 existing prompt: %#v", got, req.Messages)
978		}
979	case <-time.After(2 * time.Second):
980		t.Fatal("newer user request did not run after the status rescue")
981	}
982	waitInteractiveIdle(t, interactive)
983
984	client.mu.Lock()
985	calls := client.calls
986	client.mu.Unlock()
987	if calls != 4 {
988		t.Fatalf("provider called %d times, want initial request, compaction, status rescue, and newer user request", calls)
989	}
990	select {
991	case req := <-client.followUpRequest:
992		t.Fatalf("superseded handoff started an extra continuation: %#v", req.Messages)
993	default:
994	}
995}
996
997func TestThresholdAutoCompactionDoesNotRescueClearText(t *testing.T) {
998	client := &thresholdAutoCompactClient{
999		compactionStarted: make(chan struct{}),
1000		releaseCompaction: make(chan struct{}),
1001		followUpRequest:   make(chan provider.Request, 1),
1002		firstContent: []provider.Content{
1003			provider.TextBlock{Text: "All required work is complete."},
1004		},
1005	}
1006	agent := core.NewAgent(client, "test-model", "", nil)
1007	threshold := 70
1008	interactive := NewInteractive(InteractiveConfig{
1009		Agent:                agent,
1010		Provider:             "anthropic",
1011		Model:                "claude-sonnet-4-5-20250929",
1012		AutoCompactThreshold: &threshold,
1013	})
1014	interactive.runCtx = context.Background()
1015
1016	interactive.startTurn(context.Background(), "initial request")
1017	select {
1018	case <-client.compactionStarted:
1019	case <-time.After(2 * time.Second):
1020		t.Fatal("threshold auto-compaction did not start")
1021	}
1022	close(client.releaseCompaction)
1023	waitInteractiveIdle(t, interactive)
1024
1025	client.mu.Lock()
1026	calls := client.calls
1027	client.mu.Unlock()
1028	if calls != 2 {
1029		t.Fatalf("provider called %d times for clear text, want initial request and compaction", calls)
1030	}
1031	select {
1032	case req := <-client.followUpRequest:
1033		t.Fatalf("clear text unexpectedly started follow-up: %#v", req.Messages)
1034	default:
1035	}
1036}
1037
1038func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {
1039	client := &thresholdAutoCompactClient{
1040		compactionStarted: make(chan struct{}),
1041		releaseCompaction: make(chan struct{}),
1042		followUpRequest:   make(chan provider.Request, 1),
1043		firstStop:         provider.StopLength,
1044		firstContent:      []provider.Content{provider.TextBlock{Text: "partial answer"}},
1045	}
1046	agent := core.NewAgent(client, "test-model", "", nil)
1047	threshold := 70
1048	interactive := NewInteractive(InteractiveConfig{
1049		Agent:                agent,
1050		Provider:             "anthropic",
1051		Model:                "claude-sonnet-4-5-20250929",
1052		AutoCompactThreshold: &threshold,
1053	})
1054	interactive.runCtx = context.Background()
1055
1056	interactive.startTurn(context.Background(), "initial request")
1057	select {
1058	case <-client.compactionStarted:
1059	case <-time.After(2 * time.Second):
1060		t.Fatal("threshold auto-compaction did not start after truncated text")
1061	}
1062	close(client.releaseCompaction)
1063
1064	select {
1065	case req := <-client.followUpRequest:
1066		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
1067			t.Fatalf("truncated continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
1068		}
1069	case <-time.After(2 * time.Second):
1070		t.Fatal("threshold auto-compaction did not continue truncated text")
1071	}
1072}
1073
1074func TestThresholdAutoCompactionForcedContinuationPrecedesQueuedPrompt(t *testing.T) {
1075	client := &thresholdAutoCompactClient{
1076		compactionStarted: make(chan struct{}),
1077		releaseCompaction: make(chan struct{}),
1078		followUpRequest:   make(chan provider.Request, 2),
1079		firstStop:         provider.StopLength,
1080		firstContent:      []provider.Content{provider.TextBlock{Text: "partial answer"}},
1081	}
1082	agent := core.NewAgent(client, "test-model", "", nil)
1083	threshold := 70
1084	interactive := NewInteractive(InteractiveConfig{
1085		Agent:                agent,
1086		Provider:             "anthropic",
1087		Model:                "claude-sonnet-4-5-20250929",
1088		AutoCompactThreshold: &threshold,
1089	})
1090	interactive.runCtx = context.Background()
1091
1092	interactive.startTurn(context.Background(), "initial request")
1093	select {
1094	case <-client.compactionStarted:
1095	case <-time.After(2 * time.Second):
1096		t.Fatal("threshold auto-compaction did not start after truncated text")
1097	}
1098	interactive.SubmitOrQueue("queued follow-up", nil)
1099	close(client.releaseCompaction)
1100
1101	select {
1102	case req := <-client.followUpRequest:
1103		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
1104			t.Fatalf("first request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
1105		}
1106		if got := requestUserTextCount(req, "queued follow-up"); got != 0 {
1107			t.Fatalf("forced continuation request included queued prompt %d times, want 0: %#v", got, req.Messages)
1108		}
1109	case <-time.After(2 * time.Second):
1110		t.Fatal("threshold auto-compaction did not continue truncated text")
1111	}
1112
1113	select {
1114	case req := <-client.followUpRequest:
1115		if got := requestUserTextCount(req, "queued follow-up"); got != 1 {
1116			t.Fatalf("second request contains queued prompt %d times, want 1: %#v", got, req.Messages)
1117		}
1118	case <-time.After(2 * time.Second):
1119		t.Fatal("queued prompt did not run after forced continuation")
1120	}
1121
1122	deadline := time.NewTimer(2 * time.Second)
1123	defer deadline.Stop()
1124	poll := time.NewTicker(time.Millisecond)
1125	defer poll.Stop()
1126	for {
1127		interactive.mu.Lock()
1128		idle := !interactive.busy
1129		interactive.mu.Unlock()
1130		if idle {
1131			break
1132		}
1133		select {
1134		case <-deadline.C:
1135			t.Fatal("interactive did not settle after forced continuation and queued prompt")
1136		case <-poll.C:
1137		}
1138	}
1139
1140	client.mu.Lock()
1141	calls := client.calls
1142	followUps := client.followUps
1143	client.mu.Unlock()
1144	if calls != 4 {
1145		t.Fatalf("provider called %d times, want initial request, compaction, forced continuation, and queued prompt", calls)
1146	}
1147	if followUps != 2 {
1148		t.Fatalf("follow-up requests = %d, want forced continuation and queued prompt", followUps)
1149	}
1150}
1151
1152func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
1153	client := &compactQueueClient{
1154		compactionStarted: make(chan struct{}),
1155		releaseCompaction: make(chan struct{}),
1156		followUpRequest:   make(chan provider.Request, 1),
1157	}
1158	agent := core.NewAgent(client, "test-model", "", nil)
1159	agent.SetMessages([]provider.Message{{
1160		Role:    provider.RoleUser,
1161		Content: []provider.Content{provider.TextBlock{Text: "one"}},
1162	}})
1163	threshold := 70
1164	interactive := NewInteractive(InteractiveConfig{
1165		Agent:                agent,
1166		Provider:             "anthropic",
1167		Model:                "claude-sonnet-4-5",
1168		AutoCompactThreshold: &threshold,
1169	})
1170	interactive.runCtx = context.Background()
1171	interactive.lastCtxInput = 150000
1172	image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
1173
1174	interactive.startTurnWithImages(context.Background(), "follow up", []provider.ImageBlock{image})
1175	select {
1176	case <-client.compactionStarted:
1177	case <-time.After(2 * time.Second):
1178		t.Fatal("pre-turn compaction did not start")
1179	}
1180	close(client.releaseCompaction)
1181
1182	select {
1183	case req := <-client.followUpRequest:
1184		if got := requestUserTextCount(req, "follow up"); got != 1 {
1185			t.Fatalf("follow-up request contains prompt %d times, want 1: %#v", got, req.Messages)
1186		}
1187		if got := requestImageCount(req, image); got != 1 {
1188			t.Fatalf("follow-up request contains image %d times, want 1: %#v", got, req.Messages)
1189		}
1190	case <-time.After(2 * time.Second):
1191		t.Fatal("pre-turn compaction did not start the pending image request")
1192	}
1193}
1194
1195type contextRecoveryClient struct {
1196	mu            sync.Mutex
1197	calls         int
1198	retried       chan provider.Request
1199	overflowRetry bool
1200}
1201
1202func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }
1203
1204func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
1205	c.mu.Lock()
1206	c.calls++
1207	call := c.calls
1208	c.mu.Unlock()
1209
1210	out := make(chan provider.Event, 2)
1211	go func() {
1212		defer close(out)
1213		switch call {
1214		case 1:
1215			out <- provider.EventDone{
1216				Stop: provider.StopError,
1217				Err:  errors.New("provider error: Your input exceeds the context window of this model. Please adjust your input and try again."),
1218			}
1219		case 2:
1220			out <- provider.EventTextDelta{Delta: "summary"}
1221			out <- provider.EventDone{Stop: provider.StopEnd}
1222		default:
1223			c.retried <- req
1224			if c.overflowRetry {
1225				out <- provider.EventDone{
1226					Stop: provider.StopError,
1227					Err:  errors.New("context window exceeded after compaction"),
1228				}
1229				return
1230			}
1231			out <- provider.EventDone{
1232				Stop: provider.StopEnd,
1233				Message: provider.Message{
1234					Role:    provider.RoleAssistant,
1235					Content: []provider.Content{provider.TextBlock{Text: "done"}},
1236				},
1237			}
1238		}
1239	}()
1240	return out, nil
1241}
1242
1243func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) {
1244	client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}
1245	agent := core.NewAgent(client, "test-model", "", nil)
1246	agent.SetMessages([]provider.Message{
1247		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
1248		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
1249		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
1250		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
1251		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
1252	})
1253	interactive := NewInteractive(InteractiveConfig{Agent: agent})
1254	interactive.runCtx = context.Background()
1255
1256	image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
1257	interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image})
1258
1259	select {
1260	case req := <-client.retried:
1261		if got := requestUserTextCount(req, "retry me"); got != 1 {
1262			t.Fatalf("retried request contains original prompt %d times, want 1: %#v", got, req.Messages)
1263		}
1264		if got := requestImageCount(req, image); got != 1 {
1265			t.Fatalf("retried request contains original image %d times, want 1: %#v", got, req.Messages)
1266		}
1267	case <-time.After(2 * time.Second):
1268		t.Fatal("context-window error did not compact and retry the prompt")
1269	}
1270}
1271
1272func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
1273	client := &contextRecoveryClient{
1274		retried:       make(chan provider.Request, 2),
1275		overflowRetry: true,
1276	}
1277	agent := core.NewAgent(client, "test-model", "", nil)
1278	agent.SetMessages([]provider.Message{
1279		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
1280		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
1281		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
1282		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
1283		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
1284	})
1285	interactive := NewInteractive(InteractiveConfig{Agent: agent})
1286	interactive.runCtx = context.Background()
1287
1288	interactive.startTurn(context.Background(), "still too large")
1289	select {
1290	case <-client.retried:
1291	case <-time.After(2 * time.Second):
1292		t.Fatal("context-window recovery did not retry")
1293	}
1294
1295	deadline := time.NewTimer(2 * time.Second)
1296	defer deadline.Stop()
1297	poll := time.NewTicker(time.Millisecond)
1298	defer poll.Stop()
1299	for {
1300		interactive.mu.Lock()
1301		idleWithError := !interactive.busy && interactive.statusErr != ""
1302		interactive.mu.Unlock()
1303		if idleWithError {
1304			break
1305		}
1306		select {
1307		case <-deadline.C:
1308			t.Fatal("second context-window error did not settle")
1309		case <-poll.C:
1310		}
1311	}
1312
1313	client.mu.Lock()
1314	calls := client.calls
1315	client.mu.Unlock()
1316	if calls != 3 {
1317		t.Fatalf("provider called %d times, want initial request, compaction, and one retry", calls)
1318	}
1319}
1320
1321func requestContainsUserText(req provider.Request, want string) bool {
1322	return requestUserTextCount(req, want) > 0
1323}
1324
1325func requestUserTextCount(req provider.Request, want string) int {
1326	count := 0
1327	for _, message := range req.Messages {
1328		if message.Role != provider.RoleUser {
1329			continue
1330		}
1331		for _, content := range message.Content {
1332			if text, ok := content.(provider.TextBlock); ok && text.Text == want {
1333				count++
1334			}
1335		}
1336	}
1337	return count
1338}
1339
1340func requestImageCount(req provider.Request, want provider.ImageBlock) int {
1341	count := 0
1342	for _, message := range req.Messages {
1343		if message.Role != provider.RoleUser {
1344			continue
1345		}
1346		for _, content := range message.Content {
1347			if image, ok := content.(provider.ImageBlock); ok && image.MimeType == want.MimeType && string(image.Data) == string(want.Data) {
1348				count++
1349			}
1350		}
1351	}
1352	return count
1353}

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