1package modes
2
3import (
4 "context"
5 "sync"
6 "testing"
7 "time"
8
9 "github.com/bnema/zut/packages/agent/tools"
10 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"
12)
13
14type noProgressGoalClient struct {
15 mu sync.Mutex
16 calls int
17 hits chan int
18}
19
20func (c *noProgressGoalClient) Name() string { return "goal-no-progress-test" }
21
22func (c *noProgressGoalClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
23 c.mu.Lock()
24 c.calls++
25 call := c.calls
26 c.mu.Unlock()
27 c.hits <- call
28 out := make(chan provider.Event, 1)
29 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
30 Role: provider.RoleAssistant,
31 Content: []provider.Content{provider.TextBlock{Text: "Continuing autonomously."}},
32 }}
33 close(out)
34 return out, nil
35}
36
37func (c *noProgressGoalClient) count() int {
38 c.mu.Lock()
39 defer c.mu.Unlock()
40 return c.calls
41}
42
43func TestGoalContinuationDoesNotResendNoProgressMessagesIndefinitely(t *testing.T) {
44 var mu sync.Mutex
45 var goal *core.SessionGoal
46 current := func() *core.SessionGoal {
47 mu.Lock()
48 defer mu.Unlock()
49 return cloneSessionGoal(goal)
50 }
51 persist := func(next *core.SessionGoal) error {
52 mu.Lock()
53 goal = cloneSessionGoal(next)
54 mu.Unlock()
55 return nil
56 }
57 client := &noProgressGoalClient{hits: make(chan int, 3)}
58 agent := core.NewAgent(client, "test", "", core.Registry{tools.UpdateGoalToolName: &tools.UpdateGoalTool{}})
59 interactive := NewInteractive(InteractiveConfig{
60 Agent: agent,
61 CurrentGoal: current,
62 PersistGoal: persist,
63 PersistGoalRuntime: persist,
64 })
65 interactive.runCtx = context.Background()
66 interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"})
67
68 for want := 1; want <= maxConsecutiveGoalNoProgressTurns; want++ {
69 select {
70 case got := <-client.hits:
71 if got != want {
72 t.Fatalf("provider call = %d, want %d", got, want)
73 }
74 case <-time.After(2 * time.Second):
75 t.Fatalf("provider call %d did not start", want)
76 }
77 }
78 waitInteractiveIdle(t, interactive)
79 if got := current(); got == nil || got.Status != core.GoalStalled {
80 t.Fatalf("goal after repeated no-progress responses = %#v", got)
81 }
82 if got := client.count(); got != maxConsecutiveGoalNoProgressTurns {
83 t.Fatalf("provider call count = %d, want %d", got, maxConsecutiveGoalNoProgressTurns)
84 }
85}
86
87func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
88 var mu sync.Mutex
89 goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive}
90 current := func() *core.SessionGoal {
91 mu.Lock()
92 defer mu.Unlock()
93 return cloneSessionGoal(goal)
94 }
95 persistRuntime := func(next *core.SessionGoal) error {
96 mu.Lock()
97 goal = cloneSessionGoal(next)
98 mu.Unlock()
99 return nil
100 }
101 interactive := NewInteractive(InteractiveConfig{
102 CurrentGoal: current,
103 PersistGoal: persistRuntime,
104 PersistGoalRuntime: persistRuntime,
105 })
106
107 first, err := interactive.startGoalRun(current())
108 if err != nil || first == nil {
109 t.Fatalf("start first goal run = (%v, %v), want run without error", first, err)
110 }
111 if !interactive.finishGoalRun(false) {
112 t.Fatal("first non-progress run should receive one corrective continuation")
113 }
114 if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" {
115 t.Fatalf("first run state = %#v", got)
116 }
117
118 second, err := interactive.startGoalRun(current())
119 if err != nil || second == nil {
120 t.Fatalf("start second goal run = (%v, %v), want run without error", second, err)
121 }
122 if interactive.finishGoalRun(false) {
123 t.Fatal("second non-progress run scheduled another continuation")
124 }
125 got := current()
126 if got.Status != core.GoalStalled || got.ConsecutiveNoProgressTurns != maxConsecutiveGoalNoProgressTurns {
127 t.Fatalf("stalled goal = %#v", got)
128 }
129 if got.Reason == "" || got.ContinuationID != "" {
130 t.Fatalf("stalled goal diagnostics = %#v", got)
131 }
132}
133
134func TestGoalRuntimeContextLimitDoesNotConsumeNoProgressAllowance(t *testing.T) {
135 var mu sync.Mutex
136 goal := &core.SessionGoal{
137 ID: "goal-1",
138 Objective: "finish the work",
139 Status: core.GoalActive,
140 ConsecutiveNoProgressTurns: 1,
141 }
142 current := func() *core.SessionGoal {
143 mu.Lock()
144 defer mu.Unlock()
145 return cloneSessionGoal(goal)
146 }
147 persist := func(next *core.SessionGoal) error {
148 mu.Lock()
149 goal = cloneSessionGoal(next)
150 mu.Unlock()
151 return nil
152 }
153 interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
154 if run, err := interactive.startGoalRun(current()); err != nil || run == nil {
155 t.Fatalf("start goal run = (%v, %v)", run, err)
156 }
157 if !interactive.finishGoalRun(true) {
158 t.Fatal("context-limited run did not request a fresh continuation")
159 }
160 if got := current(); got.Status != core.GoalActive || got.ConsecutiveNoProgressTurns != 1 {
161 t.Fatalf("context-limited goal = %#v", got)
162 }
163}
164
165func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
166 for _, test := range []struct {
167 name string
168 budget *uint64
169 wantStatus core.GoalStatus
170 wantNext bool
171 }{
172 {name: "unlimited", wantStatus: core.GoalActive, wantNext: true},
173 {name: "limited", budget: uint64ptr(100), wantStatus: core.GoalBudgetLimited},
174 } {
175 t.Run(test.name, func(t *testing.T) {
176 var mu sync.Mutex
177 goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: test.budget}
178 current := func() *core.SessionGoal {
179 mu.Lock()
180 defer mu.Unlock()
181 return cloneSessionGoal(goal)
182 }
183 persist := func(next *core.SessionGoal) error {
184 mu.Lock()
185 goal = cloneSessionGoal(next)
186 mu.Unlock()
187 return nil
188 }
189 interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
190 run, err := interactive.startGoalRun(current())
191 if err != nil || run == nil {
192 t.Fatalf("start goal run = (%v, %v)", run, err)
193 }
194 interactive.observeGoalRun(core.EvToolCall{Name: "bash"})
195 interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}})
196 if got := interactive.finishGoalRun(false); got != test.wantNext {
197 t.Fatalf("finish goal run = %t, want %t", got, test.wantNext)
198 }
199 got := current()
200 if got.Status != test.wantStatus || got.TokensUsed != 100 {
201 t.Fatalf("goal = %#v", got)
202 }
203 })
204 }
205}
206
207func TestGoalRuntimeLimitsPersistedExhaustedBudgetBeforeRun(t *testing.T) {
208 budget := uint64(100)
209 var mu sync.Mutex
210 goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: &budget, TokensUsed: budget}
211 current := func() *core.SessionGoal {
212 mu.Lock()
213 defer mu.Unlock()
214 return cloneSessionGoal(goal)
215 }
216 persist := func(next *core.SessionGoal) error {
217 mu.Lock()
218 goal = cloneSessionGoal(next)
219 mu.Unlock()
220 return nil
221 }
222 interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist})
223 if !interactive.limitGoalBeforeRun(current()) {
224 t.Fatal("exhausted budget was not limited before starting a new provider turn")
225 }
226 if got := current(); got.Status != core.GoalBudgetLimited || got.ContinuationID != "" {
227 t.Fatalf("limited goal = %#v", got)
228 }
229}
230
231func TestGoalRuntimeRejectsStaleRun(t *testing.T) {
232 var mu sync.Mutex
233 goal := &core.SessionGoal{ID: "goal-1", Objective: "first goal", Status: core.GoalActive}
234 current := func() *core.SessionGoal {
235 mu.Lock()
236 defer mu.Unlock()
237 return cloneSessionGoal(goal)
238 }
239 persist := func(next *core.SessionGoal) error {
240 mu.Lock()
241 goal = cloneSessionGoal(next)
242 mu.Unlock()
243 return nil
244 }
245 interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
246 run, err := interactive.startGoalRun(current())
247 if err != nil || run == nil {
248 t.Fatalf("start goal run = (%v, %v)", run, err)
249 }
250 if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil {
251 t.Fatal(err)
252 }
253 if interactive.finishGoalRun(false) {
254 t.Fatal("stale run scheduled a continuation")
255 }
256 if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 {
257 t.Fatalf("stale run changed current goal: %#v", got)
258 }
259}
260
261func TestGoalRuntimeRecoversAbandonedLease(t *testing.T) {
262 var mu sync.Mutex
263 goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, ContinuationID: "abandoned"}
264 current := func() *core.SessionGoal {
265 mu.Lock()
266 defer mu.Unlock()
267 return cloneSessionGoal(goal)
268 }
269 persist := func(next *core.SessionGoal) error {
270 mu.Lock()
271 goal = cloneSessionGoal(next)
272 mu.Unlock()
273 return nil
274 }
275 _ = NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoalRuntime: persist})
276 if got := current(); got.ContinuationID != "" {
277 t.Fatalf("abandoned lease was not cleared: %#v", got)
278 }
279}
280
281func uint64ptr(value uint64) *uint64 { return &value }