1package modes
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"
12 "github.com/bnema/zut/packages/tui"
13)
14
15// interruptTestClient records outbound requests and answers each turn with a
16// short assistant message so end-of-turn dispatch runs deterministically.
17// Tests pause the goal inside onGoalContinuation so the scheduler never
18// schedules a second continuation beyond the return under test. The first
19// request can optionally block until the test cancels it, exercising the
20// real Esc cancellation lifecycle.
21type interruptTestClient struct {
22 requests chan provider.Request
23
24 mu sync.Mutex
25 calls int
26 onGoalContinuation func()
27 blockFirst chan struct{}
28 firstStarted chan struct{}
29}
30
31func (c *interruptTestClient) Name() string { return "goal-interrupt-test" }
32
33func (c *interruptTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
34 c.mu.Lock()
35 c.calls++
36 call := c.calls
37 blockFirst := c.blockFirst
38 firstStarted := c.firstStarted
39 c.mu.Unlock()
40 c.requests <- req
41 if _, ok := tailMessageWithMeta(req, goalContinueMetaKey); ok {
42 c.mu.Lock()
43 onGoal := c.onGoalContinuation
44 c.mu.Unlock()
45 if onGoal != nil {
46 onGoal()
47 }
48 }
49 out := make(chan provider.Event, 1)
50 if call == 1 && blockFirst != nil {
51 if firstStarted != nil {
52 close(firstStarted)
53 }
54 go func() {
55 defer close(out)
56 select {
57 case <-ctx.Done():
58 out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
59 case <-blockFirst:
60 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
61 Role: provider.RoleAssistant,
62 Content: []provider.Content{provider.TextBlock{Text: "done"}},
63 }}
64 }
65 }()
66 return out, nil
67 }
68 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
69 Role: provider.RoleAssistant,
70 Content: []provider.Content{provider.TextBlock{Text: "done"}},
71 }}
72 close(out)
73 return out, nil
74}
75
76func (c *interruptTestClient) count() int {
77 c.mu.Lock()
78 defer c.mu.Unlock()
79 return c.calls
80}
81
82type interruptGoalStore struct {
83 mu sync.Mutex
84 goal *core.SessionGoal
85}
86
87func (s *interruptGoalStore) current() *core.SessionGoal {
88 s.mu.Lock()
89 defer s.mu.Unlock()
90 return cloneSessionGoal(s.goal)
91}
92
93func (s *interruptGoalStore) persist(next *core.SessionGoal) error {
94 s.mu.Lock()
95 s.goal = cloneSessionGoal(next)
96 s.mu.Unlock()
97 return nil
98}
99
100func newInterruptTestHarness(goal *core.SessionGoal) (*Interactive, *interruptTestClient, *interruptGoalStore) {
101 store := &interruptGoalStore{goal: cloneSessionGoal(goal)}
102 client := &interruptTestClient{requests: make(chan provider.Request, 8)}
103 agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
104 interactive := NewInteractive(InteractiveConfig{
105 Agent: agent,
106 CurrentGoal: store.current,
107 PersistGoal: store.persist,
108 PersistGoalRuntime: store.persist,
109 })
110 interactive.runCtx = context.Background()
111 return interactive, client, store
112}
113
114func lastMessageWithMeta(req provider.Request, key string) (provider.Message, bool) {
115 for idx := len(req.Messages) - 1; idx >= 0; idx-- {
116 if req.Messages[idx].Meta[key] == "true" {
117 return req.Messages[idx], true
118 }
119 }
120 return provider.Message{}, false
121}
122
123// tailMessageWithMeta reports whether the latest outbound message carries
124// the key. Unlike lastMessageWithMeta it ignores earlier history rows, so a
125// user turn following a goal continuation is not mistaken for one.
126func tailMessageWithMeta(req provider.Request, key string) (provider.Message, bool) {
127 if len(req.Messages) == 0 {
128 return provider.Message{}, false
129 }
130 last := req.Messages[len(req.Messages)-1]
131 if last.Meta[key] == "true" {
132 return last, true
133 }
134 return provider.Message{}, false
135}
136
137func TestEscapeKeepsActiveGoalActiveAndArmsReturn(t *testing.T) {
138 interactive, _, store := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive})
139 interactive.mu.Lock()
140 interactive.busy = true
141 interactive.cancelTurn = func() {}
142 interactive.mu.Unlock()
143
144 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
145 t.Fatal("Esc during a turn should not exit")
146 }
147 if got := store.current(); got == nil || got.Status != core.GoalActive {
148 t.Fatalf("goal after Esc = %#v, want active", got)
149 }
150 interactive.mu.Lock()
151 state := interactive.interruptedGoalReturn
152 interactive.mu.Unlock()
153 if state == nil || !state.nextPromptArmed || state.goalID != "goal-1" {
154 t.Fatalf("interrupted return state = %#v, want armed return for goal-1", state)
155 }
156}
157
158func TestEscapeWithoutActiveGoalArmsNothing(t *testing.T) {
159 interactive, _, _ := newInterruptTestHarness(nil)
160 interactive.mu.Lock()
161 interactive.busy = true
162 interactive.cancelTurn = func() {}
163 interactive.mu.Unlock()
164
165 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
166 t.Fatal("Esc during a turn should not exit")
167 }
168 interactive.mu.Lock()
169 state := interactive.interruptedGoalReturn
170 interactive.mu.Unlock()
171 if state != nil {
172 t.Fatalf("interrupted return state = %#v, want nil without an active goal", state)
173 }
174}
175
176func TestInterruptReassessmentMessageRequiresActiveGoal(t *testing.T) {
177 interactive, _, _ := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, MissionID: "mission-1"})
178 message, ok := interactive.goalInterruptReassessmentMessage()
179 if !ok {
180 t.Fatal("reassessment message unavailable for an active goal")
181 }
182 if message.Meta[goalInterruptReassessMetaKey] != "true" {
183 t.Fatalf("reassessment message meta = %#v, want interrupt key", message.Meta)
184 }
185 text := userMessageText(message)
186 for _, clause := range []string{
187 "Handle the latest user message first",
188 "continue the goal autonomously without waiting",
189 "Call update_goal only for a genuine complete, blocked, or superseded transition",
190 "finish the work",
191 "goal_id goal-1",
192 "mission_id mission-1",
193 } {
194 if !strings.Contains(text, clause) {
195 t.Fatalf("reassessment message missing %q: %q", clause, text)
196 }
197 }
198
199 paused, _, _ := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalPaused})
200 if _, ok := paused.goalInterruptReassessmentMessage(); ok {
201 t.Fatal("paused goal should not build a reassessment message")
202 }
203}
204
205func TestInterruptReassessmentMessageHiddenFromTranscript(t *testing.T) {
206 message := provider.Message{
207 Role: provider.RoleUser,
208 Content: []provider.Content{provider.TextBlock{Text: "interrupted; handle this first"}},
209 Meta: map[string]string{goalInterruptReassessMetaKey: "true"},
210 }
211 if !isHiddenTranscriptMessage(message) {
212 t.Fatal("interrupt reassessment message should be hidden")
213 }
214 visible := provider.Message{
215 Role: provider.RoleUser,
216 Content: []provider.Content{provider.TextBlock{Text: "ordinary user input"}},
217 }
218 if isHiddenTranscriptMessage(visible) {
219 t.Fatal("ordinary user input should remain visible")
220 }
221 filtered := filterHiddenTranscriptMessages([]provider.Message{message, visible})
222 if len(filtered) != 1 || userMessageText(filtered[0]) != "ordinary user input" {
223 t.Fatalf("filtered transcript = %#v, want only the ordinary message", filtered)
224 }
225}
226
227func TestInterruptedGoalResumesOnceAfterNextUserTurn(t *testing.T) {
228 interactive, client, store := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", MissionID: "mission-1", Objective: "finish the work", Status: core.GoalActive})
229 client.mu.Lock()
230 client.blockFirst = make(chan struct{})
231 client.firstStarted = make(chan struct{})
232 client.mu.Unlock()
233 // Start a real goal turn through the scheduler, then Esc-cancel it while
234 // the provider stream is blocked. The cancellation unwinds through the
235 // genuine end-of-turn path.
236 if !interactive.requestGoalContinuationIfIdle(context.Background()) {
237 t.Fatal("goal continuation did not start")
238 }
239 goalReq := receiveRequest(t, client.requests)
240 if _, ok := lastMessageWithMeta(goalReq, goalContinueMetaKey); !ok {
241 t.Fatalf("initial request tail = %#v, want goal continuation", goalReq.Messages)
242 }
243 select {
244 case <-client.firstStarted:
245 case <-time.After(2 * time.Second):
246 t.Fatal("goal turn did not reach the provider")
247 }
248 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
249 t.Fatal("Esc during a turn should not exit")
250 }
251 waitInteractiveIdle(t, interactive)
252 if got := store.current(); got == nil || got.Status != core.GoalActive {
253 t.Fatalf("goal after Esc = %#v, want active", got)
254 }
255 // Esc must yield to user input: no autonomous request may start before
256 // the next ordinary prompt.
257 select {
258 case extra := <-client.requests:
259 t.Fatalf("goal restarted before the next user prompt: %#v", extra.Messages)
260 case <-time.After(200 * time.Millisecond):
261 }
262 client.mu.Lock()
263 client.onGoalContinuation = func() {
264 // Settle the goal after the single return under test so the
265 // scheduler does not schedule further continuations.
266 _ = store.persist(&core.SessionGoal{ID: "goal-1", MissionID: "mission-1", Objective: "finish the work", Status: core.GoalPaused})
267 }
268 client.mu.Unlock()
269
270 interactive.startTurnRequest(context.Background(), "quick question", nil, false, false)
271
272 userReq := receiveRequest(t, client.requests)
273 hidden, ok := lastMessageWithMeta(userReq, goalInterruptReassessMetaKey)
274 if !ok {
275 t.Fatalf("user request has no hidden reassessment message: %#v", userReq.Messages)
276 }
277 if !strings.Contains(userMessageText(hidden), "Handle the latest user message first") {
278 t.Fatalf("hidden reassessment text = %q", userMessageText(hidden))
279 }
280 if got := userMessageText(userReq.Messages[len(userReq.Messages)-1]); !strings.HasSuffix(got, "quick question") {
281 t.Fatalf("user prompt tail = %q, want the latest user message first", got)
282 }
283
284 returnReq := receiveRequest(t, client.requests)
285 if _, ok := lastMessageWithMeta(returnReq, goalContinueMetaKey); !ok {
286 t.Fatalf("expected one automatic goal continuation, got %#v", returnReq.Messages)
287 }
288 waitInteractiveIdle(t, interactive)
289
290 select {
291 case extra := <-client.requests:
292 t.Fatalf("unexpected fourth provider request: %#v", extra.Messages)
293 case <-time.After(200 * time.Millisecond):
294 }
295 if got := client.count(); got != 3 {
296 t.Fatalf("provider call count = %d, want cancelled goal turn plus user turn plus one goal return", got)
297 }
298 if got := store.current(); got == nil || got.Status != core.GoalPaused {
299 t.Fatalf("goal after resumed turn = %#v, want paused test settlement", got)
300 }
301}
302
303func TestInterruptedGoalStaysPausedAfterExplicitPause(t *testing.T) {
304 interactive, client, store := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive})
305 interactive.mu.Lock()
306 interactive.busy = true
307 interactive.cancelTurn = func() {}
308 interactive.mu.Unlock()
309 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
310 t.Fatal("Esc during a turn should not exit")
311 }
312
313 interactive.runGoalCommand(context.Background(), "/goal pause", []string{"/goal", "pause"})
314 if got := store.current(); got == nil || got.Status != core.GoalPaused {
315 t.Fatalf("goal after /goal pause = %#v, want paused", got)
316 }
317
318 interactive.startTurnRequest(context.Background(), "follow-up", nil, false, false)
319 _ = receiveRequest(t, client.requests)
320 waitInteractiveIdle(t, interactive)
321 select {
322 case extra := <-client.requests:
323 t.Fatalf("paused goal resumed unexpectedly: %#v", extra.Messages)
324 case <-time.After(200 * time.Millisecond):
325 }
326}
327
328func TestInterruptedGoalClearedOrReplacedNeverResumes(t *testing.T) {
329 t.Run("cleared", func(t *testing.T) {
330 interactive, client, store := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive})
331 interactive.mu.Lock()
332 interactive.busy = true
333 interactive.cancelTurn = func() {}
334 interactive.mu.Unlock()
335 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
336 t.Fatal("Esc during a turn should not exit")
337 }
338 interactive.runGoalCommand(context.Background(), "/goal clear", []string{"/goal", "clear"})
339 if got := store.current(); got != nil {
340 t.Fatalf("goal after clear = %#v, want nil", got)
341 }
342 interactive.startTurnRequest(context.Background(), "follow-up", nil, false, false)
343 _ = receiveRequest(t, client.requests)
344 waitInteractiveIdle(t, interactive)
345 select {
346 case extra := <-client.requests:
347 t.Fatalf("cleared goal resumed unexpectedly: %#v", extra.Messages)
348 case <-time.After(200 * time.Millisecond):
349 }
350 })
351
352 t.Run("replaced", func(t *testing.T) {
353 interactive, client, store := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive})
354 client.onGoalContinuation = func() {
355 _ = store.persist(&core.SessionGoal{ID: "goal-2", Objective: "do something else", Status: core.GoalPaused})
356 }
357 interactive.mu.Lock()
358 interactive.busy = true
359 interactive.cancelTurn = func() {}
360 interactive.mu.Unlock()
361 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
362 t.Fatal("Esc during a turn should not exit")
363 }
364 // Replace the goal with a distinct persisted identity so the stale
365 // Esc return cannot match it.
366 if err := store.persist(&core.SessionGoal{ID: "goal-2", Objective: "do something else", Status: core.GoalActive}); err != nil {
367 t.Fatal(err)
368 }
369 interactive.mu.Lock()
370 interactive.busy = false
371 interactive.mu.Unlock()
372 interactive.startTurnRequest(context.Background(), "follow-up", nil, false, false)
373 userReq := receiveRequest(t, client.requests)
374 if _, ok := lastMessageWithMeta(userReq, goalInterruptReassessMetaKey); ok {
375 t.Fatalf("stale interruption leaked into replaced goal: %#v", userReq.Messages)
376 }
377 waitInteractiveIdle(t, interactive)
378 // Drain the replacement goal's own continuation so the test ends idle
379 // without asserting on its scheduling here.
380 select {
381 case <-client.requests:
382 waitInteractiveIdle(t, interactive)
383 case <-time.After(200 * time.Millisecond):
384 }
385 })
386}
387
388func TestEscapePreservesNoProgressAllowance(t *testing.T) {
389 // An Esc-cancelled corrective continuation must not consume the
390 // no-progress allowance or stall the still-active goal.
391 interactive, client, store := newInterruptTestHarness(&core.SessionGoal{
392 ID: "goal-1",
393 Objective: "finish the work",
394 Status: core.GoalActive,
395 ConsecutiveNoProgressTurns: maxConsecutiveGoalNoProgressTurns - 1,
396 })
397 client.mu.Lock()
398 client.blockFirst = make(chan struct{})
399 client.firstStarted = make(chan struct{})
400 client.mu.Unlock()
401 if !interactive.requestGoalContinuationIfIdle(context.Background()) {
402 t.Fatal("goal continuation did not start")
403 }
404 _ = receiveRequest(t, client.requests)
405 select {
406 case <-client.firstStarted:
407 case <-time.After(2 * time.Second):
408 t.Fatal("goal turn did not reach the provider")
409 }
410 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
411 t.Fatal("Esc during a turn should not exit")
412 }
413 waitInteractiveIdle(t, interactive)
414 got := store.current()
415 if got == nil || got.Status != core.GoalActive {
416 t.Fatalf("goal after Esc = %#v, want active", got)
417 }
418 if got.ConsecutiveNoProgressTurns != maxConsecutiveGoalNoProgressTurns-1 {
419 t.Fatalf("no-progress turns = %d, want %d", got.ConsecutiveNoProgressTurns, maxConsecutiveGoalNoProgressTurns-1)
420 }
421}
422
423func TestExplicitResumeAfterEscapeStartsGoalImmediately(t *testing.T) {
424 interactive, client, store := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive})
425 interactive.mu.Lock()
426 interactive.busy = true
427 interactive.cancelTurn = func() {}
428 interactive.mu.Unlock()
429 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
430 t.Fatal("Esc during a turn should not exit")
431 }
432 interactive.mu.Lock()
433 interactive.busy = false
434 interactive.mu.Unlock()
435
436 interactive.runGoalCommand(context.Background(), "/goal pause", []string{"/goal", "pause"})
437 if got := store.current(); got == nil || got.Status != core.GoalPaused {
438 t.Fatalf("goal after pause = %#v, want paused", got)
439 }
440 // Re-arm an interruption against the paused goal is impossible, so arm
441 // directly: resume must clear it and start the goal without waiting for
442 // an ordinary prompt.
443 interactive.mu.Lock()
444 interactive.interruptedGoalReturn = &interruptedGoalState{goalID: "goal-1", nextPromptArmed: true}
445 interactive.mu.Unlock()
446 interactive.runGoalCommand(context.Background(), "/goal resume", []string{"/goal", "resume"})
447 if got := store.current(); got == nil || got.Status != core.GoalActive {
448 t.Fatalf("goal after resume = %#v, want active", got)
449 }
450 goalReq := receiveRequest(t, client.requests)
451 if _, ok := lastMessageWithMeta(goalReq, goalContinueMetaKey); !ok {
452 t.Fatalf("resume request tail = %#v, want goal continuation", goalReq.Messages)
453 }
454 waitInteractiveIdle(t, interactive)
455}
456
457func TestExplicitReplacementAfterEscapeStartsNewGoal(t *testing.T) {
458 interactive, client, store := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive})
459 interactive.mu.Lock()
460 interactive.busy = true
461 interactive.cancelTurn = func() {}
462 interactive.mu.Unlock()
463 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
464 t.Fatal("Esc during a turn should not exit")
465 }
466 interactive.mu.Lock()
467 interactive.busy = false
468 interactive.mu.Unlock()
469
470 interactive.runGoalCommand(context.Background(), "/goal do something else", []string{"/goal", "do"})
471 got := store.current()
472 if got == nil || got.Status != core.GoalActive || got.Objective != "do something else" {
473 t.Fatalf("goal after replacement = %#v, want the new active objective", got)
474 }
475 goalReq := receiveRequest(t, client.requests)
476 if _, ok := lastMessageWithMeta(goalReq, goalContinueMetaKey); !ok {
477 t.Fatalf("replacement request tail = %#v, want goal continuation", goalReq.Messages)
478 }
479 if text := userMessageText(goalReq.Messages[len(goalReq.Messages)-1]); !strings.Contains(text, "do something else") {
480 t.Fatalf("replacement continuation text = %q, want the new objective", text)
481 }
482 waitInteractiveIdle(t, interactive)
483}
484
485func TestInterruptedGoalCancelledOwningTurnKeepsArmedReturn(t *testing.T) {
486 interactive, client, store := newInterruptTestHarness(&core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive})
487 client.mu.Lock()
488 client.blockFirst = make(chan struct{})
489 client.firstStarted = make(chan struct{})
490 client.mu.Unlock()
491 if !interactive.requestGoalContinuationIfIdle(context.Background()) {
492 t.Fatal("goal continuation did not start")
493 }
494 _ = receiveRequest(t, client.requests)
495 select {
496 case <-client.firstStarted:
497 case <-time.After(2 * time.Second):
498 t.Fatal("goal turn did not reach the provider")
499 }
500 if done := interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
501 t.Fatal("Esc during a turn should not exit")
502 }
503 waitInteractiveIdle(t, interactive)
504 // The cancelled goal turn unwinds without restarting autonomy, but the
505 // armed return for the next ordinary prompt survives cleanup.
506 if got := store.current(); got == nil || got.Status != core.GoalActive {
507 t.Fatalf("goal after Esc = %#v, want active", got)
508 }
509 interactive.mu.Lock()
510 state := interactive.interruptedGoalReturn
511 interactive.mu.Unlock()
512 if state == nil || !state.nextPromptArmed {
513 t.Fatalf("armed return after cancellation = %#v, want next-prompt intent", state)
514 }
515 select {
516 case extra := <-client.requests:
517 t.Fatalf("goal restarted before the next user prompt: %#v", extra.Messages)
518 case <-time.After(200 * time.Millisecond):
519 }
520}