From eeeab97db0ef1296dd80d97fc606a8d14ef87ac3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 2 Jul 2026 09:42:02 +0200 Subject: [PATCH] fix(session): auto-reply awaited IRC sends to an idle plan-mode session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan mode records idle IRC deliveries into context without waking a turn, so an 'irc send await:true' sender was stranded until its wait timeout: delivery reported injected but no reply could ever be generated. Extend the existing ephemeral side-channel auto-reply (previously only for mid-turn recipients with async execution disabled) to the idle plan-mode case — the second situation where a real reply turn cannot happen in time. No primary turn is woken; plan-mode convergence stays user-driven. The deliverIrcMessage eligibility change itself rode in the previous commit (same-file hunks); this commit carries the bus doc and the regression test: an awaited idle IRC message in plan mode resolves the sender's bus waiter via the side-channel reply while the primary loop stays asleep. --- packages/coding-agent/src/irc/bus.ts | 9 ++-- ...gent-session-plan-mode-convergence.test.ts | 45 +++++++++++++++++-- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/irc/bus.ts b/packages/coding-agent/src/irc/bus.ts index c5edcc977..62ea60f66 100644 --- a/packages/coding-agent/src/irc/bus.ts +++ b/packages/coding-agent/src/irc/bus.ts @@ -8,10 +8,11 @@ * agents receive the message as a non-interrupting aside at the next step * boundary (see AgentSession.deliverIrcMessage). Replies are real turns by * the recipient, observed via `wait` — with one exception: when the sender - * awaits a reply and the recipient is mid-turn with async execution - * disabled, the recipient session generates an ephemeral side-channel - * auto-reply (it may be blocked in a synchronous task spawn whose batch - * includes the sender, so a real turn could never happen in time). + * awaits a reply and the recipient cannot run a real reply turn in time + * (mid-turn with async execution disabled — possibly blocked in a + * synchronous task spawn whose batch includes the sender — or idle in plan + * mode, where autonomous wake turns are suppressed), the recipient session + * generates an ephemeral side-channel auto-reply. */ import { logger, Snowflake } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts b/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts index 540236e79..cb6ceaec5 100644 --- a/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts +++ b/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts @@ -16,7 +16,8 @@ import { createMockModel, type MockModel, type MockResponse } from "@oh-my-pi/pi import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import type { IrcMessage } from "@oh-my-pi/pi-coding-agent/irc/bus"; +import { IrcBus, type IrcMessage } from "@oh-my-pi/pi-coding-agent/irc/bus"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -68,6 +69,7 @@ interface PlanHarness { session: AgentSession; mock: MockModel; advisorMock?: MockModel; + sideMock?: MockModel; } describe("AgentSession plan-mode convergence", () => { @@ -92,7 +94,7 @@ describe("AgentSession plan-mode convergence", () => { async function createPlanSession( responses: MockResponse[], - options?: { advisorResponses?: MockResponse[] }, + options?: { advisorResponses?: MockResponse[]; sideResponses?: MockResponse[] }, ): Promise { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected bundled anthropic model to exist"); @@ -122,6 +124,13 @@ describe("AgentSession plan-mode convergence", () => { advisorStreamFn = advisorMock.stream; } + let sideMock: MockModel | undefined; + let sideStreamFn: StreamFn | undefined; + if (options?.sideResponses) { + sideMock = createMockModel({ responses: options.sideResponses }); + sideStreamFn = sideMock.stream; + } + const created = new AgentSession({ agent, sessionManager: SessionManager.inMemory(), @@ -138,10 +147,11 @@ describe("AgentSession plan-mode convergence", () => { builtInToolNames: ["ask", "resolve", "read"], advisorTools: [], advisorStreamFn, + sideStreamFn, }); created.setPlanModeState({ enabled: true, planFilePath: "local://PLAN.md" }); session = created; - return { session: created, mock, advisorMock }; + return { session: created, mock, advisorMock, sideMock }; } it("T1: an advisor concern does not wake the primary in plan mode", async () => { @@ -186,6 +196,35 @@ describe("AgentSession plan-mode convergence", () => { expect(harness.mock.calls.length).toBe(0); }); + it("T2b: an awaited idle IRC message gets a side-channel auto-reply without waking a turn", async () => { + const harness = await createPlanSession([], { + sideResponses: [{ content: ["still planning — full reply once the plan settles"] }], + }); + const registry = AgentRegistry.global(); + registry.register({ id: "peer", displayName: "peer", kind: "sub", session: null, status: "running" }); + try { + const bus = IrcBus.global(); + // Park the sender's reply waiter first (timeout 0 = no wall-clock timer; + // a broken auto-reply path fails via the test runner's own timeout). + const replyPromise = bus.wait("peer", { from: "me" }, 0); + const msg: IrcMessage = { id: "m2", from: "peer", to: "me", body: "blocked on you — status?", ts: Date.now() }; + + const outcome = await harness.session.deliverIrcMessage(msg, { expectsReply: true }); + expect(outcome).toBe("injected"); + + // The ephemeral side-channel turn answered the sender for real. + const reply = await replyPromise; + expect(reply?.replyTo).toBe("m2"); + expect(reply?.body).toContain("still planning"); + expect(harness.sideMock?.calls.length).toBe(1); + // The primary loop never woke: no model call, no assistant turn. + expect(harness.mock.calls.length).toBe(0); + expect(harness.session.agent.state.messages.some(m => m.role === "assistant")).toBe(false); + } finally { + registry.unregister("peer"); + } + }); + it("T3a: convergence reminders are bounded by the cap, then yield to the user", async () => { // Alternating text-stop / read cascade: each text stop with awaiting=false // escalates a reminder; each read clears awaiting (not the count). The 7th