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 Unverified
B5690EEEBB952194The 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 | |||
| 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 | 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 | ||
| @@ -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 ( | |||
| 12 | 12 | ||
| 13 | 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 | 32 | func (i *Interactive) goalUpdatesAvailable() bool { | |
| 16 | 33 | ag := i.Agent() | |
| 17 | 34 | if ag == nil { | |
| @@ -47,13 +64,149 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) { | |||
| 47 | 64 | }, 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 | 186 | func (i *Interactive) goalIdleLocked() bool { | |
| 51 | 187 | 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 | 207 | func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool { | |
| 55 | 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 | 210 | return false | |
| 58 | 211 | } | |
| 59 | 212 | goal := copySessionGoal(i.cfg.CurrentGoal()) | |
| @@ -91,7 +244,7 @@ func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool | |||
| 91 | 244 | // accounting, and no-progress policy as an ordinary idle goal turn. | |
| 92 | 245 | func (i *Interactive) startReservedGoalContinuation(parent context.Context) { | |
| 93 | 246 | message, ok := i.goalContinuationMessage() | |
| 94 | - | if !ok || i.cfg.CurrentGoal == nil { | |
| 247 | + | if !ok || i.cfg.CurrentGoal == nil || i.interruptedPromptPending() { | |
| 95 | 248 | i.mu.Lock() | |
| 96 | 249 | i.busy = false | |
| 97 | 250 | i.mu.Unlock() | |
| @@ -274,6 +427,9 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||
| 274 | 427 | } | |
| 275 | 428 | current.Status = core.GoalPaused | |
| 276 | 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 | 433 | case "resume": | |
| 278 | 434 | if current == nil { | |
| 279 | 435 | i.setGoalCommandError("goal: no goal to resume") | |
| @@ -287,6 +443,7 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||
| 287 | 443 | i.setGoalCommandError("goal: " + err.Error()) | |
| 288 | 444 | return | |
| 289 | 445 | } | |
| 446 | + | i.clearInterruptedGoalReturn() | |
| 290 | 447 | i.setGoalStatus(nil) | |
| 291 | 448 | i.setGoalCommandStatus("autonomous goal cleared") | |
| 292 | 449 | return | |
| @@ -315,6 +472,11 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||
| 315 | 472 | i.setGoalCommandError("goal: " + err.Error()) | |
| 316 | 473 | 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 | 480 | i.setGoalStatus(current) | |
| 319 | 481 | i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective)) | |
| 320 | 482 | if startTitle { | |
| @@ -327,6 +489,9 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||
| 327 | 489 | ||
| 328 | 490 | // RefreshGoal reloads the status badge after the CLI commits a session switch. | |
| 329 | 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 | 495 | if i.cfg.CurrentGoal == nil { | |
| 331 | 496 | i.setGoalStatus(nil) | |
| 332 | 497 | 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 | }, true | 64 | }, 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() == 0 | 187 | 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 false | 210 | 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 = false | 249 | 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.GoalPaused | 428 | 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 | return | 444 | 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 | return | 449 | 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 | return | 473 | 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 | return | 497 | 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) { | |||
| 126 | 126 | // finishGoalRun records accounting exactly once and returns whether the | |
| 127 | 127 | // controller should attempt another autonomous continuation. Context-limited | |
| 128 | 128 | // 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 | |
| 130 | 132 | // without changing a newer goal. | |
| 131 | 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 | 141 | i.mu.Lock() | |
| 133 | 142 | run := i.goalRun | |
| 134 | 143 | i.goalRun = nil | |
| @@ -172,7 +181,10 @@ func (i *Interactive) finishGoalRun(contextLimited bool) bool { | |||
| 172 | 181 | i.setGoalStatus(goal) | |
| 173 | 182 | 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 | 188 | // The compact handoff provisions a fresh turn below. Do not let an | |
| 177 | 189 | // execution-capacity boundary consume the no-progress allowance. | |
| 178 | 190 | } else if run.hadTool { | |
| @@ -126,9 +126,18 @@ func (i *Interactive) observeGoalRun(ev core.AgentEvent) { | |||||
| 126 | // finishGoalRun records accounting exactly once and returns whether the | 126 | // finishGoalRun records accounting exactly once and returns whether the | ||
| 127 | // controller should attempt another autonomous continuation. Context-limited | 127 | // controller should attempt another autonomous continuation. Context-limited | ||
| 128 | // turns retain their prior progress classification because output truncation | 128 | // 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 | |||
| 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.goalRun | 142 | run := i.goalRun | ||
| 134 | i.goalRun = nil | 143 | i.goalRun = nil | ||
| @@ -172,7 +181,10 @@ func (i *Interactive) finishGoalRun(contextLimited bool) bool { | |||||
| 172 | i.setGoalStatus(goal) | 181 | i.setGoalStatus(goal) | ||
| 173 | return false | 182 | 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 an | 188 | // 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 { | |||
| 824 | 824 | // inferred from rendered transcript rows. | |
| 825 | 825 | 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 | 833 | // updateInfo is the result of the async update check. Zero value | |
| 828 | 834 | // while the check hasn't completed or nothing is available. | |
| 829 | 835 | updateInfo UpdateInfo | |
| @@ -824,6 +824,12 @@ type Interactive struct { | |||||
| 824 | // inferred from rendered transcript rows. | 824 | // inferred from rendered transcript rows. | ||
| 825 | compactContinuation compactContinuationState | 825 | 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 value | 833 | // 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 UpdateInfo | 835 | updateInfo UpdateInfo | ||
M packages/agent/modes/interactive_events.go +8 -0
| @@ -176,6 +176,9 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 176 | 176 | if current := i.cfg.CurrentGoal(); current != nil { | |
| 177 | 177 | 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 | 182 | } else if update.Status == core.GoalActive { | |
| 180 | 183 | // A manager may advance a terminal goal to the next persisted | |
| 181 | 184 | // 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 | 189 | } else if i.goalStatus == core.GoalActive { | |
| 187 | 190 | 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 | 198 | 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.Status | 177 | 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 persisted | 183 | // A manager may advance a terminal goal to the next persisted | ||
| 181 | // goal in the same mission. Do not let a late tool result resume | 184 | // 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.Status | 190 | 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) { | |||
| 627 | 627 | if hasRestoreQueued { | |
| 628 | 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 | 635 | cancelTurn() | |
| 632 | 636 | // If a confirm dialog is pending, refuse it so the agent | |
| 633 | 637 | // 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 agent | 636 | // If a confirm dialog is pending, refuse it so the agent | ||
| 633 | // goroutine unblocks and the context cancellation can | 637 | // 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 | |||
| 1142 | 1142 | return out | |
| 1143 | 1143 | } | |
| 1144 | 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 | 1146 | return true | |
| 1147 | 1147 | } | |
| 1148 | 1148 | if m.Role != provider.RoleUser || len(m.Content) == 0 { | |
| @@ -1142,7 +1142,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message | |||||
| 1142 | return out | 1142 | 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 true | 1146 | 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) | |||
| 188 | 188 | if prompt != "" || len(action.Images) != 0 { | |
| 189 | 189 | i.submitOrQueueMessage(core.QueuedMessage{Text: prompt, Images: action.Images, HostEvent: len(action.Completions) != 0}, false) | |
| 190 | 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 | 195 | parent := i.runCtx | |
| 192 | 196 | if parent == nil { | |
| 193 | 197 | parent = context.Background() | |
| @@ -235,7 +239,12 @@ func (i *Interactive) releaseCompletionDeliveryHold() { | |||
| 235 | 239 | if i.completionDeliveryHolds == 0 { | |
| 236 | 240 | // The coordinator must observe the goal before sealing the wave, so a | |
| 237 | 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 | 244 | _, goalActive := i.goalContinuationMessage() | |
| 245 | + | if i.interruptedPromptPending() { | |
| 246 | + | goalActive = false | |
| 247 | + | } | |
| 239 | 248 | coordinator := i.ensureCoordinatorLocked() | |
| 240 | 249 | coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive}) | |
| 241 | 250 | 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.runCtx | 195 | 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 a | 240 | // 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}).Actions | 250 | 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 | |||
| 194 | 194 | if persistResetHandoff { | |
| 195 | 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 | 205 | ctx, cancel := context.WithCancel(parent) | |
| 199 | 206 | i.mu.Lock() | |
| @@ -336,8 +343,20 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 336 | 343 | if terminalGoalError { | |
| 337 | 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 | 356 | 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 | 360 | i.mu.Lock() | |
| 342 | 361 | awaitingPre := i.awaitingStartupPre | |
| 343 | 362 | // 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 | 430 | if recoverContextOverflow || shouldAutoCompact { | |
| 412 | 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 | 455 | i.planCurrent = 0 | |
| 416 | 456 | 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 | |
| 418 | 461 | i.mu.Unlock() | |
| 419 | 462 | if persistHandoff { | |
| 420 | 463 | i.persistCompactHandoff(handoff) | |
| @@ -457,6 +500,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 457 | 500 | }) | |
| 458 | 501 | case continueGoal: | |
| 459 | 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 | } | |
| @@ -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.StopLength | 356 | 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.awaitingStartupPre | 361 | awaitingPre := i.awaitingStartupPre | ||
| 343 | // A newer explicit prompt may have cleared the handoff while the | 362 | // 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 = 0 | 455 | i.planCurrent = 0 | ||
| 416 | i.planTotal = 0 | 456 | 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 | |||
| 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 { | |||
| 42 | 42 | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | |
| 43 | 43 | ||
| 44 | 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 | 48 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 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 | 27 | func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) { | |
| 12 | 28 | tool := &UpdateGoalTool{} | |
| 13 | 29 | 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"}) | ||