v0.9.0 84a0b9d

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

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 476

↑↓ moveenter openesc close