1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16type repetitiveInteractiveRequest struct {
17 call int32
18 hasQueuedPrompt bool
19 userText []string
20}
21
22type repetitiveInteractiveClient struct {
23 calls atomic.Int32
24 entered chan struct{}
25 resume chan struct{}
26 requests chan repetitiveInteractiveRequest
27}
28
29func (*repetitiveInteractiveClient) Name() string { return "repetitive-interactive-test" }
30
31func (c *repetitiveInteractiveClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
32 call := c.calls.Add(1)
33 hasQueuedPrompt := false
34 var userText []string
35 for _, message := range req.Messages {
36 if message.Role != provider.RoleUser {
37 continue
38 }
39 for _, content := range message.Content {
40 if text, ok := content.(provider.TextBlock); ok {
41 userText = append(userText, text.Text)
42 if strings.Contains(text.Text, "new request") {
43 hasQueuedPrompt = true
44 }
45 }
46 }
47 }
48 if c.requests != nil {
49 c.requests <- repetitiveInteractiveRequest{call: call, hasQueuedPrompt: hasQueuedPrompt, userText: userText}
50 }
51 if call == 8 && c.entered != nil {
52 close(c.entered)
53 <-c.resume
54 }
55 if call > 8 {
56 out := make(chan provider.Event, 1)
57 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
58 Role: provider.RoleAssistant,
59 Content: []provider.Content{provider.TextBlock{Text: "answered"}},
60 }}
61 close(out)
62 return out, nil
63 }
64 out := make(chan provider.Event, 4)
65 out <- provider.EventStart{Provider: c.Name(), Model: req.Model}
66 out <- provider.EventToolStart{ID: "call", Name: "repeat"}
67 out <- provider.EventToolEnd{ID: "call"}
68 out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
69 Role: provider.RoleAssistant,
70 Content: []provider.Content{provider.ToolCallBlock{
71 ID: "call", Name: "repeat", Arguments: json.RawMessage(`{}`),
72 }},
73 }}
74 close(out)
75 return out, nil
76}
77
78type repetitiveInteractiveTool struct{}
79
80func (*repetitiveInteractiveTool) Name() string { return "repeat" }
81func (*repetitiveInteractiveTool) Description() string { return "repeats" }
82func (*repetitiveInteractiveTool) Schema() json.RawMessage {
83 return json.RawMessage(`{"type":"object"}`)
84}
85func (*repetitiveInteractiveTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
86 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "same"}}}, nil
87}
88
89func TestInteractiveRepetitionGuardPreservesQueuedUserPrompt(t *testing.T) {
90 client := &repetitiveInteractiveClient{
91 entered: make(chan struct{}),
92 resume: make(chan struct{}),
93 requests: make(chan repetitiveInteractiveRequest, 9),
94 }
95 defer func() {
96 select {
97 case <-client.resume:
98 default:
99 close(client.resume)
100 }
101 }()
102 agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})
103 interactive := NewInteractive(InteractiveConfig{Agent: agent})
104
105 interactive.startTurn(context.Background(), "start")
106 select {
107 case <-client.entered:
108 case <-time.After(3 * time.Second):
109 t.Fatal("eighth provider request did not start")
110 }
111 interactive.submitOrQueue("new request", nil, true)
112 for call := 1; call <= 8; call++ {
113 select {
114 case request := <-client.requests:
115 if request.call != int32(call) || request.hasQueuedPrompt {
116 t.Fatalf("provider request before loop stop = %#v; want call %d without queued prompt", request, call)
117 }
118 if call == 8 && len(request.userText) == 0 {
119 t.Fatal("eighth provider request was not observed; queueing raced with loop teardown")
120 }
121 case <-time.After(3 * time.Second):
122 t.Fatalf("provider request %d was not observed", call)
123 }
124 }
125 close(client.resume)
126
127 select {
128 case request := <-client.requests:
129 if request.call != 9 || !request.hasQueuedPrompt {
130 t.Fatalf("provider request after loop stop = %#v, want call 9 with queued user prompt", request)
131 }
132 case <-time.After(3 * time.Second):
133 t.Fatal("queued user prompt was not sent after loop stop")
134 }
135 waitInteractiveIdle(t, interactive)
136 if got := client.calls.Load(); got != 9 {
137 t.Fatalf("provider calls = %d, want 9 including the user prompt", got)
138 }
139}
140
141func TestInteractiveRepetitionGuardCanResumeStalledGoal(t *testing.T) {
142 client := &repetitiveInteractiveClient{}
143 store := &interruptGoalStore{}
144 registry := goalToolRegistry()
145 registry["repeat"] = &repetitiveInteractiveTool{}
146 interactive := NewInteractive(InteractiveConfig{
147 Agent: core.NewAgent(client, "model", "system", registry),
148 CurrentGoal: store.current,
149 PersistGoal: store.persist,
150 PersistGoalRuntime: store.persist,
151 })
152 interactive.runCtx = context.Background()
153
154 interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"})
155 waitInteractiveIdle(t, interactive)
156 if got := store.current(); got == nil || got.Status != core.GoalStalled {
157 t.Fatalf("goal after repetitive loop = %#v, want stalled", got)
158 }
159
160 interactive.runGoalCommand(context.Background(), "/goal resume", []string{"/goal", "resume"})
161 waitInteractiveIdle(t, interactive)
162 if got := client.calls.Load(); got < 9 {
163 t.Fatalf("provider requests after resume = %d, want at least 9", got)
164 }
165 if got := store.current(); got == nil || (got.Status != core.GoalActive && got.Status != core.GoalStalled) {
166 t.Fatalf("goal after resume = %#v, want an answered goal run", got)
167 }
168}
169
170func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) {
171 client := &repetitiveInteractiveClient{
172 entered: make(chan struct{}),
173 resume: make(chan struct{}),
174 }
175 defer func() {
176 select {
177 case <-client.resume:
178 default:
179 close(client.resume)
180 }
181 }()
182 agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})
183 interactive := NewInteractive(InteractiveConfig{Agent: agent})
184 ctx, cancel := context.WithCancel(context.Background())
185 defer cancel()
186
187 interactive.startTurn(ctx, "start")
188 select {
189 case <-client.entered:
190 case <-time.After(3 * time.Second):
191 t.Fatal("eighth provider request did not start")
192 }
193 followUp := make(chan error, 1)
194 go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }()
195
196 // SubmitFollowUp blocks until teardown resolves it. Hold the eighth
197 // provider call until the scheduled prompt is actually registered, not
198 // merely until its goroutine has been launched.
199 deadline := time.NewTimer(3 * time.Second)
200 defer deadline.Stop()
201 poll := time.NewTicker(time.Millisecond)
202 defer poll.Stop()
203 for {
204 interactive.mu.Lock()
205 queued := len(interactive.scheduled) == 1
206 interactive.mu.Unlock()
207 if queued {
208 break
209 }
210 select {
211 case <-deadline.C:
212 t.Fatal("scheduled follow-up did not enter the queue")
213 case <-poll.C:
214 }
215 }
216 close(client.resume)
217
218 select {
219 case err := <-followUp:
220 if !errors.Is(err, core.ErrRepetitiveLoop) {
221 t.Fatalf("scheduled follow-up error = %v, want ErrRepetitiveLoop", err)
222 }
223 case <-time.After(3 * time.Second):
224 t.Fatal("scheduled follow-up remained blocked after repetitive loop stop")
225 }
226 waitInteractiveIdle(t, interactive)
227 if got := client.calls.Load(); got != 8 {
228 t.Fatalf("provider calls = %d, want 8", got)
229 }
230}
231
232// SubmitFollowUp on an idle interactive without a Run context must start a
233// turn instead of panicking in context.WithCancel(nil parent). This pins the
234// fallback deterministically; the racy busy-to-idle interleaving behind the
235// Windows CI panic needs no timing to cover the nil-parent path itself.
236func TestSubmitFollowUpWithoutRunContextStartsTurn(t *testing.T) {
237 client := &repetitiveInteractiveClient{requests: make(chan repetitiveInteractiveRequest, 16)}
238 agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})
239 interactive := NewInteractive(InteractiveConfig{Agent: agent})
240 if interactive.runCtx != nil {
241 t.Fatalf("runCtx = %#v, want nil without Run", interactive.runCtx)
242 }
243 if err := interactive.SubmitFollowUp(context.Background(), "scheduled task"); err != nil {
244 t.Fatalf("SubmitFollowUp = %v, want nil", err)
245 }
246 select {
247 case request := <-client.requests:
248 if request.call != 1 {
249 t.Fatalf("provider request = %#v, want first call", request)
250 }
251 case <-time.After(3 * time.Second):
252 t.Fatal("provider request was not observed")
253 }
254 waitInteractiveIdle(t, interactive)
255}