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