v0.42.0 82578de

goal_interrupt_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close