From d31c40db0be3c5f8aae8cebe844cbef940709c50 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 00:49:54 +0200 Subject: [PATCH] feat(sessions): wire extension dialogs into the daemon with answer/cancel routes ctx.ui.confirm()/select()/input() from extensions now open daemon-owned pending dialog records, publish dialog.opened/dialog.closed, and park a Promise that settles on the browser's answer or cancel, the extension's own signal/timeout, the extensionDialogsTimeoutMs daemon default (5 min, 0 = forever; tuning knob, not a gate), agent_end for run-scoped dialogs, or session-ended on close/replace/dispose. Answers resolve the parked extension Promise directly via POST /sessions/:id/dialogs/answer|cancel - never the prompt queue - with first-wins stale semantics across browsers, and SessionStatus.pendingDialogs rehydrates reloading clients. --- src/config.test.ts | 30 +- src/config.ts | 21 +- src/server/sessiond.ts | 1 + .../sessionServiceDependencies.test.ts | 5 + .../sessiond/sessionServiceDependencies.ts | 3 + .../sessions/extensionDialogWaiters.test.ts | 151 +++++++ src/server/sessions/extensionDialogWaiters.ts | 103 +++++ .../piSessionService.extensionDialogs.test.ts | 423 ++++++++++++++++++ .../sessions/piSessionService.testSupport.ts | 2 +- src/server/sessions/piSessionService.ts | 182 +++++++- src/server/sessions/sessionRoutes.test.ts | 110 ++++- src/server/sessions/sessionRoutes.ts | 42 +- src/server/sessions/sessionService.ts | 4 + src/shared/apiTypes.ts | 39 ++ 14 files changed, 1107 insertions(+), 9 deletions(-) create mode 100644 src/server/sessions/extensionDialogWaiters.test.ts create mode 100644 src/server/sessions/extensionDialogWaiters.ts create mode 100644 src/server/sessions/piSessionService.extensionDialogs.test.ts diff --git a/src/config.test.ts b/src/config.test.ts index c212f20..4e51853 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, askUserEnabled, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, offlineModeEnabled, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; +import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS, DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, askUserEnabled, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, offlineModeEnabled, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -63,6 +63,22 @@ describe("PI WEB config persistence", () => { expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234); }); + it("keeps a hand-edited extensionDialogsTimeoutMs across settings saves", async () => { + await writeFile(configPath, `${JSON.stringify({ extensionDialogsTimeoutMs: 60_000 }, null, 2)}\n`, "utf8"); + + savePiWebConfig({ port: 9000 }, testOptions()); + + expect(loadPiWebConfig(testOptions()).config.extensionDialogsTimeoutMs).toBe(60_000); + }); + + it("rejects an invalid extensionDialogsTimeoutMs", async () => { + for (const value of [-1, 1.5, "5000", null]) { + await writeFile(configPath, `${JSON.stringify({ extensionDialogsTimeoutMs: value }, null, 2)}\n`, "utf8"); + + expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config extensionDialogsTimeoutMs must be a non-negative integer"); + } + }); + it("persists and reads custom agent runtime settings", () => { savePiWebConfig({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, testOptions()); @@ -223,6 +239,18 @@ describe("maxUploadBytes", () => { }); }); +describe("extensionDialogsTimeoutMs", () => { + it("defaults to five minutes when nothing is configured", () => { + expect(effectivePiWebConfig(testOptions()).config.extensionDialogsTimeoutMs).toBe(DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS); + }); + + it("resolves a configured value, including zero for waiting forever", async () => { + await writeFile(configPath, `${JSON.stringify({ extensionDialogsTimeoutMs: 0 }, null, 2)}\n`, "utf8"); + + expect(effectivePiWebConfig(testOptions()).config.extensionDialogsTimeoutMs).toBe(0); + }); +}); + describe("spawnSessionsEnabled", () => { it("is on by default when nothing is configured", () => { expect(spawnSessionsEnabled({}, {})).toBe(true); diff --git a/src/config.ts b/src/config.ts index c8a8e2a..58da5cd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,11 +15,12 @@ export interface LoadedPiWebConfig { config: PiWebConfig; } -export interface EffectivePiWebConfig extends Omit { +export interface EffectivePiWebConfig extends Omit { uploads: NonNullable; spawnSessions: boolean; subsessions: boolean; askUser: boolean; + extensionDialogsTimeoutMs: number; agent: Required>; } @@ -50,6 +51,14 @@ export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads"; +/** + * Default auto-cancel delay for extension dialogs whose extension set no + * `timeout` of its own: five minutes. `extensionDialogsTimeoutMs: 0` waits + * forever. Tunes the unattended-dialog safety valve only; dialogs are always + * enabled. + */ +export const DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS = 300_000; + export const DEFAULT_AGENT_COMMAND = "pi"; export const PI_WEB_AGENT_COMMAND_ENV = "PI_WEB_AGENT_COMMAND"; export const PI_WEB_AGENT_DIR_ENV = "PI_WEB_AGENT_DIR"; @@ -159,6 +168,8 @@ export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: subsessions: subsessionsEnabled(env, loaded.config), // Always resolved (on by default); the user is present for every ask. askUser: askUserEnabled(env, loaded.config), + // Always resolved; the unattended-dialog safety valve, not a gate. + extensionDialogsTimeoutMs: loaded.config.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS, agent: { command: agent.command, dir: agent.dir }, }, }; @@ -226,6 +237,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), ...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}), ...(value["askUser"] !== undefined ? { askUser: parseAskUser(value["askUser"], path) } : {}), + ...(value["extensionDialogsTimeoutMs"] !== undefined ? { extensionDialogsTimeoutMs: parseExtensionDialogsTimeoutMs(value["extensionDialogsTimeoutMs"], path) } : {}), ...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}), }; } @@ -277,6 +289,13 @@ function parseAskUser(value: unknown, path: string): boolean { return value; } +function parseExtensionDialogsTimeoutMs(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + throw new Error(`PI WEB config extensionDialogsTimeoutMs must be a non-negative integer: ${path}`); + } + return value; +} + /** * Whether LLMs may post a question set to the browser via the ask_user tool. On * by default: the questions land in the session the user is already watching and diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 456c3f7..70d811e 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -87,6 +87,7 @@ async function createSessionDaemonRuntime() { projectWorkspaces, subsessionsEnabled: config.subsessions, askUserEnabled: config.askUser, + extensionDialogsTimeoutMs: config.extensionDialogsTimeoutMs, notificationStore, unreadStore, catalogRefreshStatus: catalogRefresher, diff --git a/src/server/sessiond/sessionServiceDependencies.test.ts b/src/server/sessiond/sessionServiceDependencies.test.ts index da68798..3c9d170 100644 --- a/src/server/sessiond/sessionServiceDependencies.test.ts +++ b/src/server/sessiond/sessionServiceDependencies.test.ts @@ -25,6 +25,7 @@ function daemonCollaborators(patch: Partial = {}) catalogRefreshStatus: { isRefreshInFlight: () => false }, subsessionsEnabled: false, askUserEnabled: true, + extensionDialogsTimeoutMs: 300_000, ...patch, }; } @@ -93,4 +94,8 @@ describe("sessiond session service dependency assembly", () => { expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: true })).askUserEnabled).toBe(true); expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: false })).askUserEnabled).toBe(false); }); + + it("passes the extension-dialog timeout through to the session service", () => { + expect(sessionServiceDependencies(daemonCollaborators({ extensionDialogsTimeoutMs: 60_000 })).extensionDialogsTimeoutMs).toBe(60_000); + }); }); diff --git a/src/server/sessiond/sessionServiceDependencies.ts b/src/server/sessiond/sessionServiceDependencies.ts index b73b883..893bdb1 100644 --- a/src/server/sessiond/sessionServiceDependencies.ts +++ b/src/server/sessiond/sessionServiceDependencies.ts @@ -24,6 +24,8 @@ export interface SessionServiceDependencyInput { subsessionsEnabled: boolean; /** Whether agents may post structured question sets to the browser. */ askUserEnabled: boolean; + /** Auto-cancel delay for extension dialogs whose extension set no timeout; `0` waits forever. */ + extensionDialogsTimeoutMs: number; } /** @@ -48,6 +50,7 @@ export function sessionServiceDependencies(input: SessionServiceDependencyInput) // so they stay off unless spawning is configured too. subsessionsEnabled: input.spawnTargets !== undefined && input.subsessionsEnabled, askUserEnabled: input.askUserEnabled, + extensionDialogsTimeoutMs: input.extensionDialogsTimeoutMs, notificationStore: input.notificationStore, unreadStore: input.unreadStore, // Read-only, so session startup can tell a waiting user that provider diff --git a/src/server/sessions/extensionDialogWaiters.test.ts b/src/server/sessions/extensionDialogWaiters.test.ts new file mode 100644 index 0000000..2a60ed8 --- /dev/null +++ b/src/server/sessions/extensionDialogWaiters.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PendingExtensionDialog } from "../../shared/apiTypes.js"; +import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js"; + +function dialog(patch: Partial = {}): PendingExtensionDialog { + return { + dialogId: "dialog-1", + kind: "confirm", + title: "Continue?", + askedAt: "2026-02-01T10:00:00.000Z", + runScoped: false, + ...patch, + }; +} + +/** Observe a parked wait without hanging the test when it never settles. */ +async function settledValue(promise: Promise): Promise<{ settled: true; value: boolean | string | undefined } | { settled: false }> { + return await Promise.race([ + promise.then((value) => ({ settled: true as const, value })), + Promise.resolve({ settled: false as const }), + ]); +} + +describe("extensionDialogCancelValue", () => { + it("matches the SDK cancel value of each dialog kind", () => { + expect(extensionDialogCancelValue("confirm")).toBe(false); + expect(extensionDialogCancelValue("select")).toBeUndefined(); + expect(extensionDialogCancelValue("input")).toBeUndefined(); + }); +}); + +describe("effectiveExtensionDialogTimeoutMs", () => { + it("picks the sooner of the extension timeout and the daemon default", () => { + expect(effectiveExtensionDialogTimeoutMs(1_000, 300_000)).toBe(1_000); + expect(effectiveExtensionDialogTimeoutMs(600_000, 300_000)).toBe(300_000); + }); + + it("treats an absent or unusable extension timeout as the daemon default alone", () => { + expect(effectiveExtensionDialogTimeoutMs(undefined, 300_000)).toBe(300_000); + expect(effectiveExtensionDialogTimeoutMs(Number.NaN, 300_000)).toBe(300_000); + expect(effectiveExtensionDialogTimeoutMs(-5, 300_000)).toBe(300_000); + }); + + it("treats a zero daemon default as waiting forever", () => { + expect(effectiveExtensionDialogTimeoutMs(undefined, 0)).toBeUndefined(); + expect(effectiveExtensionDialogTimeoutMs(1_000, 0)).toBe(1_000); + }); +}); + +describe("ExtensionDialogWaiters", () => { + it("resolves a settled wait with the user's answer", async () => { + const waiters = new ExtensionDialogWaiters(); + const parked = waiters.park(dialog()); + + expect(waiters.settleWithAnswer("dialog-1", true)).toBe(true); + + await expect(parked).resolves.toBe(true); + }); + + it("resolves a close without an answer with the dialog kind's cancel value", async () => { + const waiters = new ExtensionDialogWaiters(); + const confirm = waiters.park(dialog({ dialogId: "dialog-1", kind: "confirm" })); + const select = waiters.park(dialog({ dialogId: "dialog-2", kind: "select", options: ["a"] })); + + waiters.settleWithCancelValue("dialog-1"); + waiters.settleWithCancelValue("dialog-2"); + + await expect(confirm).resolves.toBe(false); + await expect(select).resolves.toBeUndefined(); + }); + + it("reports an unknown dialog instead of settling anything", () => { + const waiters = new ExtensionDialogWaiters(); + + expect(waiters.settleWithAnswer("nobody", true)).toBe(false); + expect(waiters.settleWithCancelValue("nobody")).toBe(false); + }); + + it("fires the cancel trigger when the extension aborts its signal", () => { + const waiters = new ExtensionDialogWaiters(); + const controller = new AbortController(); + const triggers: string[] = []; + void waiters.park(dialog(), { signal: controller.signal, onTrigger: (reason) => { triggers.push(reason); } }); + + controller.abort(); + + expect(triggers).toEqual(["cancelled"]); + }); + + it("unsubscribes the signal on settle, so a later abort cannot trigger a settled wait", () => { + const waiters = new ExtensionDialogWaiters(); + const controller = new AbortController(); + const triggers: string[] = []; + void waiters.park(dialog(), { signal: controller.signal, onTrigger: (reason) => { triggers.push(reason); } }); + + waiters.settleWithCancelValue("dialog-1"); + controller.abort(); + + expect(triggers).toEqual([]); + }); + + it("settles each parked wait exactly once even when settle is repeated", async () => { + const waiters = new ExtensionDialogWaiters(); + const parked = waiters.park(dialog()); + + expect(waiters.settleWithAnswer("dialog-1", true)).toBe(true); + expect(waiters.settleWithAnswer("dialog-1", false)).toBe(false); + + await expect(parked).resolves.toBe(true); + }); +}); + +describe("ExtensionDialogWaiters timeout", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires the timeout trigger when the armed delay elapses", async () => { + vi.useFakeTimers(); + const waiters = new ExtensionDialogWaiters(); + const triggers: string[] = []; + const parked = waiters.park(dialog(), { timeoutMs: 5_000, onTrigger: (reason) => { triggers.push(reason); } }); + + vi.advanceTimersByTime(5_000); + + expect(triggers).toEqual(["timeout"]); + await expect(settledValue(parked)).resolves.toEqual({ settled: false }); + }); + + it("arms no timer when the dialog waits forever", () => { + vi.useFakeTimers(); + const waiters = new ExtensionDialogWaiters(); + + void waiters.park(dialog()); + + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels the timer when the wait settles, so the timeout cannot fire afterwards", async () => { + vi.useFakeTimers(); + const waiters = new ExtensionDialogWaiters(); + const triggers: string[] = []; + const parked = waiters.park(dialog(), { timeoutMs: 5_000, onTrigger: (reason) => { triggers.push(reason); } }); + + waiters.settleWithAnswer("dialog-1", true); + vi.advanceTimersByTime(10_000); + + expect(triggers).toEqual([]); + await expect(parked).resolves.toBe(true); + }); +}); diff --git a/src/server/sessions/extensionDialogWaiters.ts b/src/server/sessions/extensionDialogWaiters.ts new file mode 100644 index 0000000..e7015ec --- /dev/null +++ b/src/server/sessions/extensionDialogWaiters.ts @@ -0,0 +1,103 @@ +import type { ExtensionDialogAnswer, ExtensionDialogKind, PendingExtensionDialog } from "../../shared/apiTypes.js"; + +/** Why a parked wait ended without the browser: its timer elapsed, or the extension aborted its own signal. */ +export type ExtensionDialogWaiterTrigger = "timeout" | "cancelled"; + +export interface ExtensionDialogWaiterTriggers { + /** Effective auto-cancel delay; omitted (or resolved away) means the dialog waits forever. */ + timeoutMs?: number | undefined; + /** The extension's own abort signal, subscribed once and unsubscribed on settle. */ + signal?: AbortSignal | undefined; + /** + * Fired exactly once when the wait ends without a browser close. The caller + * owns closing the store record and settling the waiter; the waiters only + * guarantee the trigger cannot fire after a settle. Omit when nothing but + * the browser can end the wait. + */ + onTrigger?: ((reason: ExtensionDialogWaiterTrigger) => void) | undefined; +} + +interface ParkedExtensionDialog { + /** The value the extension's Promise resolves with when the dialog closes without an answer. */ + cancelValue: boolean | undefined; + resolve: (value: boolean | string | undefined) => void; + cancelArmedTimeout?: (() => void) | undefined; + removeSignalListener?: (() => void) | undefined; +} + +/** The value an extension's dialog Promise settles with on any close without an answer. */ +export function extensionDialogCancelValue(kind: ExtensionDialogKind): boolean | undefined { + return kind === "confirm" ? false : undefined; +} + +/** + * The auto-cancel delay of one dialog: the sooner of the extension's own + * `timeout` and the daemon's `extensionDialogsTimeoutMs` default, where `0` + * (or an invalid extension value, defensively ignored) means "waits forever". + */ +export function effectiveExtensionDialogTimeoutMs(extensionTimeoutMs: number | undefined, daemonDefaultMs: number): number | undefined { + const fromExtension = typeof extensionTimeoutMs === "number" && Number.isFinite(extensionTimeoutMs) && extensionTimeoutMs > 0 ? extensionTimeoutMs : undefined; + const fromDaemon = daemonDefaultMs > 0 ? daemonDefaultMs : undefined; + if (fromExtension === undefined) return fromDaemon; + if (fromDaemon === undefined) return fromExtension; + return Math.min(fromExtension, fromDaemon); +} + +/** + * The parked Promise resolvers behind open extension dialogs, plus the timers + * and signal subscriptions that can end a wait without the browser. Timers use + * the global `setTimeout`/`clearTimeout` looked up per call, the same seam the + * rest of the service's timer tests fake. + * + * Kept deliberately separate from {@link PendingExtensionDialogStore}: the + * store owns the domain state every browser sees, the waiters own the one + * in-memory resolver each open dialog parks inside extension code — state no + * browser ever observes and that must not survive the runtime. The pairing is + * the wiring's invariant: every open store record has exactly one parked + * waiter, and whoever closes the record settles the waiter exactly once. + */ +export class ExtensionDialogWaiters { + private readonly parked = new Map(); + + /** + * Park the extension-facing Promise for a dialog the store just opened. Arms + * the timeout and signal triggers; both are disarmed when the wait settles, + * so a settled wait can never be triggered (nor trigger twice). + */ + park(dialog: PendingExtensionDialog, triggers: ExtensionDialogWaiterTriggers = {}): Promise { + return new Promise((resolve) => { + const parked: ParkedExtensionDialog = { cancelValue: extensionDialogCancelValue(dialog.kind), resolve }; + if (triggers.timeoutMs !== undefined) { + const handle = setTimeout(() => { triggers.onTrigger?.("timeout"); }, triggers.timeoutMs); + parked.cancelArmedTimeout = () => { clearTimeout(handle); }; + } + if (triggers.signal !== undefined) { + const signal = triggers.signal; + const onAbort = () => { triggers.onTrigger?.("cancelled"); }; + signal.addEventListener("abort", onAbort, { once: true }); + parked.removeSignalListener = () => { signal.removeEventListener("abort", onAbort); }; + } + this.parked.set(dialog.dialogId, parked); + }); + } + + /** Resolve the parked wait with the user's answer, which the store has already validated and recorded. */ + settleWithAnswer(dialogId: string, answer: ExtensionDialogAnswer): boolean { + return this.settle(dialogId, (parked) => { parked.resolve(answer); }); + } + + /** Resolve the parked wait with the dialog kind's cancel value after a close without an answer. */ + settleWithCancelValue(dialogId: string): boolean { + return this.settle(dialogId, (parked) => { parked.resolve(parked.cancelValue); }); + } + + private settle(dialogId: string, resolveParked: (parked: ParkedExtensionDialog) => void): boolean { + const parked = this.parked.get(dialogId); + if (parked === undefined) return false; + this.parked.delete(dialogId); + parked.cancelArmedTimeout?.(); + parked.removeSignalListener?.(); + resolveParked(parked); + return true; + } +} diff --git a/src/server/sessions/piSessionService.extensionDialogs.test.ts b/src/server/sessions/piSessionService.extensionDialogs.test.ts new file mode 100644 index 0000000..51b91be --- /dev/null +++ b/src/server/sessions/piSessionService.extensionDialogs.test.ts @@ -0,0 +1,423 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; +import type { PendingExtensionDialog, SessionUiEvent } from "../../shared/apiTypes.js"; +import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; +import { PendingExtensionDialogStore, PendingExtensionDialogValidationError } from "./pendingExtensionDialogStore.js"; +import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js"; + +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; +const ACTIVE_SESSION_ID = "session-1"; + +/** + * Service over a clocked store with sequential dialog ids, so dialogs are named + * `dialog-1`, `dialog-2`, … and timestamps are fixed. The daemon default + * timeout is `0` (wait forever) unless a test says otherwise, so parked waits + * arm no real timers. + */ +function dialogService(options: { extensionDialogsTimeoutMs?: number } = {}) { + const store = new PendingExtensionDialogStore({ + now: () => new Date("2026-02-01T10:00:00.000Z"), + createDialogId: (() => { + let next = 0; + return () => { next += 1; return `dialog-${next.toString()}`; }; + })(), + }); + const fake = fakeRuntime(ACTIVE_SESSION_ID); + const events = new CapturingSessionEventHub(); + const service = new PiSessionService(events, { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + sessionManager: sessionGateway([sessionRecord(ACTIVE_SESSION_ID)]), + archiveStore: emptyArchiveStore(), + createAgentRuntime: runtimeCreator(fake.runtime), + pendingExtensionDialogStore: store, + extensionDialogsTimeoutMs: options.extensionDialogsTimeoutMs ?? 0, + heartbeatIntervalMs: 60_000, + }); + return { service, store, events, fake }; +} + +/** Start the session and return the UI context its extensions were bound with. */ +async function boundUiContext(service: PiSessionService, fake: ReturnType): Promise { + await service.status(sessionRef(ACTIVE_SESSION_ID)); + const bindings = fake.calls.bindExtensions.at(-1); + if (bindings?.uiContext === undefined) throw new Error("session extensions were not bound"); + return bindings.uiContext; +} + +function dialogEvents(events: CapturingSessionEventHub): { sessionId: string; event: SessionUiEvent }[] { + return events.sessionEvents.filter(({ event }) => event.type === "dialog.opened" || event.type === "dialog.closed"); +} + +/** Observe a parked wait without hanging the test when it never settles. */ +async function settledValue(promise: Promise): Promise<{ settled: true; value: boolean | string | undefined } | { settled: false }> { + return await Promise.race([ + promise.then((value) => ({ settled: true as const, value })), + Promise.resolve({ settled: false as const }), + ]); +} + +function openDialog(events: CapturingSessionEventHub): PendingExtensionDialog { + const opened = dialogEvents(events).find(({ event }) => event.type === "dialog.opened"); + if (opened?.event.type !== "dialog.opened") throw new Error("no dialog.opened event published"); + return opened.event.dialog; +} + +describe("PiSessionService extension dialog UI context", () => { + it("opens a confirm dialog for the extension and parks its answer", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + + const parked = ui.confirm("Proceed?", "Really proceed?"); + + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([{ + dialogId: "dialog-1", + kind: "confirm", + title: "Proceed?", + message: "Really proceed?", + askedAt: "2026-02-01T10:00:00.000Z", + runScoped: false, + }]); + expect(dialogEvents(events)).toEqual([ + { sessionId: ACTIVE_SESSION_ID, event: { type: "dialog.opened", dialog: openDialog(events) } }, + ]); + await expect(settledValue(parked)).resolves.toEqual({ settled: false }); + await service.dispose(); + }); + + it("marks a dialog opened while a run is in flight as run-scoped", async () => { + const { service, store, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + fake.session.isStreaming = true; + + void ui.confirm("Run consent", "Allow this tool call?"); + + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([expect.objectContaining({ runScoped: true })]); + await service.dispose(); + }); + + it("opens select and input dialogs with their kind-shaped fields", async () => { + const { service, store, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + + void ui.select("Pick a database", ["pg", "sqlite"]); + void ui.input("Branch name?", "feature/…"); + + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([ + expect.objectContaining({ dialogId: "dialog-1", kind: "select", title: "Pick a database", options: ["pg", "sqlite"] }), + expect.objectContaining({ dialogId: "dialog-2", kind: "input", title: "Branch name?", placeholder: "feature/…" }), + ]); + await service.dispose(); + }); + + it("rejects a malformed dialog without opening anything", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + + await expect(ui.select("Pick one", [])).rejects.toThrow(PendingExtensionDialogValidationError); + + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + expect(dialogEvents(events)).toEqual([]); + await service.dispose(); + }); + + it("dismisses a dialog whose signal is already aborted without opening it", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + const controller = new AbortController(); + controller.abort(); + + await expect(ui.confirm("Proceed?", "Really?", { signal: controller.signal })).resolves.toBe(false); + + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + expect(dialogEvents(events)).toEqual([]); + await service.dispose(); + }); + + it("keeps delegating non-dialog UI methods to the base context", async () => { + const { service, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + + await expect(ui.editor("title")).resolves.toBeUndefined(); + await service.dispose(); + }); +}); + +describe("PiSessionService.answerDialog", () => { + it("resolves the extension's parked wait with the user's answer", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + const parked = ui.confirm("Proceed?", "Really?"); + + const response = await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true); + + await expect(parked).resolves.toBe(true); + expect(response.result).toBe("closed"); + expect(response.outcome).toEqual({ + dialogId: "dialog-1", + reason: "answered", + answer: true, + askedAt: "2026-02-01T10:00:00.000Z", + closedAt: "2026-02-01T10:00:00.000Z", + }); + expect(response.sessionStatus.pendingDialogs).toBeUndefined(); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + expect(dialogEvents(events).map(({ event }) => event)).toEqual([ + { type: "dialog.opened", dialog: openDialog(events) }, + { type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: true }, + ]); + await service.dispose(); + }); + + it("routes answers by dialog id when several dialogs are open", async () => { + const { service, store, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + const select = ui.select("Pick a database", ["pg", "sqlite"]); + const input = ui.input("Branch name?"); + + await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-2", "feature/dialogs"); + + await expect(input).resolves.toBe("feature/dialogs"); + await expect(settledValue(select)).resolves.toEqual({ settled: false }); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([expect.objectContaining({ dialogId: "dialog-1" })]); + await service.dispose(); + }); + + it("reports a stale dialog id without settling the parked wait", async () => { + const { service, store, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + const parked = ui.confirm("Proceed?", "Really?"); + + const response = await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-gone", true); + + expect(response.result).toBe("stale"); + expect(response).not.toHaveProperty("outcome"); + expect(response.sessionStatus.pendingDialogs).toEqual([expect.objectContaining({ dialogId: "dialog-1" })]); + await expect(settledValue(parked)).resolves.toEqual({ settled: false }); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toHaveLength(1); + await service.dispose(); + }); + + it("rejects an answer that does not fit the dialog kind and leaves the dialog open", async () => { + const { service, store, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + const parked = ui.confirm("Proceed?", "Really?"); + + await expect(service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", "yes")).rejects.toThrow(PendingExtensionDialogValidationError); + + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toHaveLength(1); + await expect(settledValue(parked)).resolves.toEqual({ settled: false }); + await service.dispose(); + }); +}); + +describe("PiSessionService.cancelDialog", () => { + it("settles a browser cancel with the dialog kind's cancel value", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + const confirm = ui.confirm("Proceed?", "Really?"); + const select = ui.select("Pick a database", ["pg", "sqlite"]); + + const response = await service.cancelDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1"); + await service.cancelDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-2"); + + expect(response.outcome).toMatchObject({ dialogId: "dialog-1", reason: "cancelled" }); + await expect(confirm).resolves.toBe(false); + await expect(select).resolves.toBeUndefined(); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + expect(dialogEvents(events).map(({ event }) => event)).toMatchObject([ + { type: "dialog.opened", dialog: { dialogId: "dialog-1" } }, + { type: "dialog.opened", dialog: { dialogId: "dialog-2" } }, + { type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" }, + { type: "dialog.closed", dialogId: "dialog-2", reason: "cancelled" }, + ]); + await service.dispose(); + }); + + it("reports a stale cancel of a dialog that is already gone", async () => { + const { service, fake } = dialogService(); + await boundUiContext(service, fake); + + const response = await service.cancelDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-gone"); + + expect(response.result).toBe("stale"); + await service.dispose(); + }); +}); + +describe("PiSessionService extension dialog timeout", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("auto-cancels an unanswered dialog when the daemon default timeout elapses", async () => { + vi.useFakeTimers(); + const { service, store, events, fake } = dialogService({ extensionDialogsTimeoutMs: 300_000 }); + const ui = await boundUiContext(service, fake); + const parked = ui.confirm("Proceed?", "Really?"); + + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([ + expect.objectContaining({ timeoutAt: "2026-02-01T10:05:00.000Z" }), + ]); + await vi.advanceTimersByTimeAsync(300_000); + + await expect(parked).resolves.toBe(false); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + expect(dialogEvents(events).map(({ event }) => event)).toEqual([ + { type: "dialog.opened", dialog: openDialog(events) }, + { type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" }, + ]); + await service.dispose(); + }); + + it("honors the extension's own sooner timeout over the daemon default", async () => { + vi.useFakeTimers(); + const { service, store, fake } = dialogService({ extensionDialogsTimeoutMs: 300_000 }); + const ui = await boundUiContext(service, fake); + const parked = ui.input("Branch name?", undefined, { timeout: 1_000 }); + + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([ + expect.objectContaining({ timeoutAt: "2026-02-01T10:00:01.000Z" }), + ]); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(parked).resolves.toBeUndefined(); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + await service.dispose(); + }); + + it("waits forever on a zero daemon default unless the extension set a timeout", async () => { + vi.useFakeTimers(); + const { service, store, fake } = dialogService({ extensionDialogsTimeoutMs: 0 }); + const ui = await boundUiContext(service, fake); + const parked = ui.confirm("Proceed?", "Really?"); + + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toHaveLength(1); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)[0]).not.toHaveProperty("timeoutAt"); + await vi.advanceTimersByTimeAsync(60_000_000); + + await expect(settledValue(parked)).resolves.toEqual({ settled: false }); + await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true); + await expect(parked).resolves.toBe(true); + await service.dispose(); + }); +}); + +describe("PiSessionService extension dialog signal", () => { + it("dismisses the dialog with the cancel value when the extension aborts its signal", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + const controller = new AbortController(); + const parked = ui.select("Pick a database", ["pg", "sqlite"], { signal: controller.signal }); + + controller.abort(); + + await expect(parked).resolves.toBeUndefined(); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + expect(dialogEvents(events).map(({ event }) => event)).toEqual([ + { type: "dialog.opened", dialog: openDialog(events) }, + { type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" }, + ]); + await service.dispose(); + }); +}); + +describe("PiSessionService extension dialog run end and teardown", () => { + it("settles run-scoped dialogs as aborted on agent_end but leaves idle dialogs open", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + fake.session.isStreaming = true; + const consent = ui.confirm("Run consent", "Allow this tool call?"); + fake.session.isStreaming = false; + const idle = ui.input("Session note?"); + + fake.emit({ type: "agent_end" }); + + await expect(consent).resolves.toBe(false); + await expect(settledValue(idle)).resolves.toEqual({ settled: false }); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([expect.objectContaining({ dialogId: "dialog-2" })]); + expect(dialogEvents(events).map(({ event }) => event)).toMatchObject([ + { type: "dialog.opened", dialog: { dialogId: "dialog-1" } }, + { type: "dialog.opened", dialog: { dialogId: "dialog-2" } }, + { type: "dialog.closed", dialogId: "dialog-1", reason: "aborted" }, + ]); + await service.dispose(); + }); + + it("settles every dialog as session-ended when the session closes", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + fake.session.isStreaming = true; + const consent = ui.confirm("Run consent", "Allow this tool call?"); + fake.session.isStreaming = false; + const idle = ui.input("Session note?"); + + await service.stop(sessionRef(ACTIVE_SESSION_ID)); + + await expect(consent).resolves.toBe(false); + await expect(idle).resolves.toBeUndefined(); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + expect(dialogEvents(events).map(({ event }) => event)).toMatchObject([ + { type: "dialog.opened", dialog: { dialogId: "dialog-1" } }, + { type: "dialog.opened", dialog: { dialogId: "dialog-2" } }, + { type: "dialog.closed", dialogId: "dialog-1", reason: "session-ended" }, + { type: "dialog.closed", dialogId: "dialog-2", reason: "session-ended" }, + ]); + await service.dispose(); + }); + + it("settles every dialog as session-ended when the daemon disposes the session", async () => { + const { service, store, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + const parked = ui.confirm("Proceed?", "Really?"); + + await service.dispose(); + + await expect(parked).resolves.toBe(false); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + }); + + it("settles the old runtime's dialogs as session-ended when the runtime is replaced", async () => { + const { service, store, events, fake } = dialogService(); + const rebinds: ((session: PiAgentSession) => Promise)[] = []; + fake.runtime.setRebindSession = (fn) => { + if (fn !== undefined) rebinds.push(fn); + }; + const ui = await boundUiContext(service, fake); + const parked = ui.confirm("Proceed?", "Really?"); + const replacement = fakeRuntime(ACTIVE_SESSION_ID); + const rebind = rebinds[0]; + if (rebind === undefined) throw new Error("runtime replacement was not armed"); + + await rebind(replacement.session); + + await expect(parked).resolves.toBe(false); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + expect(dialogEvents(events).map(({ event }) => event)).toEqual([ + { type: "dialog.opened", dialog: openDialog(events) }, + { type: "dialog.closed", dialogId: "dialog-1", reason: "session-ended" }, + ]); + // The replacement runtime is bound with a fresh UI context of its own. + expect(replacement.calls.bindExtensions).toHaveLength(1); + await service.dispose(); + }); +}); + +describe("PiSessionService extension dialog status projection", () => { + it("reports open dialogs oldest first so a reloading browser rehydrates them", async () => { + const { service, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + + const before = await service.status(sessionRef(ACTIVE_SESSION_ID)); + void ui.confirm("Proceed?", "Really?"); + void ui.input("Branch name?"); + const during = await service.status(sessionRef(ACTIVE_SESSION_ID)); + await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true); + const after = await service.status(sessionRef(ACTIVE_SESSION_ID)); + + expect(before.pendingDialogs).toBeUndefined(); + expect(during.pendingDialogs?.map((dialog) => dialog.dialogId)).toEqual(["dialog-1", "dialog-2"]); + expect(after.pendingDialogs?.map((dialog) => dialog.dialogId)).toEqual(["dialog-2"]); + await service.dispose(); + }); +}); diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts index c6c7635..caf13ce 100644 --- a/src/server/sessions/piSessionService.testSupport.ts +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -136,7 +136,7 @@ export function testModel(): NonNullable { export function fakeRuntime(sessionId = "session-1", patch: Partial = {}) { const promptCalls: { text: string; options: unknown }[] = []; const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = []; - const bindExtensionCalls: unknown[] = []; + const bindExtensionCalls: TestExtensionBindings[] = []; const listeners: ((event: unknown) => void)[] = []; let extensionUiContext = testExtensionUiContext; const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls }; diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 5682092..39ec003 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -15,6 +15,7 @@ import { type AgentSessionServices, type CreateAgentSessionRuntimeFactory, type EditToolDetails, + type ExtensionUIDialogOptions, type ExtensionUIContext, type ModelRuntime, type ResourceDiagnostic, @@ -38,6 +39,10 @@ import type { AskUserCloseResponse, AskUserOutcome, AskUserSubmission, + ExtensionDialogAnswer, + ExtensionDialogCloseResponse, + ExtensionDialogKind, + ExtensionDialogOutcome, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, @@ -62,6 +67,9 @@ import { siblingWorkspaceCwds, type ProjectWorkspaceCwds } from "../workspaces/p import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js"; import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js"; import { PendingAskStore, renderAskUserAnswersText, type PendingAskCloseResult, type PendingAskOpenResult } from "./pendingAskStore.js"; +import { PendingExtensionDialogStore, type ExtensionDialogCancelReason } from "./pendingExtensionDialogStore.js"; +import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js"; +import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js"; import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js"; import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js"; import { buildTranscriptView } from "./subsessionTranscript.js"; @@ -719,6 +727,14 @@ export interface PiSessionServiceDependencies { askUserEnabled?: boolean; /** Daemon-lifetime open-ask state; defaults to an in-memory store in tests. */ pendingAskStore?: PendingAskStore; + /** Daemon-lifetime open-dialog state; defaults to an in-memory store in tests. */ + pendingExtensionDialogStore?: PendingExtensionDialogStore; + /** + * How long an extension dialog with no extension-set `timeout` waits for an + * answer before the daemon auto-cancels it; `0` waits forever. A tuning + * knob, not a gate: extension dialogs are always on. + */ + extensionDialogsTimeoutMs?: number; /** Structured logger for notable runtime events (e.g. spawns). */ logger?: PiSessionLogger; /** Clock seam for cleanup planning tests. */ @@ -785,6 +801,10 @@ export class PiSessionService implements SessionRouteService { private readonly notificationGenerationBySession = new WeakMap(); private readonly unreadStore: SessionUnreadStore; private readonly pendingAskStore: PendingAskStore; + private readonly pendingExtensionDialogStore: PendingExtensionDialogStore; + private readonly extensionDialogsTimeoutMs: number; + /** The parked extension Promise resolvers behind the store's open dialogs. */ + private readonly dialogWaiters = new ExtensionDialogWaiters(); private readonly catalogRefreshStatus: CatalogRefreshStatus | undefined; private readonly unreadPublicationRetryInitialMs: number; private readonly pendingUnreadMutations: SessionUnreadMutation[] = []; @@ -807,6 +827,8 @@ export class PiSessionService implements SessionRouteService { this.notificationStore = deps.notificationStore ?? new SessionNotificationStore(); this.unreadStore = deps.unreadStore ?? new SessionUnreadStore(); this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore(); + this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore(); + this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS; this.catalogRefreshStatus = deps.catalogRefreshStatus; this.unreadPublicationRetryInitialMs = Math.max( 0, @@ -977,6 +999,7 @@ export class PiSessionService implements SessionRouteService { for (const active of activeSessions) { this.forgetUnreadActivity(active.runtime.session); this.pendingAskStore.forgetSession(active.runtime.session.sessionId); + this.endSessionExtensionDialogs(active.runtime.session.sessionId); } this.active.clear(); this.pendingSessionOpens.clear(); @@ -1269,6 +1292,130 @@ export class PiSessionService implements SessionRouteService { this.publishStatus(session); } + /** + * Record the user's answer to an open extension dialog and resolve the + * extension's parked Promise with it. Unlike an ask, nothing is delivered to + * the model: the waiter is extension code inside an already in-flight run + * (or an idle handler), so no custom message and no turn are triggered. + */ + async answerDialog(ref: PiSessionLookup, dialogId: string, value: ExtensionDialogAnswer): Promise { + await this.assertWritable(ref); + const session = await this.getOrOpen(ref); + const result = this.pendingExtensionDialogStore.answer(session.sessionId, dialogId, value); + if (result.status === "stale") return { result: "stale", sessionStatus: this.statusFromSession(session) }; + const { outcome } = result; + this.publishDialogClosed(session.sessionId, outcome); + // `value` is what the store validated and recorded as the outcome's answer. + this.dialogWaiters.settleWithAnswer(dialogId, value); + this.publishStatus(session); + return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) }; + } + + /** Close an open extension dialog without an answer; the extension's wait settles with its kind's cancel value. */ + async cancelDialog(ref: PiSessionLookup, dialogId: string): Promise { + await this.assertWritable(ref); + const session = await this.getOrOpen(ref); + const result = this.pendingExtensionDialogStore.cancel(session.sessionId, dialogId, "cancelled"); + if (result.status === "stale") return { result: "stale", sessionStatus: this.statusFromSession(session) }; + const { outcome } = result; + this.publishDialogClosed(session.sessionId, outcome); + this.dialogWaiters.settleWithCancelValue(dialogId); + this.publishStatus(session); + return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) }; + } + + /** + * Implement one `ctx.ui.select()`/`confirm()`/`input()` call from extension + * code: open the store record, tell the browsers, and park a Promise that + * settles when the browser answers or cancels, the extension's own + * `signal`/`timeout` dismisses the dialog, the daemon default timeout + * elapses, or the runtime goes away. `store.open` validates the dialog, so a + * malformed one rejects the extension's call rather than rendering garbage. + * `async` so a rejected dialog becomes a rejection rather than a synchronous + * throw from a promise-returning method. + */ + private async openExtensionDialog( + session: PiAgentSession, + request: { kind: ExtensionDialogKind; title: string; message?: string | undefined; options?: string[] | undefined; placeholder?: string | undefined }, + opts: ExtensionUIDialogOptions | undefined, + ): Promise { + const signal = opts?.signal; + // A pre-aborted signal dismisses the dialog before it ever opens. + if (signal?.aborted === true) return extensionDialogCancelValue(request.kind); + const timeoutMs = effectiveExtensionDialogTimeoutMs(opts?.timeout, this.extensionDialogsTimeoutMs); + const dialog = this.pendingExtensionDialogStore.open({ + sessionId: session.sessionId, + kind: request.kind, + title: request.title, + ...(request.message === undefined ? {} : { message: request.message }), + ...(request.options === undefined ? {} : { options: request.options }), + ...(request.placeholder === undefined ? {} : { placeholder: request.placeholder }), + ...(timeoutMs === undefined ? {} : { timeoutMs }), + runScoped: session.isStreaming, + }); + this.events.publish(session.sessionId, { type: "dialog.opened", dialog }); + this.publishStatus(session); + return this.dialogWaiters.park(dialog, { + ...(timeoutMs === undefined ? {} : { timeoutMs }), + ...(signal === undefined ? {} : { signal }), + onTrigger: (reason) => { + if (this.closeExtensionDialogFromTrigger(session.sessionId, dialog.dialogId, reason)) this.publishStatusForSessionId(session.sessionId); + }, + }); + } + + /** + * Close a dialog whose wait ended without the browser (timeout, signal + * abort, run end, runtime teardown) and settle its parked Promise. Returns + * whether this call closed the dialog; a stale close means a browser answer + * or an earlier trigger already settled everything. + */ + private closeExtensionDialogFromTrigger(sessionId: string, dialogId: string, reason: ExtensionDialogCancelReason): boolean { + const result = this.pendingExtensionDialogStore.cancel(sessionId, dialogId, reason); + if (result.status !== "closed") return false; + this.publishDialogClosed(sessionId, result.outcome); + this.dialogWaiters.settleWithCancelValue(dialogId); + return true; + } + + /** + * Settle the session's run-scoped dialogs as `"aborted"` when its run ends. + * Covers user-abort mid-dialog and run crashes; idle-opened dialogs (a + * `session_start` probe, say) are not run-scoped and survive, because their + * waiter is still alive after `agent_end`. + */ + private abortRunScopedExtensionDialogs(sessionId: string): void { + let closedAny = false; + for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) { + if (dialog.runScoped) closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "aborted") || closedAny; + } + if (closedAny) this.publishStatusForSessionId(sessionId); + } + + /** + * Settle every dialog of the session as `"session-ended"`: the runtime + * whose extension code is parked on them is being closed, replaced, or + * disposed, so those Promises would otherwise never settle. + */ + private endSessionExtensionDialogs(sessionId: string): void { + let closedAny = false; + for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) { + closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "session-ended") || closedAny; + } + // Publishes only while the session is still (or already re-)registered as + // active, so teardown paths stay silent and runtime replacement refreshes. + if (closedAny) this.publishStatusForSessionId(sessionId); + } + + private publishDialogClosed(sessionId: string, outcome: ExtensionDialogOutcome): void { + this.events.publish(sessionId, { + type: "dialog.closed", + dialogId: outcome.dialogId, + reason: outcome.reason, + ...(outcome.answer === undefined ? {} : { answer: outcome.answer }), + }); + } + /** * Publish status for a session known only by id, as the ask tools are: they * run inside the session's own runtime, so the active entry is the session. @@ -2438,6 +2585,9 @@ export class PiSessionService implements SessionRouteService { // An open ask is meaningful only while the runtime that posted it exists: no // one is left to receive the answers, so it is dropped without an outcome. this.pendingAskStore.forgetSession(sessionId); + // Open dialogs share that stance, but their extension waiters are parked + // Promises inside the dying runtime: settle them rather than dropping them. + this.endSessionExtensionDialogs(sessionId); this.active.delete(sessionId); this.activities.delete(sessionId); this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd()); @@ -2647,6 +2797,10 @@ export class PiSessionService implements SessionRouteService { this.notificationGenerationBySession.set(session, candidateGeneration); } this.bindRuntime(active, session); + // The runtime being replaced parked every dialog the store still + // holds for this session; settle those waits before the new + // runtime's extensions can open fresh dialogs under the same id. + this.endSessionExtensionDialogs(boundSession.sessionId); boundSession = session; await this.bindSessionExtensions(session, candidateGeneration); if (candidateGeneration !== undefined) { @@ -2679,6 +2833,9 @@ export class PiSessionService implements SessionRouteService { } active.unsubscribe(); this.forgetUnreadActivity(boundSession); + // A session_start dialog may already be parked when a later startup + // step fails; its waiter dies with the runtime being torn down here. + this.endSessionExtensionDialogs(boundSession.sessionId); let removedActive = false; for (const [sessionId, candidate] of this.active.entries()) { if (candidate !== active) continue; @@ -2744,13 +2901,27 @@ export class PiSessionService implements SessionRouteService { notificationId: added.notification.id, }); }; - // PI WEB owns the browser-facing notification and text-formatting - // boundaries. Delegate every other UI method to Pi's headless defaults so - // unsupported dialogs cancel safely instead of hanging. + // PI WEB owns the browser-facing dialog, notification, and text-formatting + // boundaries: the three dialog primitives park daemon-held Promises that + // the browser answers, while every other UI method delegates to Pi's + // headless defaults so unsupported surfaces cancel safely instead of + // hanging. return new Proxy(baseUiContext, { - get(target, property, receiver): unknown { + get: (target, property, receiver): unknown => { if (property === "notify") return notify; if (property === "theme") return plainTextTheme; + if (property === "confirm") { + return (title: string, message: string, opts?: ExtensionUIDialogOptions) => + this.openExtensionDialog(session, { kind: "confirm", title, message }, opts); + } + if (property === "select") { + return (title: string, options: string[], opts?: ExtensionUIDialogOptions) => + this.openExtensionDialog(session, { kind: "select", title, options }, opts); + } + if (property === "input") { + return (title: string, placeholder: string | undefined, opts?: ExtensionUIDialogOptions) => + this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts); + } const value: unknown = Reflect.get(target, property, receiver); return value; }, @@ -2903,6 +3074,7 @@ export class PiSessionService implements SessionRouteService { this.events.publish(session.sessionId, toClientEvent(event)); this.publishActivityForEvent(session, event); const eventType = getString(event, "type"); + if (eventType === "agent_end") this.abortRunScopedExtensionDialogs(session.sessionId); if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId); if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId); this.publishStatus(session); @@ -3300,6 +3472,7 @@ export class PiSessionService implements SessionRouteService { const contextUsage = session.getContextUsage(); const warnings = this.warningsForSession(session); const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId); + const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId); return { sessionId: session.sessionId, persisted: sessionFileExists(session.sessionFile), @@ -3316,6 +3489,7 @@ export class PiSessionService implements SessionRouteService { ...(contextUsage === undefined ? {} : { contextUsage }), ...(warnings.length === 0 ? {} : { warnings }), ...(pendingAsk === undefined ? {} : { pendingAsk }), + ...(pendingDialogs.length === 0 ? {} : { pendingDialogs }), }; } diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index 64e299b..3eaf645 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -2,10 +2,12 @@ import { resolve } from "node:path"; import Fastify, { type FastifyInstance } from "fastify"; import fastifyWebsocket from "@fastify/websocket"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../shared/apiTypes.js"; +import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../shared/apiTypes.js"; import type { AskUserCloseResponse, AskUserSubmission, + ExtensionDialogAnswer, + ExtensionDialogCloseResponse, MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, @@ -434,6 +436,97 @@ describe("session routes", () => { } }); + it("parses dialog answers and cancels and reports both closed and stale outcomes", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const answered = await routeApp.inject({ + method: "POST", + url: "/sessions/session-1/dialogs/answer", + payload: { cwd: "/repo/./", dialogId: "dialog-1", value: true }, + }); + const answeredText = await routeApp.inject({ + method: "POST", + url: "/sessions/session-1/dialogs/answer", + payload: { dialogId: "dialog-2", value: "typed text" }, + }); + const cancelled = await routeApp.inject({ + method: "POST", + url: "/sessions/session-1/dialogs/cancel", + payload: { dialogId: "dialog-3" }, + }); + + expect(answered.statusCode).toBe(200); + expect(answered.json()).toMatchObject({ result: "closed", sessionStatus: { sessionId: "session-1" } }); + expect(answeredText.statusCode).toBe(200); + expect(routeService.answerDialogCalls).toEqual([ + { lookup: { id: "session-1", cwd: resolve("/repo") }, dialogId: "dialog-1", value: true }, + { lookup: "session-1", dialogId: "dialog-2", value: "typed text" }, + ]); + expect(cancelled.statusCode).toBe(200); + expect(cancelled.json()).toMatchObject({ result: "stale" }); + expect(routeService.cancelDialogCalls).toEqual([{ lookup: "session-1", dialogId: "dialog-3" }]); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); + + it("rejects malformed dialog payloads before calling the service", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(); + registerSessionRoutes(routeApp, routeService, eventHub); + const malformedAnswers: Record[] = [ + { value: true }, + { dialogId: "", value: true }, + { dialogId: "x".repeat(EXTENSION_DIALOG_ID_MAX_LENGTH + 1), value: true }, + { dialogId: "dialog-1" }, + { dialogId: "dialog-1", value: 7 }, + { dialogId: "dialog-1", value: ["option"] }, + { dialogId: "dialog-1", value: "x".repeat(EXTENSION_DIALOG_INPUT_MAX_LENGTH + 1) }, + ]; + + try { + for (const payload of malformedAnswers) { + const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/dialogs/answer", payload }); + expect(response.statusCode).toBe(400); + } + const cancelWithoutDialogId = await routeApp.inject({ method: "POST", url: "/sessions/session-1/dialogs/cancel", payload: {} }); + + expect(cancelWithoutDialogId.statusCode).toBe(400); + expect(routeService.answerDialogCalls).toEqual([]); + expect(routeService.cancelDialogCalls).toEqual([]); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); + + it("maps a missing session on a dialog answer to 404", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(); + routeService.dialogError = new Error("Session not found"); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/dialogs/answer", payload: { dialogId: "dialog-1", value: true } }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: "Session not found" }); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); + it("rejects prompt payloads that omit text without opening a session", async () => { const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } }); @@ -834,8 +927,11 @@ class CapturingRouteSessionService implements SessionRouteService { readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = []; readonly submitAskCalls: { lookup: SessionRouteLookup; askId: string; submission: AskUserSubmission }[] = []; readonly cancelAskCalls: { lookup: SessionRouteLookup; askId: string }[] = []; + readonly answerDialogCalls: { lookup: SessionRouteLookup; dialogId: string; value: ExtensionDialogAnswer }[] = []; + readonly cancelDialogCalls: { lookup: SessionRouteLookup; dialogId: string }[] = []; readonly startCalls: { cwd: string; startupToken: string | undefined }[] = []; askError: Error | undefined; + dialogError: Error | undefined; reloadError: Error | undefined; clearQueueError: Error | undefined; @@ -851,6 +947,18 @@ class CapturingRouteSessionService implements SessionRouteService { return Promise.resolve({ result: "stale", sessionStatus: idleStatus(lookup) }); } + answerDialog(lookup: SessionRouteLookup, dialogId: string, value: ExtensionDialogAnswer): Promise { + if (this.dialogError !== undefined) return Promise.reject(this.dialogError); + this.answerDialogCalls.push({ lookup, dialogId, value }); + return Promise.resolve({ result: "closed", sessionStatus: idleStatus(lookup) }); + } + + cancelDialog(lookup: SessionRouteLookup, dialogId: string): Promise { + if (this.dialogError !== undefined) return Promise.reject(this.dialogError); + this.cancelDialogCalls.push({ lookup, dialogId }); + return Promise.resolve({ result: "stale", sessionStatus: idleStatus(lookup) }); + } + cleanupPreview(request: NormalizedSessionCleanupRequest): Promise { this.cleanupPreviewCalls.push(request); return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } }); diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 4d597d3..1da4e2d 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type AskUserAnswer, type AskUserSubmission, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js"; +import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type AskUserAnswer, type AskUserSubmission, type ExtensionDialogAnswerRequest, type ExtensionDialogCancelRequest, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js"; import { projectBrowserMessageResponse } from "../browserMessageProjection.js"; import { normalizeRequestCwd } from "../workingDirectory.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; @@ -289,6 +289,26 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou } }); + app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dialogId?: unknown; value?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/dialogs/answer`, async (request, reply) => { + try { + const body = requireRecord(request.body); + const answer = extensionDialogAnswerFromBody(body); + return await sessions.answerDialog(sessionLookupFromBody(request.params.sessionId, body), answer.dialogId, answer.value); + } catch (error) { + return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) }); + } + }); + + app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dialogId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/dialogs/cancel`, async (request, reply) => { + try { + const body = requireRecord(request.body); + const cancel = extensionDialogCancelFromBody(body); + return await sessions.cancelDialog(sessionLookupFromBody(request.params.sessionId, body), cancel.dialogId); + } catch (error) { + return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) }); + } + }); + app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dismissId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/warnings/dismiss`, async (request, reply) => { try { const body = optionalRecord(request.body); @@ -547,6 +567,26 @@ function requireBoundedId(value: unknown, field: string): string { return requireNonEmptyBoundedString(value, field, ASK_USER_ID_MAX_LENGTH); } +/** + * Shape-check one dialog answer. Only transport-level checks belong here: + * whether the value fits the answered dialog's kind is the pending dialog + * store's job, since only it knows the open dialog. + */ +function extensionDialogAnswerFromBody(body: Record): ExtensionDialogAnswerRequest { + const dialogId = requireNonEmptyBoundedString(body["dialogId"], "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH); + const value = body["value"]; + if (typeof value === "boolean") return { dialogId, value }; + if (typeof value === "string") { + if (value.length > EXTENSION_DIALOG_INPUT_MAX_LENGTH) throw new Error("value field is too long"); + return { dialogId, value }; + } + throw new Error("value field must be a string or a boolean"); +} + +function extensionDialogCancelFromBody(body: Record): ExtensionDialogCancelRequest { + return { dialogId: requireNonEmptyBoundedString(body["dialogId"], "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH) }; +} + function optionalRecord(value: unknown): Record { if (value === undefined || value === null) return {}; return requireRecord(value); diff --git a/src/server/sessions/sessionService.ts b/src/server/sessions/sessionService.ts index ab5c5c3..f96d2a6 100644 --- a/src/server/sessions/sessionService.ts +++ b/src/server/sessions/sessionService.ts @@ -1,6 +1,8 @@ import type { AskUserCloseResponse, AskUserSubmission, + ExtensionDialogAnswer, + ExtensionDialogCloseResponse, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, @@ -60,6 +62,8 @@ export interface SessionRouteService { clearQueue(ref: SessionRouteLookup): Promise; submitAsk(ref: SessionRouteLookup, askId: string, submission: AskUserSubmission): Promise; cancelAsk(ref: SessionRouteLookup, askId: string): Promise; + answerDialog(ref: SessionRouteLookup, dialogId: string, value: ExtensionDialogAnswer): Promise; + cancelDialog(ref: SessionRouteLookup, dialogId: string): Promise; dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise; availableModels(ref: SessionRouteLookup): Promise; setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise; diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 8c36b1a..01285a7 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -103,6 +103,13 @@ export interface PiWebConfigValues { * tool. On by default; set to `false` to remove the tool from the runtime. */ askUser?: boolean; + /** + * How long an extension dialog may wait for an answer before the daemon + * auto-cancels it, in milliseconds. Applies only when the extension set no + * `timeout` of its own (the sooner of the two wins); `0` waits forever. + * Tuning knob only — extension dialogs are always enabled. + */ + extensionDialogsTimeoutMs?: number; /** Desired Pi-compatible agent profile and companion CLI (Pi by default). */ agent?: PiWebAgentConfig; } @@ -662,6 +669,38 @@ export interface ExtensionDialogOutcome { closedAt: string; } +/** + * Browser request to answer an open extension dialog with the user's value. + * `cwd` rides along as the standard session-lookup field, as on every other + * session route; whether the value fits the dialog's kind is the store's call, + * so an ill-fitting answer is a 400 that leaves the dialog open. + */ +export interface ExtensionDialogAnswerRequest { + cwd?: string; + dialogId: string; + value: ExtensionDialogAnswer; +} + +/** Browser request to dismiss an open extension dialog without an answer. */ +export interface ExtensionDialogCancelRequest { + cwd?: string; + dialogId: string; +} + +/** + * Result of the browser answering or cancelling an extension dialog. Mirrors + * {@link AskUserCloseResponse}: `"stale"` is an ordinary lost race — another + * browser, a timeout, or a teardown closed the dialog first — not an error. + * The browser drops its card and trusts `sessionStatus`, which is returned in + * both cases so closing a dialog needs no follow-up status request. + */ +export interface ExtensionDialogCloseResponse { + result: "closed" | "stale"; + /** Present only when this call is the one that closed the dialog. */ + outcome?: ExtensionDialogOutcome; + sessionStatus: SessionStatus; +} + /** * Progress of the session startup window, where the daemon is still * constructing the agent session and no `PiAgentSession` exists yet, so