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() {
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() {
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 TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
135 for _, test := range []struct {
136 name string
137 budget *uint64
138 wantStatus core.GoalStatus
139 wantNext bool
140 }{
141 {name: "unlimited", wantStatus: core.GoalActive, wantNext: true},
142 {name: "limited", budget: uint64ptr(100), wantStatus: core.GoalBudgetLimited},
143 } {
144 t.Run(test.name, func(t *testing.T) {
145 var mu sync.Mutex
146 goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: test.budget}
147 current := func() *core.SessionGoal {
148 mu.Lock()
149 defer mu.Unlock()
150 return cloneSessionGoal(goal)
151 }
152 persist := func(next *core.SessionGoal) error {
153 mu.Lock()
154 goal = cloneSessionGoal(next)
155 mu.Unlock()
156 return nil
157 }
158 interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
159 run, err := interactive.startGoalRun(current())
160 if err != nil || run == nil {
161 t.Fatalf("start goal run = (%v, %v)", run, err)
162 }
163 interactive.observeGoalRun(core.EvToolCall{Name: "bash"})
164 interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}})
165 if got := interactive.finishGoalRun(); got != test.wantNext {
166 t.Fatalf("finish goal run = %t, want %t", got, test.wantNext)
167 }
168 got := current()
169 if got.Status != test.wantStatus || got.TokensUsed != 100 {
170 t.Fatalf("goal = %#v", got)
171 }
172 })
173 }
174}
175
176func TestGoalRuntimeLimitsPersistedExhaustedBudgetBeforeRun(t *testing.T) {
177 budget := uint64(100)
178 var mu sync.Mutex
179 goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: &budget, TokensUsed: budget}
180 current := func() *core.SessionGoal {
181 mu.Lock()
182 defer mu.Unlock()
183 return cloneSessionGoal(goal)
184 }
185 persist := func(next *core.SessionGoal) error {
186 mu.Lock()
187 goal = cloneSessionGoal(next)
188 mu.Unlock()
189 return nil
190 }
191 interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist})
192 if !interactive.limitGoalBeforeRun(current()) {
193 t.Fatal("exhausted budget was not limited before starting a new provider turn")
194 }
195 if got := current(); got.Status != core.GoalBudgetLimited || got.ContinuationID != "" {
196 t.Fatalf("limited goal = %#v", got)
197 }
198}
199
200func TestGoalRuntimeRejectsStaleRun(t *testing.T) {
201 var mu sync.Mutex
202 goal := &core.SessionGoal{ID: "goal-1", Objective: "first goal", Status: core.GoalActive}
203 current := func() *core.SessionGoal {
204 mu.Lock()
205 defer mu.Unlock()
206 return cloneSessionGoal(goal)
207 }
208 persist := func(next *core.SessionGoal) error {
209 mu.Lock()
210 goal = cloneSessionGoal(next)
211 mu.Unlock()
212 return nil
213 }
214 interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
215 run, err := interactive.startGoalRun(current())
216 if err != nil || run == nil {
217 t.Fatalf("start goal run = (%v, %v)", run, err)
218 }
219 if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil {
220 t.Fatal(err)
221 }
222 if interactive.finishGoalRun() {
223 t.Fatal("stale run scheduled a continuation")
224 }
225 if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 {
226 t.Fatalf("stale run changed current goal: %#v", got)
227 }
228}
229
230func TestGoalRuntimeRecoversAbandonedLease(t *testing.T) {
231 var mu sync.Mutex
232 goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, ContinuationID: "abandoned"}
233 current := func() *core.SessionGoal {
234 mu.Lock()
235 defer mu.Unlock()
236 return cloneSessionGoal(goal)
237 }
238 persist := func(next *core.SessionGoal) error {
239 mu.Lock()
240 goal = cloneSessionGoal(next)
241 mu.Unlock()
242 return nil
243 }
244 _ = NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoalRuntime: persist})
245 if got := current(); got.ContinuationID != "" {
246 t.Fatalf("abandoned lease was not cleared: %#v", got)
247 }
248}
249
250func uint64ptr(value uint64) *uint64 { return &value }