fix(vibe): deferred IRC wakes during mode exit

This commit is contained in:
can1357
2026-08-16 02:36:26 +02:00
parent 5420b29364
commit c162a82169
3 changed files with 76 additions and 1 deletions
@@ -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();
}
}
}
@@ -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[] = [];
@@ -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<void>();
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<void>();
const releaseTeardown = Promise.withResolvers<void>();
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");