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