detached 0b7037b

feat(goals): survive Esc interruption and resume after incidental input (#196)

* feat(goals): survive Esc interruption and resume after incidental input

Esc now cancels only the running turn: active goals stay active with transient return state instead of persisting GoalPaused.

The next ordinary user prompt carries a hidden reassessment instruction (zut_goal_interrupt_reassess) requiring latest-input-first handling and autonomous resume of incidental work.

Clean completion resumes the same goal once via requestGoalContinuationIfIdle; terminal, paused, cleared, replaced, cancelled, or errored turns never resume.

update_goal guidance now mandates goal start for settled, bounded, substantial work and excludes trivial interactions.

* fix(goals): remove unused discardInterruptedGoalReturn helper
commit
0b7037b642b821ca55f9f83f126fa214c6946f79
author
bnema · · 2026-09-10 11:33 UTC
committer
GitHub · · 2026-09-10 11:33 UTC
parent
b53696d
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +799 -11
M README.md +1 -1
@@ -368,7 +368,7 @@ Goals are unlimited by default. Zut records token use for each goal but only enf
368368 }
369369 ```
370370
371-When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. If a provider truncates an active goal because its output limit was reached, zut first compacts the durable conversation state, then resumes the goal in a fresh leased turn; this capacity boundary does not count as no progress or a task blocker. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
371+When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. If a provider truncates an active goal because its output limit was reached, zut first compacts the durable conversation state, then resumes the goal in a fresh leased turn; this capacity boundary does not count as no progress or a task blocker. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` cancels only the running turn: the goal stays active, the next message is handled first, and incidental input is followed by one automatic return to the goal. Only `/goal pause` pauses execution. Terminal provider errors block the goal instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
372372
373373 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
374374
@@ -368,7 +368,7 @@ Goals are unlimited by default. Zut records token use for each goal but only enf
368 }368 }
369 ```369 ```
370 370
371-When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. If a provider truncates an active goal because its output limit was reached, zut first compacts the durable conversation state, then resumes the goal in a fresh leased turn; this capacity boundary does not count as no progress or a task blocker. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.371+When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. If a provider truncates an active goal because its output limit was reached, zut first compacts the durable conversation state, then resumes the goal in a fresh leased turn; this capacity boundary does not count as no progress or a task blocker. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` cancels only the running turn: the goal stays active, the next message is handled first, and incidental input is followed by one automatic return to the goal. Only `/goal pause` pauses execution. Terminal provider errors block the goal instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
372 372
373 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.373 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
374 374
M packages/agent/modes/goal.go +167 -2
@@ -12,6 +12,23 @@ import (
1212
1313 const goalContinueMetaKey = "zut_goal_continue"
1414
15+// goalInterruptReassessMetaKey marks the hidden post-interruption
16+// instruction appended before the next ordinary user prompt. It is filtered
17+// from the rendered transcript like goalContinueMetaKey.
18+const goalInterruptReassessMetaKey = "zut_goal_interrupt_reassess"
19+
20+// interruptedGoalState is transient controller state recording that Esc
21+// cancelled the running turn of an active goal. nextPromptArmed means the
22+// immediately following ordinary user prompt must carry the hidden
23+// reassessment instruction; returnAfterTurn means that prompt completed
24+// cleanly and the same goal may resume once via requestGoalContinuationIfIdle.
25+type interruptedGoalState struct {
26+ goalID string
27+ missionID string
28+ nextPromptArmed bool
29+ returnAfterTurn bool
30+}
31+
1532 func (i *Interactive) goalUpdatesAvailable() bool {
1633 ag := i.Agent()
1734 if ag == nil {
@@ -47,13 +64,149 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
4764 }, true
4865 }
4966
67+// armInterruptedGoalReturn records a transient return intent when Esc cancels
68+// the running turn of an active goal. The persisted goal stays active; only
69+// the next ordinary user prompt carries the hidden reassessment instruction
70+// and may resume the same goal afterwards.
71+func (i *Interactive) armInterruptedGoalReturn() {
72+ if i.cfg.CurrentGoal == nil {
73+ return
74+ }
75+ goal := i.cfg.CurrentGoal()
76+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
77+ return
78+ }
79+ i.mu.Lock()
80+ i.interruptedGoalReturn = &interruptedGoalState{
81+ goalID: goal.ID,
82+ missionID: goal.MissionID,
83+ nextPromptArmed: true,
84+ }
85+ i.mu.Unlock()
86+}
87+
88+// interruptedGoalMatchesLocked reports whether transient interruption state
89+// still targets the currently persisted active goal. Callers must hold i.mu.
90+func (i *Interactive) interruptedGoalMatchesLocked() bool {
91+ state := i.interruptedGoalReturn
92+ if state == nil || i.cfg.CurrentGoal == nil {
93+ return false
94+ }
95+ goal := i.cfg.CurrentGoal()
96+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
97+ return false
98+ }
99+ if state.goalID != "" && goal.ID != "" && state.goalID != goal.ID {
100+ return false
101+ }
102+ if state.missionID != "" && goal.MissionID != "" && state.missionID != goal.MissionID {
103+ return false
104+ }
105+ return true
106+}
107+
108+// clearInterruptedGoalReturn drops transient interruption state without
109+// touching the persisted goal.
110+func (i *Interactive) clearInterruptedGoalReturn() {
111+ i.mu.Lock()
112+ i.interruptedGoalReturn = nil
113+ i.mu.Unlock()
114+}
115+
116+// consumeInterruptedGoalPrompt binds pending interruption state to the
117+// ordinary user turn that is actually starting. It appends the hidden
118+// reassessment instruction before that prompt and records that a clean
119+// completion of this turn owns one automatic return to the same goal.
120+func (i *Interactive) consumeInterruptedGoalPrompt() {
121+ i.mu.Lock()
122+ state := i.interruptedGoalReturn
123+ matches := i.interruptedGoalMatchesLocked()
124+ if state == nil || !state.nextPromptArmed || !matches {
125+ // Stale or mismatched state must never leak into an unrelated turn.
126+ if state != nil && (!state.nextPromptArmed && !state.returnAfterTurn || !matches) {
127+ i.interruptedGoalReturn = nil
128+ }
129+ i.mu.Unlock()
130+ return
131+ }
132+ state.nextPromptArmed = false
133+ state.returnAfterTurn = true
134+ i.mu.Unlock()
135+
136+ message, ok := i.goalInterruptReassessmentMessage()
137+ if !ok {
138+ i.mu.Lock()
139+ if i.interruptedGoalReturn == state {
140+ i.interruptedGoalReturn = nil
141+ }
142+ i.mu.Unlock()
143+ return
144+ }
145+ ag := i.Agent()
146+ if ag == nil {
147+ i.mu.Lock()
148+ if i.interruptedGoalReturn == state {
149+ i.interruptedGoalReturn = nil
150+ }
151+ i.mu.Unlock()
152+ return
153+ }
154+ ag.AppendUserContext(userMessageText(message), message.Meta)
155+}
156+
157+// goalInterruptReassessmentMessage builds the hidden instruction appended
158+// immediately before the next ordinary user prompt after Esc. It requires
159+// handling the latest user message first and resuming incidental work
160+// autonomously; update_goal is reserved for genuine lifecycle transitions.
161+func (i *Interactive) goalInterruptReassessmentMessage() (provider.Message, bool) {
162+ if i.cfg.CurrentGoal == nil {
163+ return provider.Message{}, false
164+ }
165+ goal := i.cfg.CurrentGoal()
166+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
167+ return provider.Message{}, false
168+ }
169+ text := "The previous turn was interrupted by the user; the autonomous goal below remains active. " +
170+ "Handle the latest user message first. Classify it as changing, settling, blocking, or incidental to that goal. " +
171+ "If it is incidental, answer it briefly and then continue the goal autonomously without waiting for the user to say continue. " +
172+ "Call update_goal only for a genuine complete, blocked, or superseded transition.\n\nActive goal: " + goal.Objective
173+ if goal.ID != "" {
174+ text += " When superseding this goal, use goal_id " + goal.ID + "."
175+ }
176+ if goal.MissionID != "" {
177+ text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
178+ }
179+ return provider.Message{
180+ Role: provider.RoleUser,
181+ Content: []provider.Content{provider.TextBlock{Text: text}},
182+ Meta: map[string]string{goalInterruptReassessMetaKey: "true"},
183+ }, true
184+}
185+
50186 func (i *Interactive) goalIdleLocked() bool {
51187 return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0
52188 }
53189
190+// interruptedPromptPendingLocked reports whether Esc armed a reassessment
191+// prompt that has not yet started. While set, autonomous goal wakes must
192+// yield so the next ordinary user message is handled first. Callers must
193+// hold i.mu.
194+func (i *Interactive) interruptedPromptPendingLocked() bool {
195+ state := i.interruptedGoalReturn
196+ return state != nil && state.nextPromptArmed
197+}
198+
199+// interruptedPromptPending reports whether Esc armed a reassessment prompt
200+// that has not yet started.
201+func (i *Interactive) interruptedPromptPending() bool {
202+ i.mu.Lock()
203+ defer i.mu.Unlock()
204+ return i.interruptedPromptPendingLocked()
205+}
206+
54207 func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
55208 message, ok := i.goalContinuationMessage()
56- if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil {
209+ if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil || i.interruptedPromptPending() {
57210 return false
58211 }
59212 goal := copySessionGoal(i.cfg.CurrentGoal())
@@ -91,7 +244,7 @@ func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool
91244 // accounting, and no-progress policy as an ordinary idle goal turn.
92245 func (i *Interactive) startReservedGoalContinuation(parent context.Context) {
93246 message, ok := i.goalContinuationMessage()
94- if !ok || i.cfg.CurrentGoal == nil {
247+ if !ok || i.cfg.CurrentGoal == nil || i.interruptedPromptPending() {
95248 i.mu.Lock()
96249 i.busy = false
97250 i.mu.Unlock()
@@ -274,6 +427,9 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
274427 }
275428 current.Status = core.GoalPaused
276429 current.Reason = ""
430+ // An explicit pause is durable: drop any armed Esc return so the
431+ // next prompt cannot resume the goal. /goal resume restarts it.
432+ i.clearInterruptedGoalReturn()
277433 case "resume":
278434 if current == nil {
279435 i.setGoalCommandError("goal: no goal to resume")
@@ -287,6 +443,7 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
287443 i.setGoalCommandError("goal: " + err.Error())
288444 return
289445 }
446+ i.clearInterruptedGoalReturn()
290447 i.setGoalStatus(nil)
291448 i.setGoalCommandStatus("autonomous goal cleared")
292449 return
@@ -315,6 +472,11 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
315472 i.setGoalCommandError("goal: " + err.Error())
316473 return
317474 }
475+ // An explicit resume or replacement supersedes Esc-armed reassessment:
476+ // the command owns the next wake, not the pending ordinary prompt.
477+ if startIfIdle {
478+ i.clearInterruptedGoalReturn()
479+ }
318480 i.setGoalStatus(current)
319481 i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))
320482 if startTitle {
@@ -327,6 +489,9 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
327489
328490 // RefreshGoal reloads the status badge after the CLI commits a session switch.
329491 func (i *Interactive) RefreshGoal() {
492+ // A session replacement invalidates transient interruption state: the next
493+ // prompt in the new session must not inherit the prior goal's return.
494+ i.clearInterruptedGoalReturn()
330495 if i.cfg.CurrentGoal == nil {
331496 i.setGoalStatus(nil)
332497 return
@@ -12,6 +12,23 @@ import (
12 12
13 const goalContinueMetaKey = "zut_goal_continue"13 const goalContinueMetaKey = "zut_goal_continue"
14 14
15+// goalInterruptReassessMetaKey marks the hidden post-interruption
16+// instruction appended before the next ordinary user prompt. It is filtered
17+// from the rendered transcript like goalContinueMetaKey.
18+const goalInterruptReassessMetaKey = "zut_goal_interrupt_reassess"
19+
20+// interruptedGoalState is transient controller state recording that Esc
21+// cancelled the running turn of an active goal. nextPromptArmed means the
22+// immediately following ordinary user prompt must carry the hidden
23+// reassessment instruction; returnAfterTurn means that prompt completed
24+// cleanly and the same goal may resume once via requestGoalContinuationIfIdle.
25+type interruptedGoalState struct {
26+ goalID string
27+ missionID string
28+ nextPromptArmed bool
29+ returnAfterTurn bool
30+}
31+
15 func (i *Interactive) goalUpdatesAvailable() bool {32 func (i *Interactive) goalUpdatesAvailable() bool {
16 ag := i.Agent()33 ag := i.Agent()
17 if ag == nil {34 if ag == nil {
@@ -47,13 +64,149 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
47 }, true64 }, true
48 }65 }
49 66
67+// armInterruptedGoalReturn records a transient return intent when Esc cancels
68+// the running turn of an active goal. The persisted goal stays active; only
69+// the next ordinary user prompt carries the hidden reassessment instruction
70+// and may resume the same goal afterwards.
71+func (i *Interactive) armInterruptedGoalReturn() {
72+ if i.cfg.CurrentGoal == nil {
73+ return
74+ }
75+ goal := i.cfg.CurrentGoal()
76+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
77+ return
78+ }
79+ i.mu.Lock()
80+ i.interruptedGoalReturn = &interruptedGoalState{
81+ goalID: goal.ID,
82+ missionID: goal.MissionID,
83+ nextPromptArmed: true,
84+ }
85+ i.mu.Unlock()
86+}
87+
88+// interruptedGoalMatchesLocked reports whether transient interruption state
89+// still targets the currently persisted active goal. Callers must hold i.mu.
90+func (i *Interactive) interruptedGoalMatchesLocked() bool {
91+ state := i.interruptedGoalReturn
92+ if state == nil || i.cfg.CurrentGoal == nil {
93+ return false
94+ }
95+ goal := i.cfg.CurrentGoal()
96+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
97+ return false
98+ }
99+ if state.goalID != "" && goal.ID != "" && state.goalID != goal.ID {
100+ return false
101+ }
102+ if state.missionID != "" && goal.MissionID != "" && state.missionID != goal.MissionID {
103+ return false
104+ }
105+ return true
106+}
107+
108+// clearInterruptedGoalReturn drops transient interruption state without
109+// touching the persisted goal.
110+func (i *Interactive) clearInterruptedGoalReturn() {
111+ i.mu.Lock()
112+ i.interruptedGoalReturn = nil
113+ i.mu.Unlock()
114+}
115+
116+// consumeInterruptedGoalPrompt binds pending interruption state to the
117+// ordinary user turn that is actually starting. It appends the hidden
118+// reassessment instruction before that prompt and records that a clean
119+// completion of this turn owns one automatic return to the same goal.
120+func (i *Interactive) consumeInterruptedGoalPrompt() {
121+ i.mu.Lock()
122+ state := i.interruptedGoalReturn
123+ matches := i.interruptedGoalMatchesLocked()
124+ if state == nil || !state.nextPromptArmed || !matches {
125+ // Stale or mismatched state must never leak into an unrelated turn.
126+ if state != nil && (!state.nextPromptArmed && !state.returnAfterTurn || !matches) {
127+ i.interruptedGoalReturn = nil
128+ }
129+ i.mu.Unlock()
130+ return
131+ }
132+ state.nextPromptArmed = false
133+ state.returnAfterTurn = true
134+ i.mu.Unlock()
135+
136+ message, ok := i.goalInterruptReassessmentMessage()
137+ if !ok {
138+ i.mu.Lock()
139+ if i.interruptedGoalReturn == state {
140+ i.interruptedGoalReturn = nil
141+ }
142+ i.mu.Unlock()
143+ return
144+ }
145+ ag := i.Agent()
146+ if ag == nil {
147+ i.mu.Lock()
148+ if i.interruptedGoalReturn == state {
149+ i.interruptedGoalReturn = nil
150+ }
151+ i.mu.Unlock()
152+ return
153+ }
154+ ag.AppendUserContext(userMessageText(message), message.Meta)
155+}
156+
157+// goalInterruptReassessmentMessage builds the hidden instruction appended
158+// immediately before the next ordinary user prompt after Esc. It requires
159+// handling the latest user message first and resuming incidental work
160+// autonomously; update_goal is reserved for genuine lifecycle transitions.
161+func (i *Interactive) goalInterruptReassessmentMessage() (provider.Message, bool) {
162+ if i.cfg.CurrentGoal == nil {
163+ return provider.Message{}, false
164+ }
165+ goal := i.cfg.CurrentGoal()
166+ if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" {
167+ return provider.Message{}, false
168+ }
169+ text := "The previous turn was interrupted by the user; the autonomous goal below remains active. " +
170+ "Handle the latest user message first. Classify it as changing, settling, blocking, or incidental to that goal. " +
171+ "If it is incidental, answer it briefly and then continue the goal autonomously without waiting for the user to say continue. " +
172+ "Call update_goal only for a genuine complete, blocked, or superseded transition.\n\nActive goal: " + goal.Objective
173+ if goal.ID != "" {
174+ text += " When superseding this goal, use goal_id " + goal.ID + "."
175+ }
176+ if goal.MissionID != "" {
177+ text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
178+ }
179+ return provider.Message{
180+ Role: provider.RoleUser,
181+ Content: []provider.Content{provider.TextBlock{Text: text}},
182+ Meta: map[string]string{goalInterruptReassessMetaKey: "true"},
183+ }, true
184+}
185+
50 func (i *Interactive) goalIdleLocked() bool {186 func (i *Interactive) goalIdleLocked() bool {
51 return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0187 return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0
52 }188 }
53 189
190+// interruptedPromptPendingLocked reports whether Esc armed a reassessment
191+// prompt that has not yet started. While set, autonomous goal wakes must
192+// yield so the next ordinary user message is handled first. Callers must
193+// hold i.mu.
194+func (i *Interactive) interruptedPromptPendingLocked() bool {
195+ state := i.interruptedGoalReturn
196+ return state != nil && state.nextPromptArmed
197+}
198+
199+// interruptedPromptPending reports whether Esc armed a reassessment prompt
200+// that has not yet started.
201+func (i *Interactive) interruptedPromptPending() bool {
202+ i.mu.Lock()
203+ defer i.mu.Unlock()
204+ return i.interruptedPromptPendingLocked()
205+}
206+
54 func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {207 func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
55 message, ok := i.goalContinuationMessage()208 message, ok := i.goalContinuationMessage()
56- if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil {209+ if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil || i.interruptedPromptPending() {
57 return false210 return false
58 }211 }
59 goal := copySessionGoal(i.cfg.CurrentGoal())212 goal := copySessionGoal(i.cfg.CurrentGoal())
@@ -91,7 +244,7 @@ func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool
91 // accounting, and no-progress policy as an ordinary idle goal turn.244 // accounting, and no-progress policy as an ordinary idle goal turn.
92 func (i *Interactive) startReservedGoalContinuation(parent context.Context) {245 func (i *Interactive) startReservedGoalContinuation(parent context.Context) {
93 message, ok := i.goalContinuationMessage()246 message, ok := i.goalContinuationMessage()
94- if !ok || i.cfg.CurrentGoal == nil {247+ if !ok || i.cfg.CurrentGoal == nil || i.interruptedPromptPending() {
95 i.mu.Lock()248 i.mu.Lock()
96 i.busy = false249 i.busy = false
97 i.mu.Unlock()250 i.mu.Unlock()
@@ -274,6 +427,9 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
274 }427 }
275 current.Status = core.GoalPaused428 current.Status = core.GoalPaused
276 current.Reason = ""429 current.Reason = ""
430+ // An explicit pause is durable: drop any armed Esc return so the
431+ // next prompt cannot resume the goal. /goal resume restarts it.
432+ i.clearInterruptedGoalReturn()
277 case "resume":433 case "resume":
278 if current == nil {434 if current == nil {
279 i.setGoalCommandError("goal: no goal to resume")435 i.setGoalCommandError("goal: no goal to resume")
@@ -287,6 +443,7 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
287 i.setGoalCommandError("goal: " + err.Error())443 i.setGoalCommandError("goal: " + err.Error())
288 return444 return
289 }445 }
446+ i.clearInterruptedGoalReturn()
290 i.setGoalStatus(nil)447 i.setGoalStatus(nil)
291 i.setGoalCommandStatus("autonomous goal cleared")448 i.setGoalCommandStatus("autonomous goal cleared")
292 return449 return
@@ -315,6 +472,11 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
315 i.setGoalCommandError("goal: " + err.Error())472 i.setGoalCommandError("goal: " + err.Error())
316 return473 return
317 }474 }
475+ // An explicit resume or replacement supersedes Esc-armed reassessment:
476+ // the command owns the next wake, not the pending ordinary prompt.
477+ if startIfIdle {
478+ i.clearInterruptedGoalReturn()
479+ }
318 i.setGoalStatus(current)480 i.setGoalStatus(current)
319 i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))481 i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))
320 if startTitle {482 if startTitle {
@@ -327,6 +489,9 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
327 489
328 // RefreshGoal reloads the status badge after the CLI commits a session switch.490 // RefreshGoal reloads the status badge after the CLI commits a session switch.
329 func (i *Interactive) RefreshGoal() {491 func (i *Interactive) RefreshGoal() {
492+ // A session replacement invalidates transient interruption state: the next
493+ // prompt in the new session must not inherit the prior goal's return.
494+ i.clearInterruptedGoalReturn()
330 if i.cfg.CurrentGoal == nil {495 if i.cfg.CurrentGoal == nil {
331 i.setGoalStatus(nil)496 i.setGoalStatus(nil)
332 return497 return
A packages/agent/modes/goal_interrupt_test.go +520 -0
@@ -0,0 +1,520 @@
1+package modes
2+
3+import (
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.
21+type 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+
31+func (c *interruptTestClient) Name() string { return "goal-interrupt-test" }
32+
33+func (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+
76+func (c *interruptTestClient) count() int {
77+ c.mu.Lock()
78+ defer c.mu.Unlock()
79+ return c.calls
80+}
81+
82+type interruptGoalStore struct {
83+ mu sync.Mutex
84+ goal *core.SessionGoal
85+}
86+
87+func (s *interruptGoalStore) current() *core.SessionGoal {
88+ s.mu.Lock()
89+ defer s.mu.Unlock()
90+ return cloneSessionGoal(s.goal)
91+}
92+
93+func (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+
100+func 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+
114+func 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.
126+func 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+
137+func 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+
158+func 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+
176+func 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+
205+func 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+
227+func 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+
303+func 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+
328+func 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+
388+func 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+
423+func 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+
457+func 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+
485+func 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+}
@@ -0,0 +1,520 @@
1+package modes
2+
3+import (
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.
21+type 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+
31+func (c *interruptTestClient) Name() string { return "goal-interrupt-test" }
32+
33+func (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+
76+func (c *interruptTestClient) count() int {
77+ c.mu.Lock()
78+ defer c.mu.Unlock()
79+ return c.calls
80+}
81+
82+type interruptGoalStore struct {
83+ mu sync.Mutex
84+ goal *core.SessionGoal
85+}
86+
87+func (s *interruptGoalStore) current() *core.SessionGoal {
88+ s.mu.Lock()
89+ defer s.mu.Unlock()
90+ return cloneSessionGoal(s.goal)
91+}
92+
93+func (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+
100+func 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+
114+func 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.
126+func 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+
137+func 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+
158+func 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+
176+func 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+
205+func 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+
227+func 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+
303+func 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+
328+func 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+
388+func 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+
423+func 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+
457+func 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+
485+func 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+}
M packages/agent/modes/goal_runtime.go +14 -2
@@ -126,9 +126,18 @@ func (i *Interactive) observeGoalRun(ev core.AgentEvent) {
126126 // finishGoalRun records accounting exactly once and returns whether the
127127 // controller should attempt another autonomous continuation. Context-limited
128128 // turns retain their prior progress classification because output truncation
129-// is not evidence that the agent stopped acting. A stale run is discarded
129+// is not evidence that the agent stopped acting. Cancelled runs keep lease
130+// cleanup and token accounting but never consume the no-progress allowance:
131+// the user interrupted before the model could act. A stale run is discarded
130132 // without changing a newer goal.
131133 func (i *Interactive) finishGoalRun(contextLimited bool) bool {
134+ return i.finishGoalRunCancelled(contextLimited, false)
135+}
136+
137+// finishGoalRunCancelled is finishGoalRun with an explicit cancellation
138+// signal from the turn owner. Esc-armed reassessment relies on this so an
139+// interrupted corrective continuation cannot stall the still-active goal.
140+func (i *Interactive) finishGoalRunCancelled(contextLimited, cancelled bool) bool {
132141 i.mu.Lock()
133142 run := i.goalRun
134143 i.goalRun = nil
@@ -172,7 +181,10 @@ func (i *Interactive) finishGoalRun(contextLimited bool) bool {
172181 i.setGoalStatus(goal)
173182 return false
174183 }
175- if contextLimited {
184+ if cancelled {
185+ // Esc arrived before the model could act; preserve the prior
186+ // progress classification exactly like the capacity boundary below.
187+ } else if contextLimited {
176188 // The compact handoff provisions a fresh turn below. Do not let an
177189 // execution-capacity boundary consume the no-progress allowance.
178190 } else if run.hadTool {
@@ -126,9 +126,18 @@ func (i *Interactive) observeGoalRun(ev core.AgentEvent) {
126 // finishGoalRun records accounting exactly once and returns whether the126 // finishGoalRun records accounting exactly once and returns whether the
127 // controller should attempt another autonomous continuation. Context-limited127 // controller should attempt another autonomous continuation. Context-limited
128 // turns retain their prior progress classification because output truncation128 // turns retain their prior progress classification because output truncation
129-// is not evidence that the agent stopped acting. A stale run is discarded129+// is not evidence that the agent stopped acting. Cancelled runs keep lease
130+// cleanup and token accounting but never consume the no-progress allowance:
131+// the user interrupted before the model could act. A stale run is discarded
130 // without changing a newer goal.132 // without changing a newer goal.
131 func (i *Interactive) finishGoalRun(contextLimited bool) bool {133 func (i *Interactive) finishGoalRun(contextLimited bool) bool {
134+ return i.finishGoalRunCancelled(contextLimited, false)
135+}
136+
137+// finishGoalRunCancelled is finishGoalRun with an explicit cancellation
138+// signal from the turn owner. Esc-armed reassessment relies on this so an
139+// interrupted corrective continuation cannot stall the still-active goal.
140+func (i *Interactive) finishGoalRunCancelled(contextLimited, cancelled bool) bool {
132 i.mu.Lock()141 i.mu.Lock()
133 run := i.goalRun142 run := i.goalRun
134 i.goalRun = nil143 i.goalRun = nil
@@ -172,7 +181,10 @@ func (i *Interactive) finishGoalRun(contextLimited bool) bool {
172 i.setGoalStatus(goal)181 i.setGoalStatus(goal)
173 return false182 return false
174 }183 }
175- if contextLimited {184+ if cancelled {
185+ // Esc arrived before the model could act; preserve the prior
186+ // progress classification exactly like the capacity boundary below.
187+ } else if contextLimited {
176 // The compact handoff provisions a fresh turn below. Do not let an188 // The compact handoff provisions a fresh turn below. Do not let an
177 // execution-capacity boundary consume the no-progress allowance.189 // execution-capacity boundary consume the no-progress allowance.
178 } else if run.hadTool {190 } else if run.hadTool {
M packages/agent/modes/interactive.go +6 -0
@@ -824,6 +824,12 @@ type Interactive struct {
824824 // inferred from rendered transcript rows.
825825 compactContinuation compactContinuationState
826826
827+ // interruptedGoalReturn records an active goal whose running turn was
828+ // cancelled by Esc. It is transient controller state, never persisted:
829+ // the goal itself stays active and the next ordinary user turn owns a
830+ // single automatic return to the same goal after it completes cleanly.
831+ interruptedGoalReturn *interruptedGoalState
832+
827833 // updateInfo is the result of the async update check. Zero value
828834 // while the check hasn't completed or nothing is available.
829835 updateInfo UpdateInfo
@@ -824,6 +824,12 @@ type Interactive struct {
824 // inferred from rendered transcript rows.824 // inferred from rendered transcript rows.
825 compactContinuation compactContinuationState825 compactContinuation compactContinuationState
826 826
827+ // interruptedGoalReturn records an active goal whose running turn was
828+ // cancelled by Esc. It is transient controller state, never persisted:
829+ // the goal itself stays active and the next ordinary user turn owns a
830+ // single automatic return to the same goal after it completes cleanly.
831+ interruptedGoalReturn *interruptedGoalState
832+
827 // updateInfo is the result of the async update check. Zero value833 // updateInfo is the result of the async update check. Zero value
828 // while the check hasn't completed or nothing is available.834 // while the check hasn't completed or nothing is available.
829 updateInfo UpdateInfo835 updateInfo UpdateInfo
M packages/agent/modes/interactive_events.go +8 -0
@@ -176,6 +176,9 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
176176 if current := i.cfg.CurrentGoal(); current != nil {
177177 i.goalStatus = current.Status
178178 }
179+ // A superseding replacement owns its own lifecycle; a pending
180+ // Esc return for the replaced goal must not fire.
181+ i.interruptedGoalReturn = nil
179182 } else if update.Status == core.GoalActive {
180183 // A manager may advance a terminal goal to the next persisted
181184 // goal in the same mission. Do not let a late tool result resume
@@ -185,6 +188,11 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
185188 }
186189 } else if i.goalStatus == core.GoalActive {
187190 i.goalStatus = update.Status
191+ // Terminal update_goal transitions settle the mission; an Esc
192+ // return for the settled goal is inert by identity, drop it.
193+ if update.Status == core.GoalDone || update.Status == core.GoalBlocked {
194+ i.interruptedGoalReturn = nil
195+ }
188196 }
189197 }
190198 case core.EvPlanUpdate:
@@ -176,6 +176,9 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
176 if current := i.cfg.CurrentGoal(); current != nil {176 if current := i.cfg.CurrentGoal(); current != nil {
177 i.goalStatus = current.Status177 i.goalStatus = current.Status
178 }178 }
179+ // A superseding replacement owns its own lifecycle; a pending
180+ // Esc return for the replaced goal must not fire.
181+ i.interruptedGoalReturn = nil
179 } else if update.Status == core.GoalActive {182 } else if update.Status == core.GoalActive {
180 // A manager may advance a terminal goal to the next persisted183 // A manager may advance a terminal goal to the next persisted
181 // goal in the same mission. Do not let a late tool result resume184 // goal in the same mission. Do not let a late tool result resume
@@ -185,6 +188,11 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
185 }188 }
186 } else if i.goalStatus == core.GoalActive {189 } else if i.goalStatus == core.GoalActive {
187 i.goalStatus = update.Status190 i.goalStatus = update.Status
191+ // Terminal update_goal transitions settle the mission; an Esc
192+ // return for the settled goal is inert by identity, drop it.
193+ if update.Status == core.GoalDone || update.Status == core.GoalBlocked {
194+ i.interruptedGoalReturn = nil
195+ }
188 }196 }
189 }197 }
190 case core.EvPlanUpdate:198 case core.EvPlanUpdate:
M packages/agent/modes/interactive_input_routing.go +5 -1
@@ -627,7 +627,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
627627 if hasRestoreQueued {
628628 i.restoreQueuedMessageToEditor(restoreQueued)
629629 }
630- i.updateActiveGoal(core.GoalPaused, "interrupted by user")
630+ // Esc cancels only the running turn. An active goal stays
631+ // active; record a transient return so the next ordinary user
632+ // turn reassesses the interruption and then resumes it.
633+ // Explicit /goal pause remains the only pause path here.
634+ i.armInterruptedGoalReturn()
631635 cancelTurn()
632636 // If a confirm dialog is pending, refuse it so the agent
633637 // goroutine unblocks and the context cancellation can
@@ -627,7 +627,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
627 if hasRestoreQueued {627 if hasRestoreQueued {
628 i.restoreQueuedMessageToEditor(restoreQueued)628 i.restoreQueuedMessageToEditor(restoreQueued)
629 }629 }
630- i.updateActiveGoal(core.GoalPaused, "interrupted by user")630+ // Esc cancels only the running turn. An active goal stays
631+ // active; record a transient return so the next ordinary user
632+ // turn reassesses the interruption and then resumes it.
633+ // Explicit /goal pause remains the only pause path here.
634+ i.armInterruptedGoalReturn()
631 cancelTurn()635 cancelTurn()
632 // If a confirm dialog is pending, refuse it so the agent636 // If a confirm dialog is pending, refuse it so the agent
633 // goroutine unblocks and the context cancellation can637 // goroutine unblocks and the context cancellation can
M packages/agent/modes/interactive_rendering.go +1 -1
@@ -1142,7 +1142,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
11421142 return out
11431143 }
11441144 func isHiddenTranscriptMessage(m provider.Message) bool {
1145- if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
1145+ if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" || m.Meta[goalInterruptReassessMetaKey] == "true" {
11461146 return true
11471147 }
11481148 if m.Role != provider.RoleUser || len(m.Content) == 0 {
@@ -1142,7 +1142,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
1142 return out1142 return out
1143 }1143 }
1144 func isHiddenTranscriptMessage(m provider.Message) bool {1144 func isHiddenTranscriptMessage(m provider.Message) bool {
1145- if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {1145+ if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" || m.Meta[goalInterruptReassessMetaKey] == "true" {
1146 return true1146 return true
1147 }1147 }
1148 if m.Role != provider.RoleUser || len(m.Content) == 0 {1148 if m.Role != provider.RoleUser || len(m.Content) == 0 {
M packages/agent/modes/interactive_resident_subagents.go +9 -0
@@ -188,6 +188,10 @@ func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action)
188188 if prompt != "" || len(action.Images) != 0 {
189189 i.submitOrQueueMessage(core.QueuedMessage{Text: prompt, Images: action.Images, HostEvent: len(action.Completions) != 0}, false)
190190 } else if action.Reason == orchestration.WakeGoal {
191+ // Esc-armed reassessment keeps the next turn for user input.
192+ if i.interruptedPromptPending() {
193+ continue
194+ }
191195 parent := i.runCtx
192196 if parent == nil {
193197 parent = context.Background()
@@ -235,7 +239,12 @@ func (i *Interactive) releaseCompletionDeliveryHold() {
235239 if i.completionDeliveryHolds == 0 {
236240 // The coordinator must observe the goal before sealing the wave, so a
237241 // pending worker wins the wake decision instead of a direct continuation.
242+ // An Esc-armed reassessment prompt suppresses the wake: the next
243+ // ordinary user message owns the turn, not autonomous work.
238244 _, goalActive := i.goalContinuationMessage()
245+ if i.interruptedPromptPending() {
246+ goalActive = false
247+ }
239248 coordinator := i.ensureCoordinatorLocked()
240249 coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
241250 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
@@ -188,6 +188,10 @@ func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action)
188 if prompt != "" || len(action.Images) != 0 {188 if prompt != "" || len(action.Images) != 0 {
189 i.submitOrQueueMessage(core.QueuedMessage{Text: prompt, Images: action.Images, HostEvent: len(action.Completions) != 0}, false)189 i.submitOrQueueMessage(core.QueuedMessage{Text: prompt, Images: action.Images, HostEvent: len(action.Completions) != 0}, false)
190 } else if action.Reason == orchestration.WakeGoal {190 } else if action.Reason == orchestration.WakeGoal {
191+ // Esc-armed reassessment keeps the next turn for user input.
192+ if i.interruptedPromptPending() {
193+ continue
194+ }
191 parent := i.runCtx195 parent := i.runCtx
192 if parent == nil {196 if parent == nil {
193 parent = context.Background()197 parent = context.Background()
@@ -235,7 +239,12 @@ func (i *Interactive) releaseCompletionDeliveryHold() {
235 if i.completionDeliveryHolds == 0 {239 if i.completionDeliveryHolds == 0 {
236 // The coordinator must observe the goal before sealing the wave, so a240 // The coordinator must observe the goal before sealing the wave, so a
237 // pending worker wins the wake decision instead of a direct continuation.241 // pending worker wins the wake decision instead of a direct continuation.
242+ // An Esc-armed reassessment prompt suppresses the wake: the next
243+ // ordinary user message owns the turn, not autonomous work.
238 _, goalActive := i.goalContinuationMessage()244 _, goalActive := i.goalContinuationMessage()
245+ if i.interruptedPromptPending() {
246+ goalActive = false
247+ }
239 coordinator := i.ensureCoordinatorLocked()248 coordinator := i.ensureCoordinatorLocked()
240 coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})249 coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
241 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions250 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
M packages/agent/modes/interactive_turn.go +51 -3
@@ -194,6 +194,13 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
194194 if persistResetHandoff {
195195 i.persistCompactHandoff(resetHandoff)
196196 }
197+ // Bind a pending Esc interruption to the ordinary user turn that is
198+ // actually starting. Queued-while-busy prompts do not consume it; only
199+ // this start path does, so the hidden instruction precedes the prompt
200+ // that owns the single automatic goal return.
201+ if !continueExisting {
202+ i.consumeInterruptedGoalPrompt()
203+ }
197204
198205 ctx, cancel := context.WithCancel(parent)
199206 i.mu.Lock()
@@ -336,8 +343,20 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
336343 if terminalGoalError {
337344 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
338345 }
346+ // A cancelled or failed turn unwinds without scheduling autonomous
347+ // work below. Preserve a freshly armed Esc return for the next
348+ // ordinary prompt; drop only a return already bound to this turn.
349+ if ctx.Err() != nil || err != nil {
350+ i.mu.Lock()
351+ if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
352+ i.interruptedGoalReturn = nil
353+ }
354+ i.mu.Unlock()
355+ }
339356 goalContextLimited := lastStop == provider.StopLength
340- continueGoal := i.finishGoalRun(goalContextLimited)
357+ // Cancellation is user action, not model inaction: unwind the lease
358+ // without consuming the no-progress allowance or stalling the goal.
359+ continueGoal := i.finishGoalRunCancelled(goalContextLimited, ctx.Err() != nil)
341360 i.mu.Lock()
342361 awaitingPre := i.awaitingStartupPre
343362 // A newer explicit prompt may have cleared the handoff while the
@@ -411,10 +430,34 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
411430 if recoverContextOverflow || shouldAutoCompact {
412431 i.resetStreamingStateLocked()
413432 }
414- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
433+ // A clean ordinary turn that owns a pending Esc return resumes the
434+ // same still-active goal exactly once through the existing scheduler.
435+ // It yields to every existing priority above; terminal, paused, or
436+ // replaced goals never resume because the match check fails.
437+ interruptedReturn := false
438+ if err == nil && ctx.Err() == nil && !awaitingPre && !hasNext && !hasScheduled && !continueQueued && !offer && !recoverContextOverflow && !shouldAutoCompact && !continueStatusRescue && !continueGoal {
439+ state := i.interruptedGoalReturn
440+ if state != nil && state.returnAfterTurn && i.interruptedGoalMatchesLocked() {
441+ // Consume only after matching: the match helper reads the
442+ // state being consumed.
443+ i.interruptedGoalReturn = nil
444+ interruptedReturn = true
445+ }
446+ } else if ctx.Err() == nil && err == nil {
447+ // Another follow-up won this turn: keep an armed-but-unbound
448+ // interruption pending without consuming the one-shot return.
449+ // A bound return that lost priority is inert until replaced.
450+ if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
451+ i.interruptedGoalReturn = nil
452+ }
453+ }
454+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal || interruptedReturn, offer, recoverContextOverflow, shouldAutoCompact)
415455 i.planCurrent = 0
416456 i.planTotal = 0
417- i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
457+ // interruptedReturn already reserves the turn slot (like the
458+ // compact handoff path below): the reserved continuation owns
459+ // busy and must not re-enter the idle gate.
460+ i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact || interruptedReturn
418461 i.mu.Unlock()
419462 if persistHandoff {
420463 i.persistCompactHandoff(handoff)
@@ -457,6 +500,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
457500 })
458501 case continueGoal:
459502 i.requestGoalContinuationIfIdle(parent)
503+ case interruptedReturn:
504+ // busy is already reserved above; use the reserved entry point
505+ // so the one-shot return cannot fail the idle gate and leak
506+ // the reservation.
507+ i.startReservedGoalContinuation(parent)
460508 }
461509 }()
462510 }
@@ -194,6 +194,13 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
194 if persistResetHandoff {194 if persistResetHandoff {
195 i.persistCompactHandoff(resetHandoff)195 i.persistCompactHandoff(resetHandoff)
196 }196 }
197+ // Bind a pending Esc interruption to the ordinary user turn that is
198+ // actually starting. Queued-while-busy prompts do not consume it; only
199+ // this start path does, so the hidden instruction precedes the prompt
200+ // that owns the single automatic goal return.
201+ if !continueExisting {
202+ i.consumeInterruptedGoalPrompt()
203+ }
197 204
198 ctx, cancel := context.WithCancel(parent)205 ctx, cancel := context.WithCancel(parent)
199 i.mu.Lock()206 i.mu.Lock()
@@ -336,8 +343,20 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
336 if terminalGoalError {343 if terminalGoalError {
337 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")344 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
338 }345 }
346+ // A cancelled or failed turn unwinds without scheduling autonomous
347+ // work below. Preserve a freshly armed Esc return for the next
348+ // ordinary prompt; drop only a return already bound to this turn.
349+ if ctx.Err() != nil || err != nil {
350+ i.mu.Lock()
351+ if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
352+ i.interruptedGoalReturn = nil
353+ }
354+ i.mu.Unlock()
355+ }
339 goalContextLimited := lastStop == provider.StopLength356 goalContextLimited := lastStop == provider.StopLength
340- continueGoal := i.finishGoalRun(goalContextLimited)357+ // Cancellation is user action, not model inaction: unwind the lease
358+ // without consuming the no-progress allowance or stalling the goal.
359+ continueGoal := i.finishGoalRunCancelled(goalContextLimited, ctx.Err() != nil)
341 i.mu.Lock()360 i.mu.Lock()
342 awaitingPre := i.awaitingStartupPre361 awaitingPre := i.awaitingStartupPre
343 // A newer explicit prompt may have cleared the handoff while the362 // A newer explicit prompt may have cleared the handoff while the
@@ -411,10 +430,34 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
411 if recoverContextOverflow || shouldAutoCompact {430 if recoverContextOverflow || shouldAutoCompact {
412 i.resetStreamingStateLocked()431 i.resetStreamingStateLocked()
413 }432 }
414- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)433+ // A clean ordinary turn that owns a pending Esc return resumes the
434+ // same still-active goal exactly once through the existing scheduler.
435+ // It yields to every existing priority above; terminal, paused, or
436+ // replaced goals never resume because the match check fails.
437+ interruptedReturn := false
438+ if err == nil && ctx.Err() == nil && !awaitingPre && !hasNext && !hasScheduled && !continueQueued && !offer && !recoverContextOverflow && !shouldAutoCompact && !continueStatusRescue && !continueGoal {
439+ state := i.interruptedGoalReturn
440+ if state != nil && state.returnAfterTurn && i.interruptedGoalMatchesLocked() {
441+ // Consume only after matching: the match helper reads the
442+ // state being consumed.
443+ i.interruptedGoalReturn = nil
444+ interruptedReturn = true
445+ }
446+ } else if ctx.Err() == nil && err == nil {
447+ // Another follow-up won this turn: keep an armed-but-unbound
448+ // interruption pending without consuming the one-shot return.
449+ // A bound return that lost priority is inert until replaced.
450+ if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
451+ i.interruptedGoalReturn = nil
452+ }
453+ }
454+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal || interruptedReturn, offer, recoverContextOverflow, shouldAutoCompact)
415 i.planCurrent = 0455 i.planCurrent = 0
416 i.planTotal = 0456 i.planTotal = 0
417- i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact457+ // interruptedReturn already reserves the turn slot (like the
458+ // compact handoff path below): the reserved continuation owns
459+ // busy and must not re-enter the idle gate.
460+ i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact || interruptedReturn
418 i.mu.Unlock()461 i.mu.Unlock()
419 if persistHandoff {462 if persistHandoff {
420 i.persistCompactHandoff(handoff)463 i.persistCompactHandoff(handoff)
@@ -457,6 +500,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
457 })500 })
458 case continueGoal:501 case continueGoal:
459 i.requestGoalContinuationIfIdle(parent)502 i.requestGoalContinuationIfIdle(parent)
503+ case interruptedReturn:
504+ // busy is already reserved above; use the reserved entry point
505+ // so the one-shot return cannot fail the idle gate and leak
506+ // the reservation.
507+ i.startReservedGoalContinuation(parent)
460 }508 }
461 }()509 }()
462 }510 }
M packages/agent/tools/update_goal.go +1 -1
@@ -42,7 +42,7 @@ type GoalUpdate struct {
4242 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
4343
4444 func (t *UpdateGoalTool) Description() string {
45- return "Start your own mission when none is active, set the next concrete goal within an active mission, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work."
45+ return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission."
4646 }
4747
4848 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
@@ -42,7 +42,7 @@ type GoalUpdate struct {
42 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }42 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
43 43
44 func (t *UpdateGoalTool) Description() string {44 func (t *UpdateGoalTool) Description() string {
45- return "Start your own mission when none is active, set the next concrete goal within an active mission, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work."45+ return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission."
46 }46 }
47 47
48 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }48 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
M packages/agent/tools/update_goal_test.go +16 -0
@@ -3,11 +3,27 @@ package tools
33 import (
44 "context"
55 "encoding/json"
6+ "strings"
67 "testing"
78
89 "github.com/bnema/zut/packages/core"
910 )
1011
12+func TestUpdateGoalToolDescriptionRequiresGoalsForSubstantialWork(t *testing.T) {
13+ description := (&UpdateGoalTool{}).Description()
14+ for _, clause := range []string{
15+ "requires substantial multi-step work",
16+ "before substantial execution",
17+ "Do not start a mission for brief questions, casual conversation",
18+ "requests completed immediately",
19+ "Incidental messages and interrupted turns do not end an active mission",
20+ } {
21+ if !strings.Contains(description, clause) {
22+ t.Fatalf("update_goal description missing %q: %q", clause, description)
23+ }
24+ }
25+}
26+
1127 func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) {
1228 tool := &UpdateGoalTool{}
1329 raw, err := json.Marshal(map[string]string{"status": "complete"})
@@ -3,11 +3,27 @@ package tools
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "strings"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/zut/packages/core"9 "github.com/bnema/zut/packages/core"
9 )10 )
10 11
12+func TestUpdateGoalToolDescriptionRequiresGoalsForSubstantialWork(t *testing.T) {
13+ description := (&UpdateGoalTool{}).Description()
14+ for _, clause := range []string{
15+ "requires substantial multi-step work",
16+ "before substantial execution",
17+ "Do not start a mission for brief questions, casual conversation",
18+ "requests completed immediately",
19+ "Incidental messages and interrupted turns do not end an active mission",
20+ } {
21+ if !strings.Contains(description, clause) {
22+ t.Fatalf("update_goal description missing %q: %q", clause, description)
23+ }
24+ }
25+}
26+
11 func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) {27 func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) {
12 tool := &UpdateGoalTool{}28 tool := &UpdateGoalTool{}
13 raw, err := json.Marshal(map[string]string{"status": "complete"})29 raw, err := json.Marshal(map[string]string{"status": "complete"})

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 476

↑↓ moveenter openesc close