v0.38.1 969e52a

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

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