v0.50.1 a637010

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
72type goalLengthClient struct {
73	requests chan provider.Request
74	onThird  func()
75
76	mu    sync.Mutex
77	calls int
78}
79
80func (c *goalLengthClient) Name() string { return "goal-length-test" }
81
82func (c *goalLengthClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
83	c.mu.Lock()
84	c.calls++
85	call := c.calls
86	c.mu.Unlock()
87
88	out := make(chan provider.Event, 2)
89	go func() {
90		defer close(out)
91		c.requests <- req
92		switch call {
93		case 1:
94			out <- provider.EventDone{
95				Stop: provider.StopLength,
96				Message: provider.Message{
97					Role:    provider.RoleAssistant,
98					Content: []provider.Content{provider.TextBlock{Text: "partial work"}},
99				},
100			}
101		case 2:
102			out <- provider.EventTextDelta{Delta: "durable summary"}
103			out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
104				Role:    provider.RoleAssistant,
105				Content: []provider.Content{provider.TextBlock{Text: "durable summary"}},
106			}}
107		default:
108			if c.onThird != nil {
109				c.onThird()
110			}
111			out <- provider.EventDone{
112				Stop: provider.StopEnd,
113				Message: provider.Message{
114					Role:    provider.RoleAssistant,
115					Content: []provider.Content{provider.TextBlock{Text: "continued"}},
116				},
117			}
118		}
119	}()
120	return out, nil
121}
122
123func (c *autoCompactPacerClient) Name() string { return "auto-compact-pacer-test" }
124
125func (c *autoCompactPacerClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
126	c.mu.Lock()
127	c.calls++
128	call := c.calls
129	c.mu.Unlock()
130
131	out := make(chan provider.Event, 3)
132	go func() {
133		defer close(out)
134		switch call {
135		case 1:
136			const response = "a final response that is still buffered for typewriter rendering"
137			out <- provider.EventTextDelta{Delta: response}
138			out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
139			out <- provider.EventDone{
140				Stop: provider.StopEnd,
141				Message: provider.Message{
142					Role:    provider.RoleAssistant,
143					Content: []provider.Content{provider.TextBlock{Text: response}},
144				},
145			}
146		case 2:
147			close(c.compactionStarted)
148			select {
149			case <-ctx.Done():
150				out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
151			case <-c.releaseCompaction:
152				out <- provider.EventTextDelta{Delta: "summary"}
153				out <- provider.EventDone{Stop: provider.StopEnd}
154			}
155		}
156	}()
157	return out, nil
158}
159
160func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T) {
161	client := &autoCompactPacerClient{
162		compactionStarted: make(chan struct{}),
163		releaseCompaction: make(chan struct{}),
164	}
165	agent := core.NewAgent(client, "test-model", "", nil)
166	threshold := 70
167	interactive := NewInteractive(InteractiveConfig{
168		Agent:                agent,
169		Provider:             "anthropic",
170		Model:                "claude-sonnet-4-5-20250929",
171		AutoCompactThreshold: &threshold,
172	})
173	interactive.runCtx = context.Background()
174
175	// Do not start the pacer: the test controls the unpainted final delta so
176	// it can prove compaction never races with a still-live streaming frame.
177	interactive.startTurn(context.Background(), "initial request")
178	select {
179	case <-client.compactionStarted:
180	case <-time.After(2 * time.Second):
181		t.Fatal("auto-compaction did not start")
182	}
183
184	interactive.mu.Lock()
185	streamText, streamOn := interactive.stream.Text(), interactive.stream.Active()
186	interactive.mu.Unlock()
187	if streamText != "" || streamOn {
188		t.Fatalf("auto-compaction started with live stream state: text=%q on=%t", streamText, streamOn)
189	}
190
191	close(client.releaseCompaction)
192}
193
194func TestActiveGoalLengthStopCompactsAndContinuesAsGoal(t *testing.T) {
195	var goalMu sync.Mutex
196	goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalActive}
197	currentGoal := func() *core.SessionGoal {
198		goalMu.Lock()
199		defer goalMu.Unlock()
200		return cloneSessionGoal(goal)
201	}
202	persistGoal := func(next *core.SessionGoal) error {
203		goalMu.Lock()
204		goal = cloneSessionGoal(next)
205		goalMu.Unlock()
206		return nil
207	}
208	client := &goalLengthClient{
209		requests: make(chan provider.Request, 3),
210		onThird: func() {
211			_ = persistGoal(&core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalPaused})
212		},
213	}
214	agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
215	persistedHandoff := make(chan string, 4)
216	interactive := NewInteractive(InteractiveConfig{
217		Agent:       agent,
218		CurrentGoal: currentGoal,
219		PersistGoal: persistGoal,
220		PersistCompactHandoff: func(state json.RawMessage) error {
221			persistedHandoff <- string(state)
222			return nil
223		},
224	})
225	interactive.runCtx = context.Background()
226
227	run, err := interactive.startGoalRun(currentGoal())
228	if err != nil || run == nil {
229		t.Fatalf("start goal run = (%v, %v)", run, err)
230	}
231	interactive.startGoalContinuation(context.Background(), mustGoalContinuationMessage(t, interactive), run)
232
233	first := receiveRequest(t, client.requests)
234	if first.Messages[len(first.Messages)-1].Meta[goalContinueMetaKey] != "true" {
235		t.Fatalf("initial request tail = %#v, want goal continuation", first.Messages)
236	}
237	select {
238	case handoff := <-persistedHandoff:
239		if handoff != `{"version":1,"reason":"goal_compaction_pending"}` {
240			t.Fatalf("pre-compaction handoff = %q, want durable goal owner", handoff)
241		}
242	case <-time.After(2 * time.Second):
243		t.Fatal("goal handoff was not persisted before compaction")
244	}
245	compaction := receiveRequest(t, client.requests)
246	if compaction.MaxTokens != 4096 {
247		t.Fatalf("second request MaxTokens = %d, want compaction request", compaction.MaxTokens)
248	}
249	continued := receiveRequest(t, client.requests)
250	if continued.Messages[len(continued.Messages)-1].Meta[goalContinueMetaKey] != "true" {
251		t.Fatalf("post-compaction request tail = %#v, want leased goal continuation", continued.Messages)
252	}
253
254	waitInteractiveIdle(t, interactive)
255}
256
257func TestRestoredPendingGoalCompactionRunsBeforeGoalLease(t *testing.T) {
258	var goalMu sync.Mutex
259	goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalActive}
260	currentGoal := func() *core.SessionGoal {
261		goalMu.Lock()
262		defer goalMu.Unlock()
263		return cloneSessionGoal(goal)
264	}
265	persistGoal := func(next *core.SessionGoal) error {
266		goalMu.Lock()
267		goal = cloneSessionGoal(next)
268		goalMu.Unlock()
269		return nil
270	}
271	client := &goalLengthClient{
272		requests: make(chan provider.Request, 2),
273		calls:    1,
274		onThird: func() {
275			_ = persistGoal(&core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalPaused})
276		},
277	}
278	agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
279	agent.SetMessages([]provider.Message{
280		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "original goal work"}}},
281		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "truncated"}}},
282	})
283	interactive := NewInteractive(InteractiveConfig{
284		Agent:                 agent,
285		CurrentGoal:           currentGoal,
286		PersistGoal:           persistGoal,
287		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"goal_compaction_pending"}`),
288	})
289	interactive.runCtx = context.Background()
290
291	interactive.startRestoredCompactHandoff(context.Background())
292	compaction := receiveRequest(t, client.requests)
293	if compaction.MaxTokens != 4096 {
294		t.Fatalf("restored first request MaxTokens = %d, want compaction", compaction.MaxTokens)
295	}
296	request := receiveRequest(t, client.requests)
297	if request.Messages[len(request.Messages)-1].Meta[goalContinueMetaKey] != "true" {
298		t.Fatalf("restored request tail = %#v, want goal continuation", request.Messages)
299	}
300	waitInteractiveIdle(t, interactive)
301}
302
303func TestRestoredGoalCompactHandoffResumesWithGoalLease(t *testing.T) {
304	var goalMu sync.Mutex
305	goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalActive}
306	currentGoal := func() *core.SessionGoal {
307		goalMu.Lock()
308		defer goalMu.Unlock()
309		return cloneSessionGoal(goal)
310	}
311	persistGoal := func(next *core.SessionGoal) error {
312		goalMu.Lock()
313		goal = cloneSessionGoal(next)
314		goalMu.Unlock()
315		return nil
316	}
317	client := &goalLengthClient{
318		requests: make(chan provider.Request, 1),
319		calls:    2,
320		onThird: func() {
321			_ = persistGoal(&core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalPaused})
322		},
323	}
324	agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
325	interactive := NewInteractive(InteractiveConfig{
326		Agent:                 agent,
327		CurrentGoal:           currentGoal,
328		PersistGoal:           persistGoal,
329		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"goal"}`),
330	})
331	interactive.runCtx = context.Background()
332
333	interactive.startRestoredCompactHandoff(context.Background())
334	request := receiveRequest(t, client.requests)
335	if request.Messages[len(request.Messages)-1].Meta[goalContinueMetaKey] != "true" {
336		t.Fatalf("restored request tail = %#v, want goal continuation", request.Messages)
337	}
338	waitInteractiveIdle(t, interactive)
339}
340
341func mustGoalContinuationMessage(t *testing.T, interactive *Interactive) provider.Message {
342	t.Helper()
343	message, ok := interactive.goalContinuationMessage()
344	if !ok {
345		t.Fatal("goal continuation message unavailable")
346	}
347	return message
348}
349
350func receiveRequest(t *testing.T, requests <-chan provider.Request) provider.Request {
351	t.Helper()
352	select {
353	case req := <-requests:
354		return req
355	case <-time.After(2 * time.Second):
356		t.Fatal("timed out waiting for provider request")
357		return provider.Request{}
358	}
359}
360
361func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) {
362	var goalMu sync.Mutex
363	goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
364	currentGoal := func() *core.SessionGoal {
365		goalMu.Lock()
366		defer goalMu.Unlock()
367		return cloneSessionGoal(goal)
368	}
369	persistGoal := func(next *core.SessionGoal) error {
370		goalMu.Lock()
371		goal = cloneSessionGoal(next)
372		goalMu.Unlock()
373		return nil
374	}
375	client := &compactQueueClient{
376		compactionStarted: make(chan struct{}),
377		releaseCompaction: make(chan struct{}),
378		followUpRequest:   make(chan provider.Request, 1),
379		onFollowUp: func() {
380			// Pause before publishing the follow-up so the loop stops after one turn.
381			_ = persistGoal(&core.SessionGoal{Objective: "finish the goal", Status: core.GoalPaused})
382		},
383	}
384	agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
385	agent.SetMessages([]provider.Message{
386		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}},
387		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first result"}}},
388	})
389	interactive := NewInteractive(InteractiveConfig{Agent: agent, CurrentGoal: currentGoal, PersistGoal: persistGoal})
390	interactive.runCtx = context.Background()
391
392	interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginAfterTurnThreshold, lastStop: provider.StopEnd})
393	select {
394	case <-client.compactionStarted:
395	case <-time.After(2 * time.Second):
396		t.Fatal("automatic compaction did not start")
397	}
398	close(client.releaseCompaction)
399
400	select {
401	case req := <-client.followUpRequest:
402		if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Meta[goalContinueMetaKey] != "true" {
403			t.Fatalf("goal continuation tail = %#v", req.Messages)
404		}
405	case <-time.After(2 * time.Second):
406		t.Fatal("active goal did not continue after automatic compaction")
407	}
408	waitInteractiveIdle(t, interactive)
409}
410
411func TestActiveGoalDoesNotContinueAfterManualCompaction(t *testing.T) {
412	goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
413	client := &compactQueueClient{
414		compactionStarted: make(chan struct{}),
415		releaseCompaction: make(chan struct{}),
416		followUpRequest:   make(chan provider.Request, 1),
417	}
418	agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
419	agent.SetMessages([]provider.Message{
420		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}},
421		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "result"}}},
422	})
423	interactive := NewInteractive(InteractiveConfig{
424		Agent:       agent,
425		CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
426		PersistGoal: func(next *core.SessionGoal) error {
427			goal = cloneSessionGoal(next)
428			return nil
429		},
430	})
431	interactive.runCtx = context.Background()
432
433	interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
434	select {
435	case <-client.compactionStarted:
436	case <-time.After(2 * time.Second):
437		t.Fatal("manual compaction did not start")
438	}
439	close(client.releaseCompaction)
440	waitInteractiveIdle(t, interactive)
441	select {
442	case req := <-client.followUpRequest:
443		t.Fatalf("manual compaction started goal continuation: %#v", req)
444	default:
445	}
446}
447
448func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
449	client := &compactQueueClient{
450		compactionStarted: make(chan struct{}),
451		releaseCompaction: make(chan struct{}),
452		followUpRequest:   make(chan provider.Request, 1),
453	}
454	agent := core.NewAgent(client, "test-model", "", nil)
455	agent.SetMessages([]provider.Message{
456		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
457		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
458		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
459		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
460		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
461	})
462	interactive := NewInteractive(InteractiveConfig{Agent: agent})
463	interactive.runCtx = context.Background()
464
465	interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
466	select {
467	case <-client.compactionStarted:
468	case <-time.After(2 * time.Second):
469		t.Fatal("compaction did not start")
470	}
471
472	interactive.ed.SetValue("follow up")
473	interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
474	close(client.releaseCompaction)
475
476	select {
477	case req := <-client.followUpRequest:
478		if !requestContainsUserText(req, "follow up") {
479			t.Fatalf("follow-up request does not contain queued prompt: %#v", req.Messages)
480		}
481	case <-time.After(2 * time.Second):
482		t.Fatal("queued prompt did not start a turn after compaction")
483	}
484}
485
486type thresholdAutoCompactClient struct {
487	compactionStarted chan struct{}
488	releaseCompaction chan struct{}
489	followUpRequest   chan provider.Request
490	firstStop         provider.StopReason
491	firstContent      []provider.Content
492	followUpContents  []string
493	blockFollowUpCall int
494	releaseFollowUp   <-chan struct{}
495
496	mu        sync.Mutex
497	calls     int
498	followUps int
499}
500
501func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }
502
503func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
504	c.mu.Lock()
505	c.calls++
506	call := c.calls
507	c.mu.Unlock()
508
509	out := make(chan provider.Event, 3)
510	go func() {
511		defer close(out)
512		switch call {
513		case 1:
514			stop := c.firstStop
515			if stop == "" {
516				stop = provider.StopEnd
517			}
518			content := c.firstContent
519			if len(content) == 0 {
520				content = []provider.Content{provider.ReasoningBlock{Summary: "still working"}}
521			}
522			out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
523			out <- provider.EventDone{
524				Stop: stop,
525				Message: provider.Message{
526					Role:    provider.RoleAssistant,
527					Content: content,
528				},
529			}
530		case 2:
531			close(c.compactionStarted)
532			select {
533			case <-ctx.Done():
534				out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
535			case <-c.releaseCompaction:
536				out <- provider.EventTextDelta{Delta: "summary"}
537				out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
538					Role:    provider.RoleAssistant,
539					Content: []provider.Content{provider.TextBlock{Text: "summary"}},
540				}}
541			}
542		default:
543			c.mu.Lock()
544			c.followUps++
545			followUp := call - 2
546			text := "continued"
547			if followUp > 0 && followUp <= len(c.followUpContents) {
548				text = c.followUpContents[followUp-1]
549			}
550			c.mu.Unlock()
551			c.followUpRequest <- req
552			if c.blockFollowUpCall == call {
553				select {
554				case <-ctx.Done():
555					out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
556					return
557				case <-c.releaseFollowUp:
558				}
559			}
560			out <- provider.EventDone{
561				Stop: provider.StopEnd,
562				Message: provider.Message{
563					Role:    provider.RoleAssistant,
564					Content: []provider.Content{provider.TextBlock{Text: text}},
565				},
566			}
567		}
568	}()
569	return out, nil
570}
571
572type restoredHandoffClient struct {
573	requests chan provider.Request
574	replies  []string
575
576	mu    sync.Mutex
577	calls int
578}
579
580func (c *restoredHandoffClient) Name() string { return "restored-handoff-test" }
581
582func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
583	c.mu.Lock()
584	call := c.calls
585	c.calls++
586	text := "done"
587	if call < len(c.replies) {
588		text = c.replies[call]
589	}
590	c.mu.Unlock()
591
592	out := make(chan provider.Event, 1)
593	go func() {
594		defer close(out)
595		c.requests <- req
596		out <- provider.EventDone{
597			Stop: provider.StopEnd,
598			Message: provider.Message{
599				Role:    provider.RoleAssistant,
600				Content: []provider.Content{provider.TextBlock{Text: text}},
601			},
602		}
603	}()
604	return out, nil
605}
606
607type heldCompletionClient struct {
608	started chan struct{}
609	release chan struct{}
610}
611
612func (c *heldCompletionClient) Name() string { return "held-completion-test" }
613
614func (c *heldCompletionClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
615	out := make(chan provider.Event, 1)
616	go func() {
617		defer close(out)
618		close(c.started)
619		select {
620		case <-ctx.Done():
621			out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
622		case <-c.release:
623			out <- provider.EventDone{
624				Stop: provider.StopEnd,
625				Message: provider.Message{
626					Role:    provider.RoleAssistant,
627					Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
628				},
629			}
630		}
631	}()
632	return out, nil
633}
634
635func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) {
636	client := &restoredHandoffClient{
637		requests: make(chan provider.Request, 2),
638		replies:  []string{"Next I will run the targeted tests.", "All required work is complete."},
639	}
640	agent := core.NewAgent(client, "test-model", "", nil)
641	agent.SetMessages([]provider.Message{{
642		Role:    provider.RoleUser,
643		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
644		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
645	}})
646	interactive := NewInteractive(InteractiveConfig{
647		Agent:                 agent,
648		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
649	})
650	interactive.runCtx = context.Background()
651
652	interactive.startRestoredCompactHandoff(context.Background())
653	for attempt := 1; attempt <= 2; attempt++ {
654		select {
655		case req := <-client.requests:
656			if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
657				t.Fatalf("request %d continuation prompt count = %d, want %d: %#v", attempt, got, attempt, req.Messages)
658			}
659		case <-time.After(2 * time.Second):
660			t.Fatalf("restored handoff request %d did not start", attempt)
661		}
662	}
663	waitInteractiveIdle(t, interactive)
664}
665
666func TestRestoredHandoffAppendsMissingContinuationPrompt(t *testing.T) {
667	client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
668	agent := core.NewAgent(client, "test-model", "", nil)
669	interactive := NewInteractive(InteractiveConfig{
670		Agent:                 agent,
671		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"structural_tail"}`),
672	})
673	interactive.runCtx = context.Background()
674
675	interactive.startRestoredCompactHandoff(context.Background())
676	select {
677	case req := <-client.requests:
678		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
679			t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
680		}
681	case <-time.After(2 * time.Second):
682		t.Fatal("restored handoff did not append a missing continuation prompt")
683	}
684	waitInteractiveIdle(t, interactive)
685}
686
687func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
688	persisted := make(chan string, 1)
689	client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
690	agent := core.NewAgent(client, "test-model", "", nil)
691	agent.SetMessages([]provider.Message{
692		{
693			Role:    provider.RoleUser,
694			Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
695			Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
696		},
697		{
698			Role:    provider.RoleAssistant,
699			Content: []provider.Content{provider.TextBlock{Text: "All required work is complete."}},
700		},
701	})
702	interactive := NewInteractive(InteractiveConfig{
703		Agent:                 agent,
704		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
705		PersistCompactHandoff: func(state json.RawMessage) error {
706			select {
707			case persisted <- string(state):
708			default:
709			}
710			return nil
711		},
712	})
713
714	interactive.startRestoredCompactHandoff(context.Background())
715	interactive.mu.Lock()
716	busy := interactive.busy
717	interactive.mu.Unlock()
718	if busy {
719		t.Fatal("discarded restored handoff left the turn slot reserved")
720	}
721	select {
722	case state := <-persisted:
723		if state != "" {
724			t.Fatalf("cleared handoff = %q, want empty", state)
725		}
726	case <-time.After(2 * time.Second):
727		t.Fatal("stale handoff was not cleared")
728	}
729	select {
730	case req := <-client.requests:
731		t.Fatalf("stale handoff started a provider request: %#v", req.Messages)
732	default:
733	}
734}
735
736func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) {
737	client := &restoredHandoffClient{
738		requests: make(chan provider.Request, 1),
739		replies:  []string{"All required work is complete."},
740	}
741	agent := core.NewAgent(client, "test-model", "", nil)
742	agent.SetMessages([]provider.Message{{
743		Role:    provider.RoleUser,
744		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
745		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
746	}})
747	persistenceStarted := make(chan struct{})
748	releasePersistence := make(chan struct{})
749	released := false
750	defer func() {
751		if !released {
752			close(releasePersistence)
753		}
754	}()
755	interactive := NewInteractive(InteractiveConfig{
756		Agent:                 agent,
757		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
758		PersistCompactHandoff: func(json.RawMessage) error {
759			close(persistenceStarted)
760			<-releasePersistence
761			return nil
762		},
763	})
764	interactive.runCtx = context.Background()
765	interactive.mu.Lock()
766	interactive.queued = []core.QueuedMessage{{Text: "newer user request"}}
767	interactive.mu.Unlock()
768
769	done := make(chan struct{})
770	go func() {
771		interactive.startRestoredCompactHandoff(context.Background())
772		close(done)
773	}()
774	select {
775	case <-persistenceStarted:
776	case <-time.After(2 * time.Second):
777		t.Fatal("restored handoff did not reach persistence")
778	}
779	interactive.mu.Lock()
780	busy := interactive.busy
781	interactive.mu.Unlock()
782	if !busy {
783		t.Fatal("restored handoff did not reserve the turn slot before persistence")
784	}
785	close(releasePersistence)
786	released = true
787	<-done
788	select {
789	case <-client.requests:
790	case <-time.After(2 * time.Second):
791		t.Fatal("restored handoff did not continue after persistence")
792	}
793	waitInteractiveIdle(t, interactive)
794}
795
796func TestStatusRescueClearsHandoffWhenAgentIsRemovedDuringTurn(t *testing.T) {
797	client := &heldCompletionClient{started: make(chan struct{}), release: make(chan struct{})}
798	agent := core.NewAgent(client, "test-model", "", nil)
799	agent.SetMessages([]provider.Message{{
800		Role:    provider.RoleUser,
801		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
802		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
803	}})
804	persisted := make(chan string, 1)
805	interactive := NewInteractive(InteractiveConfig{
806		Agent:                 agent,
807		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
808		PersistCompactHandoff: func(state json.RawMessage) error {
809			select {
810			case persisted <- string(state):
811			default:
812			}
813			return nil
814		},
815	})
816	interactive.runCtx = context.Background()
817
818	interactive.startTurnRequest(context.Background(), "", nil, true, false)
819	select {
820	case <-client.started:
821	case <-time.After(2 * time.Second):
822		t.Fatal("continuation did not start")
823	}
824	interactive.mu.Lock()
825	interactive.agent = nil
826	interactive.mu.Unlock()
827	close(client.release)
828
829	select {
830	case state := <-persisted:
831		if state != "" {
832			t.Fatalf("cleared handoff = %q, want empty", state)
833		}
834	case <-time.After(2 * time.Second):
835		t.Fatal("agent removal did not clear the persisted handoff")
836	}
837	waitInteractiveIdle(t, interactive)
838}
839
840func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
841	client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
842	agent := core.NewAgent(client, "test-model", "", nil)
843	agent.SetMessages([]provider.Message{{
844		Role:    provider.RoleUser,
845		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
846		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
847	}})
848	interactive := NewInteractive(InteractiveConfig{
849		Agent:                 agent,
850		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
851	})
852	interactive.runCtx = context.Background()
853	interactive.mu.Lock()
854	interactive.queued = []core.QueuedMessage{{Text: "newer user request"}}
855	interactive.mu.Unlock()
856
857	interactive.startRestoredCompactHandoff(context.Background())
858	select {
859	case req := <-client.requests:
860		if got := requestUserTextCount(req, "newer user request"); got != 1 {
861			t.Fatalf("newer user request count = %d, want 1: %#v", got, req.Messages)
862		}
863		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
864			t.Fatalf("restored continuation prompt count = %d, want only the existing prompt: %#v", got, req.Messages)
865		}
866	case <-time.After(2 * time.Second):
867		t.Fatal("queued user prompt did not run instead of restored handoff")
868	}
869	waitInteractiveIdle(t, interactive)
870}
871
872func TestRestoredExhaustedStatusHandoffDoesNotContinueAgain(t *testing.T) {
873	client := &restoredHandoffClient{
874		requests: make(chan provider.Request, 2),
875		replies:  []string{"Next I will run the targeted tests."},
876	}
877	agent := core.NewAgent(client, "test-model", "", nil)
878	agent.SetMessages([]provider.Message{{
879		Role:    provider.RoleUser,
880		Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
881		Meta:    map[string]string{autoCompactContinueMetaKey: "true"},
882	}})
883	interactive := NewInteractive(InteractiveConfig{
884		Agent:                 agent,
885		InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":2}`),
886	})
887	interactive.runCtx = context.Background()
888
889	interactive.startRestoredCompactHandoff(context.Background())
890	select {
891	case req := <-client.requests:
892		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
893			t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
894		}
895	case <-time.After(2 * time.Second):
896		t.Fatal("restored handoff did not start")
897	}
898	waitInteractiveIdle(t, interactive)
899	select {
900	case req := <-client.requests:
901		t.Fatalf("exhausted handoff started another request: %#v", req.Messages)
902	default:
903	}
904}
905
906func TestThresholdAutoCompactionContinuesMostRecentIntent(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: "Next I will inspect the remaining call sites."},
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
933	select {
934	case req := <-client.followUpRequest:
935		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
936			t.Fatalf("continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
937		}
938	case <-time.After(2 * time.Second):
939		t.Fatal("threshold auto-compaction did not continue the most recent intent")
940	}
941}
942
943func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
944	persistedHandoff := make(chan string, 1)
945	client := &thresholdAutoCompactClient{
946		compactionStarted: make(chan struct{}),
947		releaseCompaction: make(chan struct{}),
948		followUpRequest:   make(chan provider.Request, 1),
949		firstContent: []provider.Content{
950			provider.TextBlock{Text: "I have completed this pass. Next I will inspect the remaining call sites."},
951		},
952	}
953	agent := core.NewAgent(client, "test-model", "", nil)
954	threshold := 70
955	interactive := NewInteractive(InteractiveConfig{
956		Agent:                agent,
957		Provider:             "anthropic",
958		Model:                "claude-sonnet-4-5-20250929",
959		AutoCompactThreshold: &threshold,
960		PersistCompactHandoff: func(state json.RawMessage) error {
961			select {
962			case persistedHandoff <- string(state):
963			default:
964			}
965			return nil
966		},
967	})
968	interactive.runCtx = context.Background()
969
970	interactive.startTurn(context.Background(), "initial request")
971	select {
972	case <-client.compactionStarted:
973	case <-time.After(2 * time.Second):
974		t.Fatal("threshold auto-compaction did not start")
975	}
976	close(client.releaseCompaction)
977
978	select {
979	case state := <-persistedHandoff:
980		want := `{"version":1,"reason":"status_rescue","rescue_attempts":1}`
981		if state != want {
982			t.Fatalf("persisted handoff = %q, want %q", state, want)
983		}
984	case <-time.After(2 * time.Second):
985		t.Fatal("status rescue handoff was not persisted")
986	}
987
988	select {
989	case req := <-client.followUpRequest:
990		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
991			t.Fatalf("status rescue request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
992		}
993	case <-time.After(2 * time.Second):
994		t.Fatal("status rescue continuation did not start")
995	}
996	waitInteractiveIdle(t, interactive)
997
998	client.mu.Lock()
999	calls := client.calls
1000	client.mu.Unlock()
1001	if calls != 3 {
1002		t.Fatalf("provider called %d times, want initial request, compaction, and one status rescue", calls)
1003	}
1004}
1005
1006func TestThresholdAutoCompactionBoundsSecondStatusRescue(t *testing.T) {
1007	client := &thresholdAutoCompactClient{
1008		compactionStarted: make(chan struct{}),
1009		releaseCompaction: make(chan struct{}),
1010		followUpRequest:   make(chan provider.Request, 2),
1011		firstContent: []provider.Content{
1012			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
1013		},
1014		followUpContents: []string{
1015			"Then I will run the targeted tests.",
1016			"done",
1017		},
1018	}
1019	agent := core.NewAgent(client, "test-model", "", nil)
1020	threshold := 70
1021	interactive := NewInteractive(InteractiveConfig{
1022		Agent:                agent,
1023		Provider:             "anthropic",
1024		Model:                "claude-sonnet-4-5-20250929",
1025		AutoCompactThreshold: &threshold,
1026	})
1027	interactive.runCtx = context.Background()
1028
1029	interactive.startTurn(context.Background(), "initial request")
1030	select {
1031	case <-client.compactionStarted:
1032	case <-time.After(2 * time.Second):
1033		t.Fatal("threshold auto-compaction did not start")
1034	}
1035	close(client.releaseCompaction)
1036
1037	for attempt := 1; attempt <= 2; attempt++ {
1038		select {
1039		case req := <-client.followUpRequest:
1040			if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
1041				t.Fatalf("status rescue %d contains auto-continue prompt %d times, want %d: %#v", attempt, got, attempt, req.Messages)
1042			}
1043		case <-time.After(2 * time.Second):
1044			t.Fatalf("bounded status rescue %d did not start", attempt)
1045		}
1046	}
1047	waitInteractiveIdle(t, interactive)
1048
1049	client.mu.Lock()
1050	calls, followUps := client.calls, client.followUps
1051	client.mu.Unlock()
1052	if calls != 4 || followUps != 2 {
1053		t.Fatalf("provider calls/follow-ups = %d/%d, want 4/2", calls, followUps)
1054	}
1055}
1056
1057func TestThresholdAutoCompactionStopsAfterStatusRescueCompletion(t *testing.T) {
1058	client := &thresholdAutoCompactClient{
1059		compactionStarted: make(chan struct{}),
1060		releaseCompaction: make(chan struct{}),
1061		followUpRequest:   make(chan provider.Request, 2),
1062		firstContent: []provider.Content{
1063			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
1064		},
1065		followUpContents: []string{"Then I will run the targeted tests.", "All required work is complete."},
1066	}
1067	agent := core.NewAgent(client, "test-model", "", nil)
1068	threshold := 70
1069	interactive := NewInteractive(InteractiveConfig{
1070		Agent:                agent,
1071		Provider:             "anthropic",
1072		Model:                "claude-sonnet-4-5-20250929",
1073		AutoCompactThreshold: &threshold,
1074	})
1075	interactive.runCtx = context.Background()
1076
1077	interactive.startTurn(context.Background(), "initial request")
1078	select {
1079	case <-client.compactionStarted:
1080	case <-time.After(2 * time.Second):
1081		t.Fatal("threshold auto-compaction did not start")
1082	}
1083	close(client.releaseCompaction)
1084	for attempt := 1; attempt <= 2; attempt++ {
1085		select {
1086		case <-client.followUpRequest:
1087		case <-time.After(2 * time.Second):
1088			t.Fatalf("status rescue %d did not start", attempt)
1089		}
1090	}
1091	waitInteractiveIdle(t, interactive)
1092
1093	client.mu.Lock()
1094	calls := client.calls
1095	client.mu.Unlock()
1096	if calls != 4 {
1097		t.Fatalf("provider called %d times after completion, want 4", calls)
1098	}
1099	select {
1100	case <-client.followUpRequest:
1101		t.Fatal("completion after bounded rescue started an extra request")
1102	default:
1103	}
1104}
1105
1106func TestThresholdStatusRescueDefersToNewerUserPrompt(t *testing.T) {
1107	client := &thresholdAutoCompactClient{
1108		compactionStarted: make(chan struct{}),
1109		releaseCompaction: make(chan struct{}),
1110		followUpRequest:   make(chan provider.Request, 1),
1111		firstContent: []provider.Content{
1112			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
1113		},
1114	}
1115	agent := core.NewAgent(client, "test-model", "", nil)
1116	threshold := 70
1117	interactive := NewInteractive(InteractiveConfig{
1118		Agent:                agent,
1119		Provider:             "anthropic",
1120		Model:                "claude-sonnet-4-5-20250929",
1121		AutoCompactThreshold: &threshold,
1122	})
1123	interactive.runCtx = context.Background()
1124
1125	interactive.startTurn(context.Background(), "initial request")
1126	select {
1127	case <-client.compactionStarted:
1128	case <-time.After(2 * time.Second):
1129		t.Fatal("threshold auto-compaction did not start")
1130	}
1131	interactive.SubmitOrQueue("newer user request", nil)
1132	close(client.releaseCompaction)
1133
1134	select {
1135	case req := <-client.followUpRequest:
1136		if got := requestUserTextCount(req, "newer user request"); got != 1 {
1137			t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
1138		}
1139		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 0 {
1140			t.Fatalf("superseded status rescue count = %d, want 0: %#v", got, req.Messages)
1141		}
1142	case <-time.After(2 * time.Second):
1143		t.Fatal("newer user request did not run")
1144	}
1145	waitInteractiveIdle(t, interactive)
1146}
1147
1148func TestThresholdStatusRescueQueuedUserCancelsFollowUpHandoff(t *testing.T) {
1149	releaseFollowUp := make(chan struct{})
1150	client := &thresholdAutoCompactClient{
1151		compactionStarted: make(chan struct{}),
1152		releaseCompaction: make(chan struct{}),
1153		followUpRequest:   make(chan provider.Request, 3),
1154		firstContent: []provider.Content{
1155			provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
1156		},
1157		followUpContents: []string{
1158			"Then I will run the targeted tests.",
1159			"Next I will inspect the user's newly requested work.",
1160		},
1161		blockFollowUpCall: 3,
1162		releaseFollowUp:   releaseFollowUp,
1163	}
1164	agent := core.NewAgent(client, "test-model", "", nil)
1165	threshold := 70
1166	interactive := NewInteractive(InteractiveConfig{
1167		Agent:                agent,
1168		Provider:             "anthropic",
1169		Model:                "claude-sonnet-4-5-20250929",
1170		AutoCompactThreshold: &threshold,
1171	})
1172	interactive.runCtx = context.Background()
1173
1174	interactive.startTurn(context.Background(), "initial request")
1175	select {
1176	case <-client.compactionStarted:
1177	case <-time.After(2 * time.Second):
1178		t.Fatal("threshold auto-compaction did not start")
1179	}
1180	close(client.releaseCompaction)
1181
1182	select {
1183	case req := <-client.followUpRequest:
1184		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
1185			t.Fatalf("first status rescue contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
1186		}
1187	case <-time.After(2 * time.Second):
1188		t.Fatal("first status rescue did not start")
1189	}
1190
1191	interactive.SubmitOrQueue("newer user request", nil)
1192	close(releaseFollowUp)
1193
1194	select {
1195	case req := <-client.followUpRequest:
1196		if got := requestUserTextCount(req, "newer user request"); got != 1 {
1197			t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
1198		}
1199		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
1200			t.Fatalf("superseded follow-up rescue count = %d, want 1 existing prompt: %#v", got, req.Messages)
1201		}
1202	case <-time.After(2 * time.Second):
1203		t.Fatal("newer user request did not run after the status rescue")
1204	}
1205	waitInteractiveIdle(t, interactive)
1206
1207	client.mu.Lock()
1208	calls := client.calls
1209	client.mu.Unlock()
1210	if calls != 4 {
1211		t.Fatalf("provider called %d times, want initial request, compaction, status rescue, and newer user request", calls)
1212	}
1213	select {
1214	case req := <-client.followUpRequest:
1215		t.Fatalf("superseded handoff started an extra continuation: %#v", req.Messages)
1216	default:
1217	}
1218}
1219
1220func TestThresholdAutoCompactionDoesNotRescueClearText(t *testing.T) {
1221	client := &thresholdAutoCompactClient{
1222		compactionStarted: make(chan struct{}),
1223		releaseCompaction: make(chan struct{}),
1224		followUpRequest:   make(chan provider.Request, 1),
1225		firstContent: []provider.Content{
1226			provider.TextBlock{Text: "All required work is complete."},
1227		},
1228	}
1229	agent := core.NewAgent(client, "test-model", "", nil)
1230	threshold := 70
1231	interactive := NewInteractive(InteractiveConfig{
1232		Agent:                agent,
1233		Provider:             "anthropic",
1234		Model:                "claude-sonnet-4-5-20250929",
1235		AutoCompactThreshold: &threshold,
1236	})
1237	interactive.runCtx = context.Background()
1238
1239	interactive.startTurn(context.Background(), "initial request")
1240	select {
1241	case <-client.compactionStarted:
1242	case <-time.After(2 * time.Second):
1243		t.Fatal("threshold auto-compaction did not start")
1244	}
1245	close(client.releaseCompaction)
1246	waitInteractiveIdle(t, interactive)
1247
1248	client.mu.Lock()
1249	calls := client.calls
1250	client.mu.Unlock()
1251	if calls != 2 {
1252		t.Fatalf("provider called %d times for clear text, want initial request and compaction", calls)
1253	}
1254	select {
1255	case req := <-client.followUpRequest:
1256		t.Fatalf("clear text unexpectedly started follow-up: %#v", req.Messages)
1257	default:
1258	}
1259}
1260
1261func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {
1262	client := &thresholdAutoCompactClient{
1263		compactionStarted: make(chan struct{}),
1264		releaseCompaction: make(chan struct{}),
1265		followUpRequest:   make(chan provider.Request, 1),
1266		firstStop:         provider.StopLength,
1267		firstContent:      []provider.Content{provider.TextBlock{Text: "partial answer"}},
1268	}
1269	agent := core.NewAgent(client, "test-model", "", nil)
1270	threshold := 70
1271	interactive := NewInteractive(InteractiveConfig{
1272		Agent:                agent,
1273		Provider:             "anthropic",
1274		Model:                "claude-sonnet-4-5-20250929",
1275		AutoCompactThreshold: &threshold,
1276	})
1277	interactive.runCtx = context.Background()
1278
1279	interactive.startTurn(context.Background(), "initial request")
1280	select {
1281	case <-client.compactionStarted:
1282	case <-time.After(2 * time.Second):
1283		t.Fatal("threshold auto-compaction did not start after truncated text")
1284	}
1285	close(client.releaseCompaction)
1286
1287	select {
1288	case req := <-client.followUpRequest:
1289		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
1290			t.Fatalf("truncated continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
1291		}
1292	case <-time.After(2 * time.Second):
1293		t.Fatal("threshold auto-compaction did not continue truncated text")
1294	}
1295}
1296
1297func TestThresholdAutoCompactionForcedContinuationPrecedesQueuedPrompt(t *testing.T) {
1298	client := &thresholdAutoCompactClient{
1299		compactionStarted: make(chan struct{}),
1300		releaseCompaction: make(chan struct{}),
1301		followUpRequest:   make(chan provider.Request, 2),
1302		firstStop:         provider.StopLength,
1303		firstContent:      []provider.Content{provider.TextBlock{Text: "partial answer"}},
1304	}
1305	agent := core.NewAgent(client, "test-model", "", nil)
1306	threshold := 70
1307	interactive := NewInteractive(InteractiveConfig{
1308		Agent:                agent,
1309		Provider:             "anthropic",
1310		Model:                "claude-sonnet-4-5-20250929",
1311		AutoCompactThreshold: &threshold,
1312	})
1313	interactive.runCtx = context.Background()
1314
1315	interactive.startTurn(context.Background(), "initial request")
1316	select {
1317	case <-client.compactionStarted:
1318	case <-time.After(2 * time.Second):
1319		t.Fatal("threshold auto-compaction did not start after truncated text")
1320	}
1321	interactive.SubmitOrQueue("queued follow-up", nil)
1322	close(client.releaseCompaction)
1323
1324	select {
1325	case req := <-client.followUpRequest:
1326		if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
1327			t.Fatalf("first request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
1328		}
1329		if got := requestUserTextCount(req, "queued follow-up"); got != 0 {
1330			t.Fatalf("forced continuation request included queued prompt %d times, want 0: %#v", got, req.Messages)
1331		}
1332	case <-time.After(2 * time.Second):
1333		t.Fatal("threshold auto-compaction did not continue truncated text")
1334	}
1335
1336	select {
1337	case req := <-client.followUpRequest:
1338		if got := requestUserTextCount(req, "queued follow-up"); got != 1 {
1339			t.Fatalf("second request contains queued prompt %d times, want 1: %#v", got, req.Messages)
1340		}
1341	case <-time.After(2 * time.Second):
1342		t.Fatal("queued prompt did not run after forced continuation")
1343	}
1344
1345	deadline := time.NewTimer(2 * time.Second)
1346	defer deadline.Stop()
1347	poll := time.NewTicker(time.Millisecond)
1348	defer poll.Stop()
1349	for {
1350		interactive.mu.Lock()
1351		idle := !interactive.busy
1352		interactive.mu.Unlock()
1353		if idle {
1354			break
1355		}
1356		select {
1357		case <-deadline.C:
1358			t.Fatal("interactive did not settle after forced continuation and queued prompt")
1359		case <-poll.C:
1360		}
1361	}
1362
1363	client.mu.Lock()
1364	calls := client.calls
1365	followUps := client.followUps
1366	client.mu.Unlock()
1367	if calls != 4 {
1368		t.Fatalf("provider called %d times, want initial request, compaction, forced continuation, and queued prompt", calls)
1369	}
1370	if followUps != 2 {
1371		t.Fatalf("follow-up requests = %d, want forced continuation and queued prompt", followUps)
1372	}
1373}
1374
1375func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
1376	client := &compactQueueClient{
1377		compactionStarted: make(chan struct{}),
1378		releaseCompaction: make(chan struct{}),
1379		followUpRequest:   make(chan provider.Request, 1),
1380	}
1381	agent := core.NewAgent(client, "test-model", "", nil)
1382	agent.SetMessages([]provider.Message{{
1383		Role:    provider.RoleUser,
1384		Content: []provider.Content{provider.TextBlock{Text: "one"}},
1385	}})
1386	threshold := 70
1387	interactive := NewInteractive(InteractiveConfig{
1388		Agent:                agent,
1389		Provider:             "anthropic",
1390		Model:                "claude-sonnet-4-5",
1391		AutoCompactThreshold: &threshold,
1392	})
1393	interactive.runCtx = context.Background()
1394	interactive.lastCtxInput = 150000
1395	image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
1396
1397	interactive.startTurnWithImages(context.Background(), "follow up", []provider.ImageBlock{image})
1398	select {
1399	case <-client.compactionStarted:
1400	case <-time.After(2 * time.Second):
1401		t.Fatal("pre-turn compaction did not start")
1402	}
1403	close(client.releaseCompaction)
1404
1405	select {
1406	case req := <-client.followUpRequest:
1407		if got := requestUserTextCount(req, "follow up"); got != 1 {
1408			t.Fatalf("follow-up request contains prompt %d times, want 1: %#v", got, req.Messages)
1409		}
1410		if got := requestImageCount(req, image); got != 1 {
1411			t.Fatalf("follow-up request contains image %d times, want 1: %#v", got, req.Messages)
1412		}
1413	case <-time.After(2 * time.Second):
1414		t.Fatal("pre-turn compaction did not start the pending image request")
1415	}
1416}
1417
1418type contextRecoveryClient struct {
1419	mu            sync.Mutex
1420	calls         int
1421	retried       chan provider.Request
1422	overflowRetry bool
1423}
1424
1425func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }
1426
1427func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
1428	c.mu.Lock()
1429	c.calls++
1430	call := c.calls
1431	c.mu.Unlock()
1432
1433	out := make(chan provider.Event, 2)
1434	go func() {
1435		defer close(out)
1436		switch call {
1437		case 1:
1438			out <- provider.EventDone{
1439				Stop: provider.StopError,
1440				Err:  errors.New("provider error: Your input exceeds the context window of this model. Please adjust your input and try again."),
1441			}
1442		case 2:
1443			out <- provider.EventTextDelta{Delta: "summary"}
1444			out <- provider.EventDone{Stop: provider.StopEnd}
1445		default:
1446			c.retried <- req
1447			if c.overflowRetry {
1448				out <- provider.EventDone{
1449					Stop: provider.StopError,
1450					Err:  errors.New("context window exceeded after compaction"),
1451				}
1452				return
1453			}
1454			out <- provider.EventDone{
1455				Stop: provider.StopEnd,
1456				Message: provider.Message{
1457					Role:    provider.RoleAssistant,
1458					Content: []provider.Content{provider.TextBlock{Text: "done"}},
1459				},
1460			}
1461		}
1462	}()
1463	return out, nil
1464}
1465
1466func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) {
1467	client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}
1468	agent := core.NewAgent(client, "test-model", "", nil)
1469	agent.SetMessages([]provider.Message{
1470		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
1471		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
1472		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
1473		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
1474		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
1475	})
1476	interactive := NewInteractive(InteractiveConfig{Agent: agent})
1477	interactive.runCtx = context.Background()
1478
1479	image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
1480	interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image})
1481
1482	select {
1483	case req := <-client.retried:
1484		if got := requestUserTextCount(req, "retry me"); got != 1 {
1485			t.Fatalf("retried request contains original prompt %d times, want 1: %#v", got, req.Messages)
1486		}
1487		if got := requestImageCount(req, image); got != 1 {
1488			t.Fatalf("retried request contains original image %d times, want 1: %#v", got, req.Messages)
1489		}
1490	case <-time.After(2 * time.Second):
1491		t.Fatal("context-window error did not compact and retry the prompt")
1492	}
1493}
1494
1495func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
1496	client := &contextRecoveryClient{
1497		retried:       make(chan provider.Request, 2),
1498		overflowRetry: true,
1499	}
1500	agent := core.NewAgent(client, "test-model", "", nil)
1501	agent.SetMessages([]provider.Message{
1502		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
1503		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
1504		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
1505		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
1506		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
1507	})
1508	interactive := NewInteractive(InteractiveConfig{Agent: agent})
1509	interactive.runCtx = context.Background()
1510
1511	interactive.startTurn(context.Background(), "still too large")
1512	select {
1513	case <-client.retried:
1514	case <-time.After(2 * time.Second):
1515		t.Fatal("context-window recovery did not retry")
1516	}
1517
1518	deadline := time.NewTimer(2 * time.Second)
1519	defer deadline.Stop()
1520	poll := time.NewTicker(time.Millisecond)
1521	defer poll.Stop()
1522	for {
1523		interactive.mu.Lock()
1524		idleWithError := !interactive.busy && interactive.statusErr != ""
1525		interactive.mu.Unlock()
1526		if idleWithError {
1527			break
1528		}
1529		select {
1530		case <-deadline.C:
1531			t.Fatal("second context-window error did not settle")
1532		case <-poll.C:
1533		}
1534	}
1535
1536	client.mu.Lock()
1537	calls := client.calls
1538	client.mu.Unlock()
1539	if calls != 3 {
1540		t.Fatalf("provider called %d times, want initial request, compaction, and one retry", calls)
1541	}
1542}
1543
1544func requestContainsUserText(req provider.Request, want string) bool {
1545	return requestUserTextCount(req, want) > 0
1546}
1547
1548func requestUserTextCount(req provider.Request, want string) int {
1549	count := 0
1550	for _, message := range req.Messages {
1551		if message.Role != provider.RoleUser {
1552			continue
1553		}
1554		for _, content := range message.Content {
1555			if text, ok := content.(provider.TextBlock); ok && text.Text == want {
1556				count++
1557			}
1558		}
1559	}
1560	return count
1561}
1562
1563func requestImageCount(req provider.Request, want provider.ImageBlock) int {
1564	count := 0
1565	for _, message := range req.Messages {
1566		if message.Role != provider.RoleUser {
1567			continue
1568		}
1569		for _, content := range message.Content {
1570			if image, ok := content.(provider.ImageBlock); ok && image.MimeType == want.MimeType && string(image.Data) == string(want.Data) {
1571				count++
1572			}
1573		}
1574	}
1575	return count
1576}
1577
1578func TestCompactionRetainsContextEstimate(t *testing.T) {
1579	client := &compactQueueClient{
1580		compactionStarted: make(chan struct{}),
1581		releaseCompaction: make(chan struct{}),
1582		followUpRequest:   make(chan provider.Request, 1),
1583	}
1584	agent := core.NewAgent(client, "test-model", "", nil)
1585	agent.SetMessages([]provider.Message{
1586		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
1587		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
1588		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
1589		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
1590		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
1591	})
1592	interactive := NewInteractive(InteractiveConfig{Agent: agent})
1593
1594	interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
1595	select {
1596	case <-client.compactionStarted:
1597	case <-time.After(2 * time.Second):
1598		t.Fatal("compaction did not start")
1599	}
1600	close(client.releaseCompaction)
1601
1602	deadline := time.NewTimer(5 * time.Second)
1603	defer deadline.Stop()
1604	poll := time.NewTicker(time.Millisecond)
1605	defer poll.Stop()
1606	for {
1607		interactive.mu.Lock()
1608		busy := interactive.busy
1609		got := interactive.lastCtxInput
1610		interactive.mu.Unlock()
1611		if !busy {
1612			want := estimateCompactedContextInput(agent.Messages())
1613			if got != want || got == 0 {
1614				t.Fatalf("context estimate after compaction = %d, want %d", got, want)
1615			}
1616			return
1617		}
1618		select {
1619		case <-deadline.C:
1620			t.Fatal("compaction did not finish")
1621		case <-poll.C:
1622		}
1623	}
1624}
1625
1626func TestCompactionEstimateBypassesImmediateContinuation(t *testing.T) {
1627	client := &compactQueueClient{
1628		compactionStarted: make(chan struct{}),
1629		releaseCompaction: make(chan struct{}),
1630		followUpRequest:   make(chan provider.Request, 1),
1631	}
1632	agent := core.NewAgent(client, "test-model", "", nil)
1633	agent.SetMessages([]provider.Message{
1634		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
1635		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
1636		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
1637		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
1638		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
1639	})
1640	interactive := NewInteractive(InteractiveConfig{Agent: agent})
1641	interactive.runCtx = context.Background()
1642
1643	interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
1644	select {
1645	case <-client.compactionStarted:
1646	case <-time.After(2 * time.Second):
1647		t.Fatal("compaction did not start")
1648	}
1649
1650	interactive.ed.SetValue("follow up")
1651	interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
1652	close(client.releaseCompaction)
1653
1654	select {
1655	case req := <-client.followUpRequest:
1656		if !requestContainsUserText(req, "follow up") {
1657			t.Fatalf("follow-up request does not contain queued prompt: %#v", req.Messages)
1658		}
1659	case <-time.After(5 * time.Second):
1660		t.Fatal("queued prompt did not start a turn after compaction")
1661	}
1662
1663	// The immediate continuation runs against the just-compacted transcript,
1664	// so the pre-turn guard must see a zero estimate until fresh provider
1665	// usage arrives. The follow-up sends no usage, so the bypass must hold
1666	// through the turn with no second compaction (exactly two provider calls).
1667	deadline := time.NewTimer(5 * time.Second)
1668	defer deadline.Stop()
1669	poll := time.NewTicker(time.Millisecond)
1670	defer poll.Stop()
1671	for {
1672		interactive.mu.Lock()
1673		busy := interactive.busy
1674		estimate := interactive.lastCtxInput
1675		interactive.mu.Unlock()
1676		client.mu.Lock()
1677		calls := client.calls
1678		client.mu.Unlock()
1679		if !busy {
1680			if estimate != 0 {
1681				t.Fatalf("context estimate during immediate continuation = %d, want bypass 0", estimate)
1682			}
1683			if calls != 2 {
1684				t.Fatalf("provider called %d times, want compaction plus one follow-up (no second compaction)", calls)
1685			}
1686			return
1687		}
1688		select {
1689		case <-deadline.C:
1690			t.Fatal("follow-up turn did not finish")
1691		case <-poll.C:
1692		}
1693	}
1694}
1695
1696func TestEstimateCompactedContextInput(t *testing.T) {
1697	if got := estimateCompactedContextInput(nil); got != 0 {
1698		t.Fatalf("empty transcript estimate = %d, want 0", got)
1699	}
1700	msgs := []provider.Message{
1701		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "1234"}}},
1702		{Role: provider.RoleAssistant, Content: []provider.Content{
1703			provider.TextBlock{Text: "5678"},
1704			provider.ToolCallBlock{ID: "call-1", Name: "read", Arguments: json.RawMessage(`{}`)},
1705		}},
1706		{Role: provider.RoleTool, Content: []provider.Content{
1707			provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{
1708				provider.TextBlock{Text: "01234567"},
1709				provider.ImageBlock{MimeType: "image/png", Data: []byte("ignored-bytes")},
1710			}},
1711		}},
1712	}
1713	// 4 + 4 + len("read")+2 + 8 = 22 chars → 5 tokens; image bytes excluded.
1714	if got, want := estimateCompactedContextInput(msgs), 5; got != want {
1715		t.Fatalf("estimate = %d, want %d", got, want)
1716	}
1717}

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