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