1package modes
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 agenttools "github.com/bnema/zut/packages/agent/tools"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16func goalToolRegistry() core.Registry {
17 return core.Registry{agenttools.UpdateGoalToolName: &agenttools.UpdateGoalTool{}}
18}
19
20type goalContinuationClient struct {
21 mu sync.Mutex
22 requests []provider.Request
23 onCall func(call int)
24}
25
26func (c *goalContinuationClient) Name() string { return "goal-continuation" }
27
28func (c *goalContinuationClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
29 c.mu.Lock()
30 c.requests = append(c.requests, req)
31 call := len(c.requests)
32 onCall := c.onCall
33 c.mu.Unlock()
34 if onCall != nil {
35 onCall(call)
36 }
37 out := make(chan provider.Event, 1)
38 out <- provider.EventDone{
39 Stop: provider.StopEnd,
40 Message: provider.Message{
41 Role: provider.RoleAssistant,
42 Content: []provider.Content{provider.TextBlock{Text: "working"}},
43 },
44 }
45 close(out)
46 return out, nil
47}
48
49func TestActiveGoalStartsAnotherTurnWhenThreadBecomesIdle(t *testing.T) {
50 var mu sync.Mutex
51 var goal *core.SessionGoal
52 currentGoal := func() *core.SessionGoal {
53 mu.Lock()
54 defer mu.Unlock()
55 return cloneSessionGoal(goal)
56 }
57 persistGoal := func(next *core.SessionGoal) error {
58 mu.Lock()
59 goal = cloneSessionGoal(next)
60 mu.Unlock()
61 return nil
62 }
63 client := &goalContinuationClient{onCall: func(call int) {
64 if call == 2 {
65 _ = persistGoal(&core.SessionGoal{Objective: "finish issue 97", Status: core.GoalPaused})
66 }
67 }}
68 turnFlushed := make(chan struct{}, 2)
69 interactive := NewInteractive(InteractiveConfig{
70 Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
71 CurrentGoal: currentGoal,
72 PersistGoal: persistGoal,
73 FlushSession: func() {
74 turnFlushed <- struct{}{}
75 },
76 })
77 interactive.runCtx = context.Background()
78 interactive.runSlash(context.Background(), "/goal finish issue 97")
79 for turn := 1; turn <= 2; turn++ {
80 select {
81 case <-turnFlushed:
82 case <-time.After(2 * time.Second):
83 t.Fatalf("turn %d did not finish", turn)
84 }
85 }
86
87 client.mu.Lock()
88 defer client.mu.Unlock()
89 if len(client.requests) != 2 {
90 t.Fatalf("provider requests = %d, want 2", len(client.requests))
91 }
92 messages := client.requests[1].Messages
93 if len(messages) == 0 || messages[len(messages)-1].Meta[goalContinueMetaKey] != "true" {
94 t.Fatalf("continuation request tail = %#v", messages)
95 }
96}
97
98func TestReplacementGoalContinuesAfterStaleTurn(t *testing.T) {
99 var mu sync.Mutex
100 goal := &core.SessionGoal{Objective: "replacement objective", Status: core.GoalActive}
101 currentGoal := func() *core.SessionGoal {
102 mu.Lock()
103 defer mu.Unlock()
104 return cloneSessionGoal(goal)
105 }
106 persistGoal := func(next *core.SessionGoal) error {
107 mu.Lock()
108 goal = cloneSessionGoal(next)
109 mu.Unlock()
110 return nil
111 }
112 client := &goalContinuationClient{onCall: func(call int) {
113 if call == 1 {
114 _ = persistGoal(&core.SessionGoal{Objective: "replacement objective", Status: core.GoalPaused})
115 }
116 }}
117 turnFlushed := make(chan struct{}, 1)
118 interactive := NewInteractive(InteractiveConfig{
119 Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
120 CurrentGoal: currentGoal,
121 PersistGoal: persistGoal,
122 FlushSession: func() {
123 turnFlushed <- struct{}{}
124 },
125 })
126 interactive.runCtx = context.Background()
127 interactive.mu.Lock()
128 interactive.busy = true
129 interactive.mu.Unlock()
130 stale := provider.Message{
131 Role: provider.RoleUser,
132 Content: []provider.Content{provider.TextBlock{Text: "original objective"}},
133 Meta: map[string]string{goalContinueMetaKey: "true"},
134 }
135
136 interactive.startGoalContinuation(context.Background(), stale)
137 select {
138 case <-turnFlushed:
139 case <-time.After(2 * time.Second):
140 t.Fatal("replacement goal did not start")
141 }
142
143 client.mu.Lock()
144 defer client.mu.Unlock()
145 if len(client.requests) != 1 {
146 t.Fatalf("provider requests = %d, want 1", len(client.requests))
147 }
148 tail := client.requests[0].Messages[len(client.requests[0].Messages)-1]
149 if got := userMessageText(tail); !strings.Contains(got, "replacement objective") || strings.Contains(got, "original objective") {
150 t.Fatalf("continuation message = %q", got)
151 }
152}
153
154func TestToolResultPersistenceRunsOutsideTUILock(t *testing.T) {
155 var transitionMu sync.RWMutex
156 transitionCompleted := false
157 callbackStarted := make(chan struct{})
158 allowPersistence := make(chan struct{})
159 transitionHeld := make(chan struct{})
160 transitionDone := make(chan struct{})
161 handled := make(chan struct{})
162 agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
163 agent.CommitToolResult = func(_ string, _ core.ToolResult) error {
164 close(callbackStarted)
165 <-allowPersistence
166 transitionMu.RLock()
167 completed := transitionCompleted
168 transitionMu.RUnlock()
169 if !completed {
170 return errors.New("session transition did not complete")
171 }
172 return nil
173 }
174 interactive := NewInteractive(InteractiveConfig{Agent: agent})
175 commitErr := make(chan error, 1)
176
177 go func() {
178 commitErr <- agent.CommitToolResult("tool", core.ToolResult{})
179 interactive.handleEvent(core.EvToolResult{ID: "tool", Result: core.ToolResult{}})
180 close(handled)
181 }()
182 select {
183 case <-callbackStarted:
184 case <-time.After(2 * time.Second):
185 t.Fatal("tool-result callback did not start")
186 }
187 go func() {
188 transitionMu.Lock()
189 close(transitionHeld)
190 interactive.ApplySessionAgent(core.NewAgent(nil, "model", "system", goalToolRegistry()), "provider", "model")
191 transitionCompleted = true
192 transitionMu.Unlock()
193 close(transitionDone)
194 }()
195 select {
196 case <-transitionHeld:
197 case <-time.After(2 * time.Second):
198 t.Fatal("session transition did not acquire its lock")
199 }
200 select {
201 case <-transitionDone:
202 case <-time.After(2 * time.Second):
203 close(allowPersistence)
204 t.Fatal("session transition deadlocked with tool-result persistence")
205 }
206 close(allowPersistence)
207 select {
208 case <-handled:
209 case <-time.After(2 * time.Second):
210 t.Fatal("tool-result callback did not finish")
211 }
212 if err := <-commitErr; err != nil {
213 t.Fatalf("commit tool result: %v", err)
214 }
215}
216
217func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) {
218 goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
219 interactive := NewInteractive(InteractiveConfig{
220 Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
221 CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
222 })
223 // Core converts a goal update to an error result when durable persistence
224 // fails, so the presentation layer must leave the active badge unchanged.
225 result := core.ToolResult{
226 Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}},
227 IsError: true,
228 }
229
230 interactive.handleEvent(core.EvToolResult{ID: "goal", Result: result})
231 interactive.mu.Lock()
232 defer interactive.mu.Unlock()
233 if interactive.goalStatus != core.GoalActive {
234 t.Fatalf("goal badge = %q, want durable active status", interactive.goalStatus)
235 }
236}
237
238func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) {
239 var goal *core.SessionGoal
240 interactive := NewInteractive(InteractiveConfig{
241 Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
242 CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
243 PersistGoal: func(next *core.SessionGoal) error {
244 goal = cloneSessionGoal(next)
245 return nil
246 },
247 })
248 interactive.busy = true // exercise controls without launching a provider turn
249
250 interactive.runSlash(context.Background(), "/goal finish issue 97")
251 if goal == nil || goal.Status != core.GoalActive || goal.Objective != "finish issue 97" {
252 t.Fatalf("started goal = %#v", goal)
253 }
254 if interactive.goalStatus != core.GoalActive {
255 t.Fatalf("started badge = %q", interactive.goalStatus)
256 }
257
258 interactive.runSlash(context.Background(), "/goal pause")
259 if goal.Status != core.GoalPaused {
260 t.Fatalf("paused goal = %#v", goal)
261 }
262 if interactive.goalStatus != core.GoalPaused {
263 t.Fatalf("paused badge = %q", interactive.goalStatus)
264 }
265 interactive.runSlash(context.Background(), "/goal resume")
266 if goal.Status != core.GoalActive {
267 t.Fatalf("resumed goal = %#v", goal)
268 }
269 if interactive.goalStatus != core.GoalActive {
270 t.Fatalf("resumed badge = %q", interactive.goalStatus)
271 }
272 interactive.runSlash(context.Background(), "/goal clear")
273 if goal != nil {
274 t.Fatalf("cleared goal = %#v", goal)
275 }
276 if interactive.goalStatus != "" {
277 t.Fatalf("cleared badge = %q", interactive.goalStatus)
278 }
279 interactive.runSlash(context.Background(), "/goal pause deployment safely")
280 if goal == nil || goal.Status != core.GoalActive || goal.Objective != "pause deployment safely" {
281 t.Fatalf("multiword control-like objective = %#v", goal)
282 }
283 goal.Status = core.GoalBlocked
284 goal.Reason = "waiting for input"
285 interactive.runSlash(context.Background(), "/goal")
286 if !strings.Contains(interactive.statusOK, "waiting for input") {
287 t.Fatalf("goal status = %q", interactive.statusOK)
288 }
289}
290
291func TestGoalControlRequiresSessionPersistence(t *testing.T) {
292 interactive := NewInteractive(InteractiveConfig{})
293 interactive.runSlash(context.Background(), "/goal pause")
294 if !strings.Contains(interactive.statusErr, "session persistence is unavailable") {
295 t.Fatalf("status error = %q", interactive.statusErr)
296 }
297}
298
299func TestGoalCommandDoesNotMutateLiveStateWhenPersistenceFails(t *testing.T) {
300 goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
301 interactive := NewInteractive(InteractiveConfig{
302 Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
303 CurrentGoal: func() *core.SessionGoal { return goal },
304 PersistGoal: func(*core.SessionGoal) error { return errors.New("disk full") },
305 })
306 interactive.busy = true
307
308 interactive.runSlash(context.Background(), "/goal pause")
309 if goal.Status != core.GoalActive {
310 t.Fatalf("live goal status = %q, want active", goal.Status)
311 }
312 if interactive.goalStatus != core.GoalActive {
313 t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
314 }
315}
316
317func TestGoalSlashCommandRequiresUpdateGoalTool(t *testing.T) {
318 var persisted bool
319 interactive := NewInteractive(InteractiveConfig{
320 Agent: core.NewAgent(nil, "model", "system", nil),
321 CurrentGoal: func() *core.SessionGoal { return nil },
322 PersistGoal: func(*core.SessionGoal) error {
323 persisted = true
324 return nil
325 },
326 })
327
328 interactive.runSlash(context.Background(), "/goal finish issue 97")
329 if persisted {
330 t.Fatal("goal was persisted without the update_goal tool")
331 }
332 if !strings.Contains(interactive.statusErr, "update_goal is unavailable") {
333 t.Fatalf("status error = %q", interactive.statusErr)
334 }
335}
336
337func TestGoalContinuationRequiresActiveIdleGoalAndYieldsToQueuedUser(t *testing.T) {
338 goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
339 agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
340 interactive := &Interactive{
341 agent: agent,
342 cfg: InteractiveConfig{CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }},
343 }
344
345 message, ok := interactive.goalContinuationIfIdle()
346 if !ok {
347 t.Fatal("active idle goal did not request continuation")
348 }
349 if message.Meta[goalContinueMetaKey] != "true" || message.Role != provider.RoleUser {
350 t.Fatalf("continuation message = %#v", message)
351 }
352 if !isHiddenTranscriptMessage(message) {
353 t.Fatal("goal continuation was rendered as a user message")
354 }
355
356 agent.QueueMessage("user follow-up")
357 if _, ok := interactive.goalContinuationIfIdle(); ok {
358 t.Fatal("goal continuation bypassed queued user work")
359 }
360 agent.DrainQueuedMessages()
361 goal.Status = core.GoalPaused
362 if _, ok := interactive.goalContinuationIfIdle(); ok {
363 t.Fatal("paused goal requested continuation")
364 }
365}
366
367func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal {
368 if goal == nil {
369 return nil
370 }
371 clone := *goal
372 return &clone
373}