v0.6.0 01ec125

compact_queue_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"errors"
6	"sync"
7	"testing"
8	"time"
9
10	"github.com/bnema/zut/packages/core"
11	"github.com/bnema/zut/packages/provider"
12	"github.com/bnema/zut/packages/tui"
13)
14
15type compactQueueClient struct {
16	compactionStarted chan struct{}
17	releaseCompaction chan struct{}
18	followUpRequest   chan provider.Request
19
20	mu    sync.Mutex
21	calls int
22}
23
24func (c *compactQueueClient) Name() string { return "compact-queue-test" }
25
26func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
27	c.mu.Lock()
28	c.calls++
29	call := c.calls
30	c.mu.Unlock()
31
32	out := make(chan provider.Event, 2)
33	go func() {
34		defer close(out)
35		if call == 1 {
36			close(c.compactionStarted)
37			select {
38			case <-ctx.Done():
39				out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
40			case <-c.releaseCompaction:
41				out <- provider.EventTextDelta{Delta: "summary"}
42				out <- provider.EventDone{Stop: provider.StopEnd}
43			}
44			return
45		}
46
47		c.followUpRequest <- req
48		out <- provider.EventDone{
49			Stop: provider.StopEnd,
50			Message: provider.Message{
51				Role:    provider.RoleAssistant,
52				Content: []provider.Content{provider.TextBlock{Text: "done"}},
53			},
54		}
55	}()
56	return out, nil
57}
58
59type autoCompactPacerClient struct {
60	compactionStarted chan struct{}
61	releaseCompaction chan struct{}
62
63	mu    sync.Mutex
64	calls int
65}
66
67func (c *autoCompactPacerClient) Name() string { return "auto-compact-pacer-test" }
68
69func (c *autoCompactPacerClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
70	c.mu.Lock()
71	c.calls++
72	call := c.calls
73	c.mu.Unlock()
74
75	out := make(chan provider.Event, 3)
76	go func() {
77		defer close(out)
78		switch call {
79		case 1:
80			const response = "a final response that is still buffered for typewriter rendering"
81			out <- provider.EventTextDelta{Delta: response}
82			out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
83			out <- provider.EventDone{
84				Stop: provider.StopEnd,
85				Message: provider.Message{
86					Role:    provider.RoleAssistant,
87					Content: []provider.Content{provider.TextBlock{Text: response}},
88				},
89			}
90		case 2:
91			close(c.compactionStarted)
92			select {
93			case <-ctx.Done():
94				out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
95			case <-c.releaseCompaction:
96				out <- provider.EventTextDelta{Delta: "summary"}
97				out <- provider.EventDone{Stop: provider.StopEnd}
98			}
99		}
100	}()
101	return out, nil
102}
103
104func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T) {
105	client := &autoCompactPacerClient{
106		compactionStarted: make(chan struct{}),
107		releaseCompaction: make(chan struct{}),
108	}
109	agent := core.NewAgent(client, "test-model", "", nil)
110	threshold := 70
111	interactive := NewInteractive(InteractiveConfig{
112		Agent:                agent,
113		Provider:             "anthropic",
114		Model:                "claude-sonnet-4-5-20250929",
115		AutoCompactThreshold: &threshold,
116	})
117	interactive.runCtx = context.Background()
118
119	// Do not start the pacer: the test controls the unpainted final delta so
120	// it can prove compaction never races with a still-live streaming frame.
121	interactive.startTurn(context.Background(), "initial request")
122	select {
123	case <-client.compactionStarted:
124	case <-time.After(2 * time.Second):
125		t.Fatal("auto-compaction did not start")
126	}
127
128	interactive.mu.Lock()
129	streaming, pending := interactive.streaming.Len(), len(interactive.streamPending)
130	streamOn, flushPending := interactive.streamOn, interactive.streamFlushPending
131	interactive.mu.Unlock()
132	if streaming != 0 || pending != 0 || streamOn || flushPending {
133		t.Fatalf("auto-compaction started with live stream state: rendered=%d pending=%d on=%t flush=%t", streaming, pending, streamOn, flushPending)
134	}
135
136	close(client.releaseCompaction)
137}
138
139func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
140	client := &compactQueueClient{
141		compactionStarted: make(chan struct{}),
142		releaseCompaction: make(chan struct{}),
143		followUpRequest:   make(chan provider.Request, 1),
144	}
145	agent := core.NewAgent(client, "test-model", "", nil)
146	agent.SetMessages([]provider.Message{
147		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
148		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
149		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
150		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
151		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
152	})
153	interactive := NewInteractive(InteractiveConfig{Agent: agent})
154	interactive.runCtx = context.Background()
155
156	interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
157	select {
158	case <-client.compactionStarted:
159	case <-time.After(2 * time.Second):
160		t.Fatal("compaction did not start")
161	}
162
163	interactive.ed.SetValue("follow up")
164	interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
165	close(client.releaseCompaction)
166
167	select {
168	case req := <-client.followUpRequest:
169		if !requestContainsUserText(req, "follow up") {
170			t.Fatalf("follow-up request does not contain queued prompt: %#v", req.Messages)
171		}
172	case <-time.After(2 * time.Second):
173		t.Fatal("queued prompt did not start a turn after compaction")
174	}
175}
176
177type thresholdAutoCompactClient struct {
178	compactionStarted chan struct{}
179	releaseCompaction chan struct{}
180	followUpRequest   chan provider.Request
181	firstStop         provider.StopReason
182	firstContent      []provider.Content
183	followUpContents  []string
184	blockFollowUpCall int
185	releaseFollowUp   <-chan struct{}
186
187	mu        sync.Mutex
188	calls     int
189	followUps int
190}
191
192func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }
193
194func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
195	c.mu.Lock()
196	c.calls++
197	call := c.calls
198	c.mu.Unlock()
199
200	out := make(chan provider.Event, 3)
201	go func() {
202		defer close(out)
203		switch call {
204		case 1:
205			stop := c.firstStop
206			if stop == "" {
207				stop = provider.StopEnd
208			}
209			content := c.firstContent
210			if len(content) == 0 {
211				content = []provider.Content{provider.ReasoningBlock{Summary: "still working"}}
212			}
213			out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
214			out <- provider.EventDone{
215				Stop: stop,
216				Message: provider.Message{
217					Role:    provider.RoleAssistant,
218					Content: content,
219				},
220			}
221		case 2:
222			close(c.compactionStarted)
223			select {
224			case <-ctx.Done():
225				out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
226			case <-c.releaseCompaction:
227				out <- provider.EventTextDelta{Delta: "summary"}
228				out <- provider.EventDone{Stop: provider.StopEnd}
229			}
230		default:
231			c.mu.Lock()
232			c.followUps++
233			followUp := call - 2
234			text := "continued"
235			if followUp > 0 && followUp <= len(c.followUpContents) {
236				text = c.followUpContents[followUp-1]
237			}
238			c.mu.Unlock()
239			c.followUpRequest <- req
240			if c.blockFollowUpCall == call {
241				select {
242				case <-ctx.Done():
243					out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
244					return
245				case <-c.releaseFollowUp:
246				}
247			}
248			out <- provider.EventDone{
249				Stop: provider.StopEnd,
250				Message: provider.Message{
251					Role:    provider.RoleAssistant,
252					Content: []provider.Content{provider.TextBlock{Text: text}},
253				},
254			}
255		}
256	}()
257	return out, nil
258}
259
260func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
261	client := &thresholdAutoCompactClient{
262		compactionStarted: make(chan struct{}),
263		releaseCompaction: make(chan struct{}),
264		followUpRequest:   make(chan provider.Request, 1),
265	}
266	agent := core.NewAgent(client, "test-model", "", nil)
267	threshold := 70
268	interactive := NewInteractive(InteractiveConfig{
269		Agent:                agent,
270		Provider:             "anthropic",
271		Model:                "claude-sonnet-4-5-20250929",
272		AutoCompactThreshold: &threshold,
273	})
274	interactive.runCtx = context.Background()
275
276	interactive.startTurn(context.Background(), "initial request")
277	select {
278	case <-client.compactionStarted:
279	case <-time.After(2 * time.Second):
280		t.Fatal("threshold auto-compaction did not start")
281	}
282	close(client.releaseCompaction)
283
284	select {
285	case req := <-client.followUpRequest:
286		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
287			t.Fatalf("continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
288		}
289	case <-time.After(2 * time.Second):
290		t.Fatal("threshold auto-compaction did not continue the most recent intent")
291	}
292}
293
294func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
295	client := &thresholdAutoCompactClient{
296		compactionStarted: make(chan struct{}),
297		releaseCompaction: make(chan struct{}),
298		followUpRequest:   make(chan provider.Request, 1),
299		firstContent: []provider.Content{
300			provider.TextBlock{Text: "I have completed this pass. Next I will inspect the remaining call sites."},
301		},
302	}
303	agent := core.NewAgent(client, "test-model", "", nil)
304	threshold := 70
305	interactive := NewInteractive(InteractiveConfig{
306		Agent:                agent,
307		Provider:             "anthropic",
308		Model:                "claude-sonnet-4-5-20250929",
309		AutoCompactThreshold: &threshold,
310	})
311	interactive.runCtx = context.Background()
312
313	interactive.startTurn(context.Background(), "initial request")
314	select {
315	case <-client.compactionStarted:
316	case <-time.After(2 * time.Second):
317		t.Fatal("threshold auto-compaction did not start")
318	}
319	close(client.releaseCompaction)
320
321	select {
322	case req := <-client.followUpRequest:
323		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
324			t.Fatalf("status rescue request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
325		}
326	case <-time.After(2 * time.Second):
327		t.Fatal("status rescue continuation did not start")
328	}
329	waitInteractiveIdle(t, interactive)
330
331	client.mu.Lock()
332	calls := client.calls
333	client.mu.Unlock()
334	if calls != 3 {
335		t.Fatalf("provider called %d times, want initial request, compaction, and one status rescue", calls)
336	}
337}
338
339func TestThresholdAutoCompactionBoundsSecondStatusRescue(t *testing.T) {
340	client := &thresholdAutoCompactClient{
341		compactionStarted: make(chan struct{}),
342		releaseCompaction: make(chan struct{}),
343		followUpRequest:   make(chan provider.Request, 2),
344		firstContent: []provider.Content{
345			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
346		},
347		followUpContents: []string{
348			"Then I will run the targeted tests.",
349			"done",
350		},
351	}
352	agent := core.NewAgent(client, "test-model", "", nil)
353	threshold := 70
354	interactive := NewInteractive(InteractiveConfig{
355		Agent:                agent,
356		Provider:             "anthropic",
357		Model:                "claude-sonnet-4-5-20250929",
358		AutoCompactThreshold: &threshold,
359	})
360	interactive.runCtx = context.Background()
361
362	interactive.startTurn(context.Background(), "initial request")
363	select {
364	case <-client.compactionStarted:
365	case <-time.After(2 * time.Second):
366		t.Fatal("threshold auto-compaction did not start")
367	}
368	close(client.releaseCompaction)
369
370	for attempt := 1; attempt <= 2; attempt++ {
371		select {
372		case req := <-client.followUpRequest:
373			if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
374				t.Fatalf("status rescue %d contains auto-continue prompt %d times, want %d: %#v", attempt, got, attempt, req.Messages)
375			}
376		case <-time.After(2 * time.Second):
377			t.Fatalf("bounded status rescue %d did not start", attempt)
378		}
379	}
380	waitInteractiveIdle(t, interactive)
381
382	client.mu.Lock()
383	calls, followUps := client.calls, client.followUps
384	client.mu.Unlock()
385	if calls != 4 || followUps != 2 {
386		t.Fatalf("provider calls/follow-ups = %d/%d, want 4/2", calls, followUps)
387	}
388}
389
390func TestThresholdAutoCompactionStopsAfterStatusRescueCompletion(t *testing.T) {
391	client := &thresholdAutoCompactClient{
392		compactionStarted: make(chan struct{}),
393		releaseCompaction: make(chan struct{}),
394		followUpRequest:   make(chan provider.Request, 2),
395		firstContent: []provider.Content{
396			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
397		},
398		followUpContents: []string{"Then I will run the targeted tests.", "All required work is complete."},
399	}
400	agent := core.NewAgent(client, "test-model", "", nil)
401	threshold := 70
402	interactive := NewInteractive(InteractiveConfig{
403		Agent:                agent,
404		Provider:             "anthropic",
405		Model:                "claude-sonnet-4-5-20250929",
406		AutoCompactThreshold: &threshold,
407	})
408	interactive.runCtx = context.Background()
409
410	interactive.startTurn(context.Background(), "initial request")
411	select {
412	case <-client.compactionStarted:
413	case <-time.After(2 * time.Second):
414		t.Fatal("threshold auto-compaction did not start")
415	}
416	close(client.releaseCompaction)
417	for attempt := 1; attempt <= 2; attempt++ {
418		select {
419		case <-client.followUpRequest:
420		case <-time.After(2 * time.Second):
421			t.Fatalf("status rescue %d did not start", attempt)
422		}
423	}
424	waitInteractiveIdle(t, interactive)
425
426	client.mu.Lock()
427	calls := client.calls
428	client.mu.Unlock()
429	if calls != 4 {
430		t.Fatalf("provider called %d times after completion, want 4", calls)
431	}
432	select {
433	case <-client.followUpRequest:
434		t.Fatal("completion after bounded rescue started an extra request")
435	default:
436	}
437}
438
439func TestThresholdStatusRescueDefersToNewerUserPrompt(t *testing.T) {
440	client := &thresholdAutoCompactClient{
441		compactionStarted: make(chan struct{}),
442		releaseCompaction: make(chan struct{}),
443		followUpRequest:   make(chan provider.Request, 1),
444		firstContent: []provider.Content{
445			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
446		},
447	}
448	agent := core.NewAgent(client, "test-model", "", nil)
449	threshold := 70
450	interactive := NewInteractive(InteractiveConfig{
451		Agent:                agent,
452		Provider:             "anthropic",
453		Model:                "claude-sonnet-4-5-20250929",
454		AutoCompactThreshold: &threshold,
455	})
456	interactive.runCtx = context.Background()
457
458	interactive.startTurn(context.Background(), "initial request")
459	select {
460	case <-client.compactionStarted:
461	case <-time.After(2 * time.Second):
462		t.Fatal("threshold auto-compaction did not start")
463	}
464	interactive.SubmitOrQueue("newer user request", nil)
465	close(client.releaseCompaction)
466
467	select {
468	case req := <-client.followUpRequest:
469		if got := requestUserTextCount(req, "newer user request"); got != 1 {
470			t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
471		}
472		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 0 {
473			t.Fatalf("superseded status rescue count = %d, want 0: %#v", got, req.Messages)
474		}
475	case <-time.After(2 * time.Second):
476		t.Fatal("newer user request did not run")
477	}
478	waitInteractiveIdle(t, interactive)
479}
480
481func TestThresholdStatusRescueQueuedUserCancelsFollowUpHandoff(t *testing.T) {
482	releaseFollowUp := make(chan struct{})
483	client := &thresholdAutoCompactClient{
484		compactionStarted: make(chan struct{}),
485		releaseCompaction: make(chan struct{}),
486		followUpRequest:   make(chan provider.Request, 3),
487		firstContent: []provider.Content{
488			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
489		},
490		followUpContents: []string{
491			"Then I will run the targeted tests.",
492			"Next I will inspect the user's newly requested work.",
493		},
494		blockFollowUpCall: 3,
495		releaseFollowUp:   releaseFollowUp,
496	}
497	agent := core.NewAgent(client, "test-model", "", nil)
498	threshold := 70
499	interactive := NewInteractive(InteractiveConfig{
500		Agent:                agent,
501		Provider:             "anthropic",
502		Model:                "claude-sonnet-4-5-20250929",
503		AutoCompactThreshold: &threshold,
504	})
505	interactive.runCtx = context.Background()
506
507	interactive.startTurn(context.Background(), "initial request")
508	select {
509	case <-client.compactionStarted:
510	case <-time.After(2 * time.Second):
511		t.Fatal("threshold auto-compaction did not start")
512	}
513	close(client.releaseCompaction)
514
515	select {
516	case req := <-client.followUpRequest:
517		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
518			t.Fatalf("first status rescue contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
519		}
520	case <-time.After(2 * time.Second):
521		t.Fatal("first status rescue did not start")
522	}
523
524	interactive.SubmitOrQueue("newer user request", nil)
525	close(releaseFollowUp)
526
527	select {
528	case req := <-client.followUpRequest:
529		if got := requestUserTextCount(req, "newer user request"); got != 1 {
530			t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
531		}
532		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
533			t.Fatalf("superseded follow-up rescue count = %d, want 1 existing prompt: %#v", got, req.Messages)
534		}
535	case <-time.After(2 * time.Second):
536		t.Fatal("newer user request did not run after the status rescue")
537	}
538	waitInteractiveIdle(t, interactive)
539
540	client.mu.Lock()
541	calls := client.calls
542	client.mu.Unlock()
543	if calls != 4 {
544		t.Fatalf("provider called %d times, want initial request, compaction, status rescue, and newer user request", calls)
545	}
546	select {
547	case req := <-client.followUpRequest:
548		t.Fatalf("superseded handoff started an extra continuation: %#v", req.Messages)
549	default:
550	}
551}
552
553func TestThresholdAutoCompactionDoesNotRescueClearText(t *testing.T) {
554	client := &thresholdAutoCompactClient{
555		compactionStarted: make(chan struct{}),
556		releaseCompaction: make(chan struct{}),
557		followUpRequest:   make(chan provider.Request, 1),
558		firstContent: []provider.Content{
559			provider.TextBlock{Text: "All required work is complete."},
560		},
561	}
562	agent := core.NewAgent(client, "test-model", "", nil)
563	threshold := 70
564	interactive := NewInteractive(InteractiveConfig{
565		Agent:                agent,
566		Provider:             "anthropic",
567		Model:                "claude-sonnet-4-5-20250929",
568		AutoCompactThreshold: &threshold,
569	})
570	interactive.runCtx = context.Background()
571
572	interactive.startTurn(context.Background(), "initial request")
573	select {
574	case <-client.compactionStarted:
575	case <-time.After(2 * time.Second):
576		t.Fatal("threshold auto-compaction did not start")
577	}
578	close(client.releaseCompaction)
579	waitInteractiveIdle(t, interactive)
580
581	client.mu.Lock()
582	calls := client.calls
583	client.mu.Unlock()
584	if calls != 2 {
585		t.Fatalf("provider called %d times for clear text, want initial request and compaction", calls)
586	}
587	select {
588	case req := <-client.followUpRequest:
589		t.Fatalf("clear text unexpectedly started follow-up: %#v", req.Messages)
590	default:
591	}
592}
593
594func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {
595	client := &thresholdAutoCompactClient{
596		compactionStarted: make(chan struct{}),
597		releaseCompaction: make(chan struct{}),
598		followUpRequest:   make(chan provider.Request, 1),
599		firstStop:         provider.StopLength,
600		firstContent:      []provider.Content{provider.TextBlock{Text: "partial answer"}},
601	}
602	agent := core.NewAgent(client, "test-model", "", nil)
603	threshold := 70
604	interactive := NewInteractive(InteractiveConfig{
605		Agent:                agent,
606		Provider:             "anthropic",
607		Model:                "claude-sonnet-4-5-20250929",
608		AutoCompactThreshold: &threshold,
609	})
610	interactive.runCtx = context.Background()
611
612	interactive.startTurn(context.Background(), "initial request")
613	select {
614	case <-client.compactionStarted:
615	case <-time.After(2 * time.Second):
616		t.Fatal("threshold auto-compaction did not start after truncated text")
617	}
618	close(client.releaseCompaction)
619
620	select {
621	case req := <-client.followUpRequest:
622		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
623			t.Fatalf("truncated continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
624		}
625	case <-time.After(2 * time.Second):
626		t.Fatal("threshold auto-compaction did not continue truncated text")
627	}
628}
629
630func TestThresholdAutoCompactionForcedContinuationPrecedesQueuedPrompt(t *testing.T) {
631	client := &thresholdAutoCompactClient{
632		compactionStarted: make(chan struct{}),
633		releaseCompaction: make(chan struct{}),
634		followUpRequest:   make(chan provider.Request, 2),
635		firstStop:         provider.StopLength,
636		firstContent:      []provider.Content{provider.TextBlock{Text: "partial answer"}},
637	}
638	agent := core.NewAgent(client, "test-model", "", nil)
639	threshold := 70
640	interactive := NewInteractive(InteractiveConfig{
641		Agent:                agent,
642		Provider:             "anthropic",
643		Model:                "claude-sonnet-4-5-20250929",
644		AutoCompactThreshold: &threshold,
645	})
646	interactive.runCtx = context.Background()
647
648	interactive.startTurn(context.Background(), "initial request")
649	select {
650	case <-client.compactionStarted:
651	case <-time.After(2 * time.Second):
652		t.Fatal("threshold auto-compaction did not start after truncated text")
653	}
654	interactive.SubmitOrQueue("queued follow-up", nil)
655	close(client.releaseCompaction)
656
657	select {
658	case req := <-client.followUpRequest:
659		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
660			t.Fatalf("first request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
661		}
662		if got := requestUserTextCount(req, "queued follow-up"); got != 0 {
663			t.Fatalf("forced continuation request included queued prompt %d times, want 0: %#v", got, req.Messages)
664		}
665	case <-time.After(2 * time.Second):
666		t.Fatal("threshold auto-compaction did not continue truncated text")
667	}
668
669	select {
670	case req := <-client.followUpRequest:
671		if got := requestUserTextCount(req, "queued follow-up"); got != 1 {
672			t.Fatalf("second request contains queued prompt %d times, want 1: %#v", got, req.Messages)
673		}
674	case <-time.After(2 * time.Second):
675		t.Fatal("queued prompt did not run after forced continuation")
676	}
677
678	deadline := time.NewTimer(2 * time.Second)
679	defer deadline.Stop()
680	poll := time.NewTicker(time.Millisecond)
681	defer poll.Stop()
682	for {
683		interactive.mu.Lock()
684		idle := !interactive.busy
685		interactive.mu.Unlock()
686		if idle {
687			break
688		}
689		select {
690		case <-deadline.C:
691			t.Fatal("interactive did not settle after forced continuation and queued prompt")
692		case <-poll.C:
693		}
694	}
695
696	client.mu.Lock()
697	calls := client.calls
698	followUps := client.followUps
699	client.mu.Unlock()
700	if calls != 4 {
701		t.Fatalf("provider called %d times, want initial request, compaction, forced continuation, and queued prompt", calls)
702	}
703	if followUps != 2 {
704		t.Fatalf("follow-up requests = %d, want forced continuation and queued prompt", followUps)
705	}
706}
707
708func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
709	client := &compactQueueClient{
710		compactionStarted: make(chan struct{}),
711		releaseCompaction: make(chan struct{}),
712		followUpRequest:   make(chan provider.Request, 1),
713	}
714	agent := core.NewAgent(client, "test-model", "", nil)
715	agent.SetMessages([]provider.Message{{
716		Role:    provider.RoleUser,
717		Content: []provider.Content{provider.TextBlock{Text: "one"}},
718	}})
719	threshold := 70
720	interactive := NewInteractive(InteractiveConfig{
721		Agent:                agent,
722		Provider:             "anthropic",
723		Model:                "claude-sonnet-4-5",
724		AutoCompactThreshold: &threshold,
725	})
726	interactive.runCtx = context.Background()
727	interactive.lastCtxInput = 150000
728	image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
729
730	interactive.startTurnWithImages(context.Background(), "follow up", []provider.ImageBlock{image})
731	select {
732	case <-client.compactionStarted:
733	case <-time.After(2 * time.Second):
734		t.Fatal("pre-turn compaction did not start")
735	}
736	close(client.releaseCompaction)
737
738	select {
739	case req := <-client.followUpRequest:
740		if got := requestUserTextCount(req, "follow up"); got != 1 {
741			t.Fatalf("follow-up request contains prompt %d times, want 1: %#v", got, req.Messages)
742		}
743		if got := requestImageCount(req, image); got != 1 {
744			t.Fatalf("follow-up request contains image %d times, want 1: %#v", got, req.Messages)
745		}
746	case <-time.After(2 * time.Second):
747		t.Fatal("pre-turn compaction did not start the pending image request")
748	}
749}
750
751type contextRecoveryClient struct {
752	mu            sync.Mutex
753	calls         int
754	retried       chan provider.Request
755	overflowRetry bool
756}
757
758func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }
759
760func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
761	c.mu.Lock()
762	c.calls++
763	call := c.calls
764	c.mu.Unlock()
765
766	out := make(chan provider.Event, 2)
767	go func() {
768		defer close(out)
769		switch call {
770		case 1:
771			out <- provider.EventDone{
772				Stop: provider.StopError,
773				Err:  errors.New("provider error: Your input exceeds the context window of this model. Please adjust your input and try again."),
774			}
775		case 2:
776			out <- provider.EventTextDelta{Delta: "summary"}
777			out <- provider.EventDone{Stop: provider.StopEnd}
778		default:
779			c.retried <- req
780			if c.overflowRetry {
781				out <- provider.EventDone{
782					Stop: provider.StopError,
783					Err:  errors.New("context window exceeded after compaction"),
784				}
785				return
786			}
787			out <- provider.EventDone{
788				Stop: provider.StopEnd,
789				Message: provider.Message{
790					Role:    provider.RoleAssistant,
791					Content: []provider.Content{provider.TextBlock{Text: "done"}},
792				},
793			}
794		}
795	}()
796	return out, nil
797}
798
799func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) {
800	client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}
801	agent := core.NewAgent(client, "test-model", "", nil)
802	agent.SetMessages([]provider.Message{
803		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
804		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
805		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
806		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
807		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
808	})
809	interactive := NewInteractive(InteractiveConfig{Agent: agent})
810	interactive.runCtx = context.Background()
811
812	image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
813	interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image})
814
815	select {
816	case req := <-client.retried:
817		if got := requestUserTextCount(req, "retry me"); got != 1 {
818			t.Fatalf("retried request contains original prompt %d times, want 1: %#v", got, req.Messages)
819		}
820		if got := requestImageCount(req, image); got != 1 {
821			t.Fatalf("retried request contains original image %d times, want 1: %#v", got, req.Messages)
822		}
823	case <-time.After(2 * time.Second):
824		t.Fatal("context-window error did not compact and retry the prompt")
825	}
826}
827
828func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
829	client := &contextRecoveryClient{
830		retried:       make(chan provider.Request, 2),
831		overflowRetry: true,
832	}
833	agent := core.NewAgent(client, "test-model", "", nil)
834	agent.SetMessages([]provider.Message{
835		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
836		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
837		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
838		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
839		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
840	})
841	interactive := NewInteractive(InteractiveConfig{Agent: agent})
842	interactive.runCtx = context.Background()
843
844	interactive.startTurn(context.Background(), "still too large")
845	select {
846	case <-client.retried:
847	case <-time.After(2 * time.Second):
848		t.Fatal("context-window recovery did not retry")
849	}
850
851	deadline := time.NewTimer(2 * time.Second)
852	defer deadline.Stop()
853	poll := time.NewTicker(time.Millisecond)
854	defer poll.Stop()
855	for {
856		interactive.mu.Lock()
857		idleWithError := !interactive.busy && interactive.statusErr != ""
858		interactive.mu.Unlock()
859		if idleWithError {
860			break
861		}
862		select {
863		case <-deadline.C:
864			t.Fatal("second context-window error did not settle")
865		case <-poll.C:
866		}
867	}
868
869	client.mu.Lock()
870	calls := client.calls
871	client.mu.Unlock()
872	if calls != 3 {
873		t.Fatalf("provider called %d times, want initial request, compaction, and one retry", calls)
874	}
875}
876
877func requestContainsUserText(req provider.Request, want string) bool {
878	return requestUserTextCount(req, want) > 0
879}
880
881func requestUserTextCount(req provider.Request, want string) int {
882	count := 0
883	for _, message := range req.Messages {
884		if message.Role != provider.RoleUser {
885			continue
886		}
887		for _, content := range message.Content {
888			if text, ok := content.(provider.TextBlock); ok && text.Text == want {
889				count++
890			}
891		}
892	}
893	return count
894}
895
896func requestImageCount(req provider.Request, want provider.ImageBlock) int {
897	count := 0
898	for _, message := range req.Messages {
899		if message.Role != provider.RoleUser {
900			continue
901		}
902		for _, content := range message.Content {
903			if image, ok := content.(provider.ImageBlock); ok && image.MimeType == want.MimeType && string(image.Data) == string(want.Data) {
904				count++
905			}
906		}
907	}
908	return count
909}

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