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