fix(session): auto-reply awaited IRC sends to an idle plan-mode session

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.
This commit is contained in:
can1357
2026-07-02 09:42:02 +02:00
parent 71209f025d
commit eeeab97db0
2 changed files with 47 additions and 7 deletions
+5 -4
View File
@@ -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";
@@ -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<PlanHarness> {
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