interactive_completion_error_test.go RawHistoryFolder
1package modes
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 "github.com/bnema/zut/packages/agent/subagents"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16type completionErrorClient struct {
17 calls atomic.Int32
18 started chan struct{}
19 release chan struct{}
20 requests chan provider.Request
21}
22
23func (*completionErrorClient) Name() string { return "completion-error-test" }
24func (c *completionErrorClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
25 if c.calls.Add(1) == 1 {
26 close(c.started)
27 select {
28 case <-ctx.Done():
29 return nil, ctx.Err()
30 case <-c.release:
31 return nil, errors.New("synthetic request failure")
32 }
33 }
34 c.requests <- req
35 out := make(chan provider.Event, 1)
36 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}}
37 close(out)
38 return out, nil
39}
40
41func TestCompletionSurvivesParentError(t *testing.T) {
42 for _, name := range []string{"provider", "compaction", "idle-pre-turn-compaction"} {
43 t.Run(name, func(t *testing.T) {
44 ctx, cancel := context.WithCancel(context.Background())
45 defer cancel()
46 client := &completionErrorClient{started: make(chan struct{}), release: make(chan struct{}), requests: make(chan provider.Request, 4)}
47 ag := core.NewAgent(client, "test-model", "", nil)
48 ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "existing task"}}}})
49 threshold := 70
50 i := NewInteractive(InteractiveConfig{Agent: ag, Provider: "anthropic", Model: "claude-sonnet-4-5", AutoCompactThreshold: &threshold})
51 i.runCtx = ctx
52 report := func() {
53 i.TrackResidentSubagent("worker", "turn")
54 i.ReportResidentSubagent(subagents.ResidentCompletion{ChildID: "worker", TurnID: "turn", Summary: "retained evidence"})
55 }
56 switch name {
57 case "idle-pre-turn-compaction":
58 i.lastCtxInput = 150000
59 report()
60 case "compaction":
61 i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
62 default:
63 i.startTurn(ctx, "work")
64 }
65 select {
66 case <-client.started:
67 case <-time.After(2 * time.Second):
68 t.Fatal("request did not start")
69 }
70 if name != "idle-pre-turn-compaction" {
71 report()
72 }
73 deadline := time.Now().Add(2 * time.Second)
74 for {
75 i.mu.Lock()
76 count := len(i.queued) + ag.QueuedMessageCount()
77 i.mu.Unlock()
78 if count != 0 {
79 break
80 }
81 if time.Now().After(deadline) {
82 t.Fatal("completion did not enter queue")
83 }
84 time.Sleep(time.Millisecond)
85 }
86 i.submitOrQueue("stale user input", nil, true)
87 close(client.release)
88 waitInteractiveIdle(t, i)
89 pending := ag.PendingQueuedMessages()
90 if len(pending) != 1 || !pending[0].HostEvent || !strings.Contains(pending[0].Text, "retained evidence") {
91 t.Fatalf("pending after error = %#v", pending)
92 }
93 if client.calls.Load() != 1 {
94 t.Fatal("error automatically retried")
95 }
96 i.mu.Lock()
97 i.lastCtxInput = 0 // the explicit retry no longer needs compaction
98 i.mu.Unlock()
99 i.startTurn(ctx, "retry now")
100 select {
101 case req := <-client.requests:
102 if got := requestUserTextCount(req, pending[0].Text); got != 1 {
103 t.Fatalf("completion copies = %d, want 1", got)
104 }
105 if requestUserTextCount(req, "stale user input") != 0 {
106 t.Fatal("stale user input survived error")
107 }
108 case <-time.After(2 * time.Second):
109 t.Fatal("explicit retry did not start")
110 }
111 waitInteractiveIdle(t, i)
112 if ag.QueuedMessageCount() != 0 {
113 t.Fatal("completion remained queued after delivery")
114 }
115 })
116 }
117}
118
119func TestExplicitCancellationDiscardsQueuedHostEvents(t *testing.T) {
120 ag := core.NewAgent(nil, "test-model", "", nil)
121 ag.QueuePrompt(core.QueuedMessage{Text: "agent event", HostEvent: true})
122 i := &Interactive{agent: ag, queued: []core.QueuedMessage{{Text: "host event", HostEvent: true}}}
123 i.mu.Lock()
124 i.discardQueuedMessagesLocked(false)
125 i.mu.Unlock()
126 if ag.QueuedMessageCount() != 0 || len(i.queued) != 0 {
127 t.Fatal("explicit cancellation retained queued events")
128 }
129}