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