diff --git a/apps/pi-extension/current-pi-session.test.ts b/apps/pi-extension/current-pi-session.test.ts index afccd0a2b..9ffc3430b 100644 --- a/apps/pi-extension/current-pi-session.test.ts +++ b/apps/pi-extension/current-pi-session.test.ts @@ -5,6 +5,7 @@ import { notifyCurrentPiSession, registerCurrentPiSession, sendUserMessageToCurrentPiSession, + resolveIdleDeliveryOptions, type CurrentPiSessionRegistration, } from "./current-pi-session.ts"; @@ -14,7 +15,7 @@ afterEach(() => { for (const registration of registrations.splice(0)) registration.clear(); }); -function createContext(sessionId: string, notifications: string[]): ExtensionContext { +function createContext(sessionId: string, notifications: string[], idle = true): ExtensionContext { return { cwd: "/tmp", mode: "tui", @@ -24,6 +25,7 @@ function createContext(sessionId: string, notifications: string[]): ExtensionCon getSessionName: () => sessionId, }, ui: { notify: (message: string) => notifications.push(message) }, + isIdle: () => idle, } as unknown as ExtensionContext; } @@ -68,3 +70,143 @@ describe("current Pi session feedback routing", () => { expect(replacementNotifications).toEqual(["feedback delivered"]); }); }); + +describe("resolveIdleDeliveryOptions", () => { + test("idle host drops deliverAs so the host starts a turn", () => { + expect( + resolveIdleDeliveryOptions({ isIdle: () => true }, { deliverAs: "followUp" }), + ).toBeUndefined(); + }); + + test("streaming host keeps deliverAs", () => { + expect( + resolveIdleDeliveryOptions({ isIdle: () => false }, { deliverAs: "followUp" }), + ).toEqual({ deliverAs: "followUp" }); + }); + + test("idle host keeps sibling options and drops only deliverAs", () => { + expect( + resolveIdleDeliveryOptions( + { isIdle: () => true }, + { deliverAs: "followUp", expandPromptTemplates: true }, + ), + ).toEqual({ expandPromptTemplates: true }); + }); + + test("host without isIdle passes options through unchanged", () => { + expect(resolveIdleDeliveryOptions({}, { deliverAs: "followUp" })).toEqual({ + deliverAs: "followUp", + }); + }); + + test("probe that throws passes options through unchanged", () => { + expect( + resolveIdleDeliveryOptions( + { + isIdle: () => { + throw new Error("probe exploded"); + }, + }, + { deliverAs: "followUp" }, + ), + ).toEqual({ deliverAs: "followUp" }); + }); + + test("undefined options stay undefined", () => { + expect(resolveIdleDeliveryOptions({ isIdle: () => true })).toBeUndefined(); + }); + + test("options without deliverAs are untouched even when idle", () => { + expect( + resolveIdleDeliveryOptions({ isIdle: () => true }, { expandPromptTemplates: true }), + ).toEqual({ expandPromptTemplates: true }); + }); + + test("steer is also dropped when idle", () => { + expect( + resolveIdleDeliveryOptions({ isIdle: () => true }, { deliverAs: "steer" }), + ).toBeUndefined(); + }); +}); + +describe("idle delivery through the current-session send path", () => { + test("idle replacement runtime drops deliverAs so the host starts a turn", () => { + const oldRuntime = registerSessionRuntime("same-session", [], []); + const origin = getPiSessionIdentity(oldRuntime.ctx); + + const captured: Array<{ content: unknown; options: unknown }> = []; + const replacementPi = { + sendUserMessage: (content: unknown, options?: unknown) => { + captured.push({ content, options }); + }, + } as unknown as ExtensionAPI; + const replacement = registerCurrentPiSession(replacementPi); + registrations.push(replacement); + replacement.update(createContext("same-session", [], true)); + oldRuntime.registration.clear(); + + const result = sendUserMessageToCurrentPiSession( + "annotation feedback", + { deliverAs: "followUp" }, + origin, + ); + + expect(result).toEqual({ ok: true }); + expect(captured).toEqual([{ content: "annotation feedback", options: undefined }]); + }); + + test("streaming replacement runtime keeps deliverAs", () => { + const oldRuntime = registerSessionRuntime("same-session", [], []); + const origin = getPiSessionIdentity(oldRuntime.ctx); + + const captured: Array<{ content: unknown; options: unknown }> = []; + const replacementPi = { + sendUserMessage: (content: unknown, options?: unknown) => { + captured.push({ content, options }); + }, + } as unknown as ExtensionAPI; + const replacement = registerCurrentPiSession(replacementPi); + registrations.push(replacement); + replacement.update(createContext("same-session", [], false)); + oldRuntime.registration.clear(); + + const result = sendUserMessageToCurrentPiSession( + "annotation feedback", + { deliverAs: "followUp" }, + origin, + ); + + expect(result).toEqual({ ok: true }); + expect(captured).toEqual([ + { content: "annotation feedback", options: { deliverAs: "followUp" } }, + ]); + }); + + test("isIdle on ExtensionAPI is ignored; the context is the probe", () => { + const oldRuntime = registerSessionRuntime("same-session", [], []); + const origin = getPiSessionIdentity(oldRuntime.ctx); + + const captured: Array<{ content: unknown; options: unknown }> = []; + const lyingPi = { + isIdle: () => true, + sendUserMessage: (content: unknown, options?: unknown) => { + captured.push({ content, options }); + }, + } as unknown as ExtensionAPI; + const replacement = registerCurrentPiSession(lyingPi); + registrations.push(replacement); + replacement.update(createContext("same-session", [], false)); + oldRuntime.registration.clear(); + + const result = sendUserMessageToCurrentPiSession( + "annotation feedback", + { deliverAs: "followUp" }, + origin, + ); + + expect(result).toEqual({ ok: true }); + expect(captured).toEqual([ + { content: "annotation feedback", options: { deliverAs: "followUp" } }, + ]); + }); +}); diff --git a/apps/pi-extension/current-pi-session.ts b/apps/pi-extension/current-pi-session.ts index a548b730b..d5ec14d34 100644 --- a/apps/pi-extension/current-pi-session.ts +++ b/apps/pi-extension/current-pi-session.ts @@ -4,6 +4,25 @@ type SendUserMessageContent = Parameters[0]; type SendUserMessageOptions = Parameters[1]; type NotificationType = "info" | "warning" | "error"; +type IdleProbeHost = { isIdle?: () => boolean }; + +export function resolveIdleDeliveryOptions( + host: IdleProbeHost, + options?: SendUserMessageOptions, +): SendUserMessageOptions | undefined { + if (!options || options.deliverAs === undefined) return options; + const probe = host.isIdle; + if (typeof probe !== "function") return options; + try { + if (!probe.call(host)) return options; + } catch { + return options; + } + const next = { ...options }; + delete next.deliverAs; + return Object.keys(next).length > 0 ? next : undefined; +} + type CurrentPiSession = { token: symbol; sendUserMessage: (content: SendUserMessageContent, options?: SendUserMessageOptions) => void; @@ -96,7 +115,7 @@ function setCurrentPiSession(token: symbol, pi: ExtensionAPI, ctx?: ExtensionCon const current: CurrentPiSession = { token, sendUserMessage: (content, options) => { - pi.sendUserMessage(content, options); + pi.sendUserMessage(content, resolveIdleDeliveryOptions(ctx ?? {}, options)); }, }; if (ctx) { diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 6b36649e7..badafe8f6 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -61,6 +61,7 @@ import { notifyCurrentPiSession, type PiSessionIdentity, registerCurrentPiSession, + resolveIdleDeliveryOptions, sendUserMessageToCurrentPiSession, withCurrentPiSessionFallbackHeader, } from "./current-pi-session.ts"; @@ -268,11 +269,12 @@ function sendUserMessageWithCurrentSessionFallback( options: Parameters[1], errorMessage: string, origin: PiSessionIdentity, + ctx?: ExtensionContext, ): void { if (trySendUserMessageToDifferentCurrentSession(content, options, errorMessage, origin)) return; try { - pi.sendUserMessage(content, options); + pi.sendUserMessage(content, resolveIdleDeliveryOptions(ctx ?? {}, options)); return; } catch (err) { if (trySendUserMessageToDifferentCurrentSession(content, options, errorMessage, origin)) return; @@ -673,6 +675,7 @@ export default function plannotator(pi: ExtensionAPI): void { { deliverAs: "followUp" }, "Plannotator code review feedback could not be sent", origin, + ctx, ); return; } @@ -696,6 +699,7 @@ export default function plannotator(pi: ExtensionAPI): void { { deliverAs: "followUp" }, "Plannotator code review feedback could not be sent", origin, + ctx, ); } catch (err) { reportBackgroundError(ctx, "Plannotator code review feedback could not be sent", err, origin); @@ -994,6 +998,7 @@ export default function plannotator(pi: ExtensionAPI): void { { deliverAs: "followUp" }, "Plannotator annotation feedback could not be sent", origin, + ctx, ); if (outcome.notification === "approved") { safeNotify(ctx, "Annotation approved.", "info", origin); @@ -1088,6 +1093,7 @@ export default function plannotator(pi: ExtensionAPI): void { { deliverAs: "followUp" }, "Plannotator message annotation feedback could not be sent", origin, + ctx, ); if (outcome.notification === "approved") { safeNotify(ctx, "Message approved.", "info", origin);