diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 81d4a6eb1..1c699f78f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,7 +17,7 @@ - Retried assistant turns that stop with reasoning/thinking only and no final text or tool call, so Gemini/Antigravity thought-only `STOP` responses continue instead of silently ending the session. - Fixed `~/.agent[s]/skills` not appearing as `/skill:` commands when every named source toggle (`skills.enableCodexUser`, `skills.enableClaudeUser`, `skills.enableClaudeProject`, `skills.enablePiUser`, `skills.enablePiProject`) was off: `loadSkills` gated the `agents` provider on `anyBuiltInSkillSourceEnabled`, so a user who turned off the Claude/Codex/Pi sources to clean noise also lost their own canonical OMP-native skills. The `agents` provider now reads the dedicated `enableAgentsUser`/`enableAgentsProject` toggles, decoupled from the third-party fall-through ([#2401](https://github.com/can1357/oh-my-pi/issues/2401)). - Fixed Windows PowerShell image paste so Ctrl+V can fall back to the PowerShell clipboard bridge when the native clipboard reader reports no image ([#2429](https://github.com/can1357/oh-my-pi/issues/2429)). -- Fixed `/goal ` and `/goal set ` during streaming so goal context is steered immediately but objective submission waits for the active turn to finish instead of spamming `AgentBusyError` ([#2454](https://github.com/can1357/oh-my-pi/issues/2454)). +- Fixed `/goal ` and `/goal set ` during streaming so goal context is steered immediately but objective submission waits for the active turn to finish instead of spamming `AgentBusyError`. The interactive goal-continuation timer is now streaming-aware too: if a turn starts inside the 800 ms idle window the timer was scheduled in, it drops the tick instead of submitting a stale `goal-continuation` that would resurface the same `AgentBusyError`; the next `agent_end` reschedules ([#2454](https://github.com/can1357/oh-my-pi/issues/2454)). ### Fixed diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 40908b285..de0e8c01b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -905,6 +905,14 @@ export class InteractiveMode implements InteractiveModeContext { this.#goalContinuationTimer = undefined; if (!this.onInputCallback) return; if (!this.goalModeEnabled || this.goalModePaused) return; + // The 800ms timer can outlive the idle window that scheduled it: a + // `/goal set` taken via the streaming branch (or any extension/hook + // path that starts a turn while we wait) leaves the agent busy. Firing + // the continuation now would route through `submitInteractiveInput` → + // `promptCustomMessage` with no `streamingBehavior` and resurface + // `AgentBusyError`. Drop this tick; `#handleGoalSessionEvent` reschedules + // on the next `agent_end`. + if (this.#isAutoSubmitBlocked()) return; if (this.#pendingSubmittedInput) return; if (this.editor.getText().trim().length > 0) return; if ((this.pendingImages?.length ?? 0) > 0) return; @@ -928,7 +936,7 @@ export class InteractiveMode implements InteractiveModeContext { } } - #isLoopAutoSubmitBlocked(): boolean { + #isAutoSubmitBlocked(): boolean { return this.session.isStreaming || this.session.isCompacting || this.session.hasPostPromptWork; } @@ -938,7 +946,7 @@ export class InteractiveMode implements InteractiveModeContext { this.disableLoopMode("Loop time limit reached. Loop mode disabled."); return; } - if (this.#isLoopAutoSubmitBlocked()) { + if (this.#isAutoSubmitBlocked()) { this.#deferLoopAutoSubmit(() => this.#submitLoopPromptWhenReady(prompt)); return; } @@ -947,7 +955,7 @@ export class InteractiveMode implements InteractiveModeContext { async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise { if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return; - if (this.#isLoopAutoSubmitBlocked()) { + if (this.#isAutoSubmitBlocked()) { this.#deferLoopAutoSubmit(() => { void this.#runLoopIteration(action, prompt); }); diff --git a/packages/coding-agent/test/goals/goal-mode-integration.test.ts b/packages/coding-agent/test/goals/goal-mode-integration.test.ts index e14a5d996..fffb697b8 100644 --- a/packages/coding-agent/test/goals/goal-mode-integration.test.ts +++ b/packages/coding-agent/test/goals/goal-mode-integration.test.ts @@ -225,6 +225,30 @@ describe("InteractiveMode goal mode integration", () => { await waiter.inputPromise; }); + it("drops a goal continuation tick while the agent is streaming", async () => { + // Repro for the race the streaming guard on /goal set X exposed: the + // 800ms continuation timer armed by getUserInput() can outlive the idle + // window when streaming starts between schedule and fire (e.g. /goal set + // taking the streaming branch, or any extension that triggers a turn). + // Without the streaming-aware guard the timer fires onInputCallback + // with a `goal-continuation` and submitInteractiveInput resurfaces + // AgentBusyError via promptCustomMessage. + await harness.mode.handleGoalModeCommand("Ship the release"); + const waiter = await armInputWaiter(harness.mode); + + let streaming = true; + Object.defineProperty(harness.session, "isStreaming", { configurable: true, get: () => streaming }); + + // Let the 800ms timer fire while streaming is true. + await Bun.sleep(900); + + expect(waiter.getResolvedText()).toBeUndefined(); + + streaming = false; + harness.mode.onInputCallback?.(harness.mode.startPendingSubmission({ text: "cleanup" })); + await waiter.inputPromise; + }); + it("refuses /goal while plan mode is active", async () => { const showWarning = vi.spyOn(harness.mode, "showWarning"); harness.mode.planModeEnabled = true;