diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index e6a5dc3b0..26363c92e 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -776,7 +776,14 @@ export class AgentSession { * the agent responds to the peer. Skip only when a queued steer/follow-up will itself drive a * resume turn whose aside poll already consumes these (no double-wake). */ #resumeStrandedIrcAsides(): void { - if (this.#isDisposed || this.isStreaming || !this.#irc.hasPending()) return; + if ( + this.#modeExitDrainSuppressionDepth > 0 || + this.#isDisposed || + this.isStreaming || + !this.#irc.hasPending() + ) { + return; + } if (this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages()) return; const records = this.#irc.drainPending(); if (this.#planModeState?.enabled) { @@ -805,6 +812,10 @@ export class AgentSession { * because #canAutoContinueForFollowUp suppresses follow-up auto-resume while a user interrupt is * in effect, even though the wake left a provider-valid tail. */ #wakeForIrc(records: CustomMessage[]): void { + if (this.#modeExitDrainSuppressionDepth > 0) { + this.#irc.deferWake(records); + return; + } // Park only a *blocked* follow-up (one a user interrupt is intentionally holding); an // already-resumable follow-up can ride the wake turn normally without reordering. const parkedFollowUps = @@ -5969,6 +5980,7 @@ export class AgentSession { this.#modeExitDrainSuppressionDepth--; if (this.#modeExitDrainSuppressionDepth === 0) { this.#scheduleIdleQueueDrain(); + this.#resumeStrandedIrcAsides(); } } } diff --git a/packages/coding-agent/src/session/irc-bridge.ts b/packages/coding-agent/src/session/irc-bridge.ts index 831fa2e76..0e04d277d 100644 --- a/packages/coding-agent/src/session/irc-bridge.ts +++ b/packages/coding-agent/src/session/irc-bridge.ts @@ -51,6 +51,11 @@ export class IrcBridge { return records; } + /** Queues records whose idle wake must wait for a session transition to finish. */ + deferWake(records: CustomMessage[]): void { + this.#asides.push(...records); + } + /** Surfaces and consumes queued incoming records before automatic injection. */ drainInboxMessages(agentId: string, opts?: { from?: string; limit?: number }): IrcMessage[] { const messages: IrcMessage[] = []; diff --git a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts index c1620e65c..c95f65119 100644 --- a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts +++ b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts @@ -250,6 +250,64 @@ describe("InteractiveMode vibe mode toggle", () => { expect(session.getToolByName("vibe_spawn")).toBeUndefined(); }); + it("holds IRC wakes during Vibe teardown until the tools are removed", async () => { + const toolNamesPerCall: string[][] = []; + const firstStarted = Promise.withResolvers(); + streamFn = (_model, context, options) => { + toolNamesPerCall.push((context.tools ?? []).map(tool => tool.name)); + const isFirst = toolNamesPerCall.length === 1; + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + if (isFirst) { + options?.signal?.addEventListener( + "abort", + () => stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }), + { once: true }, + ); + firstStarted.resolve(); + } else { + stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Resumed") }); + } + }); + return stream; + }; + + await mode.handleVibeModeCommand(); + const prompt = session.prompt("Delegate this"); + await firstStarted.promise; + await session.deliverIrcMessage({ id: "m1", from: "peer", to: "me", body: "first", ts: Date.now() }); + + const abortSettled = Promise.withResolvers(); + const releaseTeardown = Promise.withResolvers(); + const abort = session.abort.bind(session); + vi.spyOn(session, "abort").mockImplementation(async options => { + await abort(options); + abortSettled.resolve(); + await releaseTeardown.promise; + }); + const exit = mode.handleVibeModeCommand(); + await abortSettled.promise; + await session.deliverIrcMessage({ id: "m2", from: "peer", to: "me", body: "second", ts: Date.now() }); + for (let index = 0; index < 5; index++) await Promise.resolve(); + expect(toolNamesPerCall).toHaveLength(1); + releaseTeardown.resolve(); + + await exit; + await prompt; + await session.waitForIdle(); + + expect(toolNamesPerCall).toHaveLength(2); + for (const name of VIBE_TOOL_NAMES) { + expect(toolNamesPerCall[1]).not.toContain(name); + } + expect( + session.agent.state.messages.filter( + message => message.role === "custom" && message.customType === "irc:incoming", + ), + ).toHaveLength(2); + }); + it("keeps a same-named non-built-in Todo tool unavailable in Vibe mode", async () => { const model = session.model; if (!model) throw new Error("Expected active model");