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 agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})
173 interactive := NewInteractive(InteractiveConfig{Agent: agent})
174 ctx, cancel := context.WithCancel(context.Background())
175 defer cancel()
176
177 interactive.startTurn(ctx, "start")
178 followUp := make(chan error, 1)
179 go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }()
180
181 select {
182 case err := <-followUp:
183 if !errors.Is(err, core.ErrRepetitiveLoop) {
184 t.Fatalf("scheduled follow-up error = %v, want ErrRepetitiveLoop", err)
185 }
186 case <-time.After(3 * time.Second):
187 t.Fatal("scheduled follow-up remained blocked after repetitive loop stop")
188 }
189 if got := client.calls.Load(); got != 8 {
190 t.Fatalf("provider calls = %d, want 8", got)
191 }
192}