fix(coding-agent): drop stale goal continuation tick while streaming

Reviewer flagged a race left open by the streaming guard added in #2455:
getUserInput() arms onInputCallback and schedules an 800 ms goal
continuation timer; when /goal set takes the streaming branch (or any
extension/hook starts a turn inside that window), the timer fired
unchecked. The downstream onInputCallback resolved the main waiter with
a goal-continuation submission, submitInteractiveInput called
session.promptCustomMessage without a streamingBehavior, and the same
AgentBusyError the PR set out to fix resurfaced.

Make the continuation timer streaming-aware: at fire time, bail out
when session.isStreaming || isCompacting || hasPostPromptWork is true.
Reuses the auto-submit busy check loop mode already relies on (renamed
#isLoopAutoSubmitBlocked -> #isAutoSubmitBlocked since both flows have
the same notion of 'agent is busy, do not submit'). The next agent_end
in #handleGoalSessionEvent reschedules normally.

Fixes #2454
This commit is contained in:
roboomp
2026-06-14 07:22:42 +00:00
parent 34a5362adf
commit 207caa3621
3 changed files with 36 additions and 4 deletions
@@ -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;