From 50801e4f85bbb2e65a786a4fdb4acd8a8be32e21 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 00:05:21 +0200 Subject: [PATCH 1/9] feat(sessions): add pending extension dialog store and wire types Domain layer for issue #106: a daemon-owned, per-session PendingExtensionDialogStore (multiple open dialogs, no supersede, kind-validated answers, stale-tolerant closes) plus the shared PendingExtensionDialog/ExtensionDialogOutcome wire types, SessionStatus.pendingDialogs, and dialog.opened/dialog.closed events. Relay: issue-106-extension-dialogs leg 1 --- .../pendingExtensionDialogStore.test.ts | 279 ++++++++++++++++++ .../sessions/pendingExtensionDialogStore.ts | 257 ++++++++++++++++ src/shared/apiTypes.ts | 79 +++++ 3 files changed, 615 insertions(+) create mode 100644 src/server/sessions/pendingExtensionDialogStore.test.ts create mode 100644 src/server/sessions/pendingExtensionDialogStore.ts diff --git a/src/server/sessions/pendingExtensionDialogStore.test.ts b/src/server/sessions/pendingExtensionDialogStore.test.ts new file mode 100644 index 0000000..b5dd9a0 --- /dev/null +++ b/src/server/sessions/pendingExtensionDialogStore.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from "vitest"; +import { + EXTENSION_DIALOG_INPUT_MAX_LENGTH, + EXTENSION_DIALOG_OPTION_LIMIT, + type ExtensionDialogAnswer, +} from "../../shared/apiTypes.js"; +import { + PendingExtensionDialogStore, + PendingExtensionDialogValidationError, + type ExtensionDialogCancelReason, +} from "./pendingExtensionDialogStore.js"; + +const sessionId = "session-1"; + +function testStore(createDialogId?: () => string) { + let dialogCount = 0; + let tick = 0; + return new PendingExtensionDialogStore({ + createDialogId: createDialogId ?? (() => `dialog-${(++dialogCount).toString()}`), + now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++)), + }); +} + +describe("PendingExtensionDialogStore open", () => { + it("normalizes a confirm dialog and reports it among the session's pending dialogs", () => { + const store = testStore(); + + const dialog = store.open({ + sessionId, + kind: "confirm", + title: "Deploy to production?", + message: "This will restart the service.", + timeoutMs: 300_000, + runScoped: true, + }); + + expect(dialog).toEqual({ + dialogId: "dialog-1", + kind: "confirm", + title: "Deploy to production?", + message: "This will restart the service.", + askedAt: "2026-01-01T00:00:00.000Z", + timeoutAt: "2026-01-01T00:05:00.000Z", + runScoped: true, + }); + expect(store.pendingDialogs(sessionId)).toEqual([dialog]); + expect(store.pendingDialogs("other-session")).toEqual([]); + }); + + it("keeps several dialogs of one session open, oldest first, without superseding", () => { + const store = testStore(); + + const confirm = store.open({ sessionId, kind: "confirm", title: "Proceed?", runScoped: true }); + const select = store.open({ sessionId, kind: "select", title: "Pick a branch", options: ["main", "dev"], runScoped: false }); + const input = store.open({ sessionId, kind: "input", title: "Commit name", placeholder: "feat: …", runScoped: false }); + + expect(store.pendingDialogs(sessionId)).toEqual([confirm, select, input]); + expect(select).toEqual({ + dialogId: "dialog-2", + kind: "select", + title: "Pick a branch", + options: ["main", "dev"], + askedAt: "2026-01-01T00:00:01.000Z", + runScoped: false, + }); + expect(input).toEqual({ + dialogId: "dialog-3", + kind: "input", + title: "Commit name", + placeholder: "feat: …", + askedAt: "2026-01-01T00:00:02.000Z", + runScoped: false, + }); + }); + + it("keeps each session's dialogs separate", () => { + const store = testStore(); + store.open({ sessionId, kind: "confirm", title: "One?", runScoped: false }); + store.open({ sessionId: "session-2", kind: "confirm", title: "Two?", runScoped: false }); + store.open({ sessionId: "session-2", kind: "confirm", title: "Three?", runScoped: false }); + + expect(store.pendingDialogs(sessionId).map((dialog) => dialog.title)).toEqual(["One?"]); + expect(store.pendingDialogs("session-2").map((dialog) => dialog.title)).toEqual(["Two?", "Three?"]); + }); + + it("omits timeoutAt when no timeout applies and drops blank cosmetic fields", () => { + const store = testStore(); + + const dialog = store.open({ sessionId, kind: "confirm", title: "Sure?", message: " ", runScoped: false }); + + expect(dialog).toEqual({ + dialogId: "dialog-1", + kind: "confirm", + title: "Sure?", + askedAt: "2026-01-01T00:00:00.000Z", + runScoped: false, + }); + expect(dialog).not.toHaveProperty("timeoutAt"); + expect(dialog).not.toHaveProperty("message"); + }); + + it("drops fields that do not belong to the dialog's kind", () => { + const store = testStore(); + + const select = store.open({ + sessionId, + kind: "select", + title: "Pick", + options: ["a"], + message: "not a confirm field", + placeholder: "not an input field", + runScoped: false, + }); + const input = store.open({ + sessionId, + kind: "input", + title: "Type", + options: ["a"], + runScoped: false, + }); + + expect(select).not.toHaveProperty("message"); + expect(select).not.toHaveProperty("placeholder"); + expect(input).not.toHaveProperty("options"); + expect(input).not.toHaveProperty("placeholder"); + }); + + it("rejects dialogs the user could not meaningfully answer", () => { + const store = testStore(); + const open = (overrides: Record) => () => + store.open({ sessionId, kind: "confirm", title: "Ok?", runScoped: false, ...overrides }); + + expect(open({ title: " " })).toThrow(/dialog title must not be empty/); + expect(open({ kind: "widget" })).toThrow(/Unknown dialog kind widget/); + expect(open({ kind: "select", options: undefined })).toThrow(/at least one option/); + expect(open({ kind: "select", options: [] })).toThrow(/at least one option/); + expect(open({ kind: "select", options: ["a", "a"] })).toThrow(/Duplicate select option a/); + expect(open({ kind: "select", options: [" "] })).toThrow(/select option must not be empty/); + expect(open({ + kind: "select", + options: Array.from({ length: EXTENSION_DIALOG_OPTION_LIMIT + 1 }, (_, index) => `v${index.toString()}`), + })).toThrow(/more than 24 options/); + expect(open({ timeoutMs: 0 })).toThrow(PendingExtensionDialogValidationError); + expect(open({ timeoutMs: -5 })).toThrow(PendingExtensionDialogValidationError); + expect(open({ timeoutMs: Number.NaN })).toThrow(PendingExtensionDialogValidationError); + expect(store.pendingDialogs(sessionId)).toEqual([]); + }); + + it("rejects an open whose id collides with a still-open dialog", () => { + const store = testStore(() => "dialog-x"); + store.open({ sessionId, kind: "confirm", title: "First?", runScoped: false }); + + expect(() => store.open({ sessionId, kind: "confirm", title: "Second?", runScoped: false })) + .toThrow(/already open/); + expect(store.pendingDialogs(sessionId).map((dialog) => dialog.title)).toEqual(["First?"]); + }); +}); + +describe("PendingExtensionDialogStore answer", () => { + it("closes a confirm dialog with the user's boolean answer", () => { + const store = testStore(); + store.open({ sessionId, kind: "confirm", title: "Proceed?", runScoped: true }); + + const result = store.answer(sessionId, "dialog-1", true); + + expect(result).toEqual({ + status: "closed", + outcome: { + dialogId: "dialog-1", + reason: "answered", + answer: true, + askedAt: "2026-01-01T00:00:00.000Z", + closedAt: "2026-01-01T00:00:01.000Z", + }, + }); + expect(store.pendingDialogs(sessionId)).toEqual([]); + }); + + it("closes a select dialog with the chosen option and an input dialog with the typed text", () => { + const store = testStore(); + store.open({ sessionId, kind: "select", title: "Pick", options: ["main", "dev"], runScoped: false }); + store.open({ sessionId, kind: "input", title: "Name", runScoped: false }); + + const selected = store.answer(sessionId, "dialog-1", "dev"); + const typed = store.answer(sessionId, "dialog-2", "feat: dialogs"); + + expect(selected).toMatchObject({ status: "closed", outcome: { reason: "answered", answer: "dev" } }); + expect(typed).toMatchObject({ status: "closed", outcome: { reason: "answered", answer: "feat: dialogs" } }); + }); + + it("accepts an empty string as an input answer, distinct from cancelling", () => { + const store = testStore(); + store.open({ sessionId, kind: "input", title: "Name", runScoped: false }); + + const result = store.answer(sessionId, "dialog-1", ""); + + expect(result).toMatchObject({ status: "closed", outcome: { reason: "answered", answer: "" } }); + }); + + it("rejects answers that do not fit the dialog's kind and keeps the dialog open", () => { + const store = testStore(); + store.open({ sessionId, kind: "confirm", title: "Sure?", runScoped: false }); + store.open({ sessionId, kind: "select", title: "Pick", options: ["a", "b"], runScoped: false }); + store.open({ sessionId, kind: "input", title: "Type", runScoped: false }); + const answer = (dialogId: string, value: ExtensionDialogAnswer) => () => store.answer(sessionId, dialogId, value); + + expect(answer("dialog-1", "yes")).toThrow(/expects a boolean answer/); + expect(answer("dialog-2", true)).toThrow(/has no option true/); + expect(answer("dialog-2", "c")).toThrow(/has no option c/); + expect(answer("dialog-3", false)).toThrow(/expects a text answer/); + expect(answer("dialog-3", "x".repeat(EXTENSION_DIALOG_INPUT_MAX_LENGTH + 1))).toThrow(/exceeds its length limit/); + expect(store.pendingDialogs(sessionId).map((dialog) => dialog.dialogId)).toEqual(["dialog-1", "dialog-2", "dialog-3"]); + }); + + it("treats an answer for a dialog that is no longer open as stale", () => { + const store = testStore(); + store.open({ sessionId, kind: "confirm", title: "Sure?", runScoped: false }); + + expect(store.answer(sessionId, "dialog-other", true)).toEqual({ status: "stale" }); + expect(store.answer("session-2", "dialog-1", true)).toEqual({ status: "stale" }); + + store.answer(sessionId, "dialog-1", false); + expect(store.answer(sessionId, "dialog-1", true)).toEqual({ status: "stale" }); + }); +}); + +describe("PendingExtensionDialogStore cancel", () => { + it("closes a dialog without an answer for every cancel reason", () => { + const store = testStore(); + const reasons: ExtensionDialogCancelReason[] = ["cancelled", "timeout", "aborted", "session-ended"]; + + for (const reason of reasons) { + const dialog = store.open({ sessionId, kind: "confirm", title: `${reason}?`, runScoped: false }); + const result = store.cancel(sessionId, dialog.dialogId, reason); + if (result.status !== "closed") throw new Error("expected the dialog to close"); + const { closedAt, ...outcome } = result.outcome; + expect(closedAt).toEqual(expect.any(String)); + expect(outcome).toEqual({ dialogId: dialog.dialogId, reason, askedAt: dialog.askedAt }); + expect(result.outcome).not.toHaveProperty("answer"); + } + expect(store.pendingDialogs(sessionId)).toEqual([]); + }); + + it("closes only the named dialog and keeps the rest in order", () => { + const store = testStore(); + store.open({ sessionId, kind: "confirm", title: "One?", runScoped: false }); + store.open({ sessionId, kind: "confirm", title: "Two?", runScoped: false }); + store.open({ sessionId, kind: "confirm", title: "Three?", runScoped: false }); + + store.cancel(sessionId, "dialog-2", "cancelled"); + expect(store.pendingDialogs(sessionId).map((dialog) => dialog.dialogId)).toEqual(["dialog-1", "dialog-3"]); + + store.answer(sessionId, "dialog-1", true); + expect(store.pendingDialogs(sessionId).map((dialog) => dialog.dialogId)).toEqual(["dialog-3"]); + }); + + it("records the close time, not the open time, as closedAt", () => { + const store = testStore(); + store.open({ sessionId, kind: "confirm", title: "Sure?", runScoped: false }); + + const result = store.cancel(sessionId, "dialog-1", "timeout"); + + expect(result).toMatchObject({ + status: "closed", + outcome: { askedAt: "2026-01-01T00:00:00.000Z", closedAt: "2026-01-01T00:00:01.000Z" }, + }); + }); + + it("treats a cancel for a dialog that is no longer open as stale", () => { + const store = testStore(); + store.open({ sessionId, kind: "confirm", title: "Sure?", runScoped: false }); + + expect(store.cancel(sessionId, "dialog-other", "cancelled")).toEqual({ status: "stale" }); + expect(store.cancel("session-2", "dialog-1", "cancelled")).toEqual({ status: "stale" }); + + store.cancel(sessionId, "dialog-1", "aborted"); + expect(store.cancel(sessionId, "dialog-1", "cancelled")).toEqual({ status: "stale" }); + }); +}); diff --git a/src/server/sessions/pendingExtensionDialogStore.ts b/src/server/sessions/pendingExtensionDialogStore.ts new file mode 100644 index 0000000..59b5d75 --- /dev/null +++ b/src/server/sessions/pendingExtensionDialogStore.ts @@ -0,0 +1,257 @@ +import { randomUUID } from "node:crypto"; +import { + EXTENSION_DIALOG_ID_MAX_LENGTH, + EXTENSION_DIALOG_INPUT_MAX_LENGTH, + EXTENSION_DIALOG_OPTION_LIMIT, + EXTENSION_DIALOG_TEXT_MAX_LENGTH, + type ExtensionDialogAnswer, + type ExtensionDialogCloseReason, + type ExtensionDialogKind, + type ExtensionDialogOutcome, + type PendingExtensionDialog, +} from "../../shared/apiTypes.js"; + +export interface PendingExtensionDialogStoreOptions { + now?: (() => Date) | undefined; + createDialogId?: (() => string) | undefined; +} + +/** + * What one extension dialog needs to open: the SDK `ctx.ui` dialog arguments + * plus the wiring's scoping decisions. The effective timeout (the sooner of + * the extension's own `timeout` and the daemon default) is decided by the + * caller; the store only projects it onto its clock as `timeoutAt`. + */ +export interface PendingExtensionDialogOpenInput { + sessionId: string; + kind: ExtensionDialogKind; + title: string; + message?: string | undefined; + options?: string[] | undefined; + placeholder?: string | undefined; + /** Effective timeout in milliseconds; omit (or have the caller resolve `0`) to wait forever. */ + timeoutMs?: number | undefined; + /** True when opened while a run is in flight; run-scoped dialogs are settled on `agent_end`. */ + runScoped: boolean; +} + +/** Why a dialog was closed without an answer. `"answered"` is {@link answer}'s reason, not a cancel reason. */ +export type ExtensionDialogCancelReason = Exclude; + +/** + * Result of answering or cancelling a dialog. `"stale"` means the dialog named + * by the caller is no longer open (already answered, cancelled, timed out, or + * gone with its session runtime), which is an ordinary race a browser can + * lose — not an error. + */ +export type PendingExtensionDialogCloseResult = + | { status: "closed"; outcome: ExtensionDialogOutcome } + | { status: "stale" }; + +/** Rejected input: the dialog is malformed, or an answer does not fit its kind. */ +export class PendingExtensionDialogValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "PendingExtensionDialogValidationError"; + } +} + +/** + * Daemon-owned open-dialog state: the extension dialogs of every session, + * several per session because each dialog is an independent blocking wait + * inside extension code — opening one must never supersede another. + * + * The store is pure domain logic — no Fastify, no Pi session, no I/O, no + * timers. It validates dialogs and answers and owns the open/answer/cancel + * transitions; callers hold the waiting Promise resolvers, publish the + * returned records and outcomes, and own the timers that turn `timeoutAt` + * into a `"timeout"` cancel. + * + * State is deliberately daemon-lifetime and in-memory. An open dialog is + * meaningful only while the session runtime whose extension is waiting on it + * exists, and browsers rehydrate open dialogs from `SessionStatus` rather + * than from disk. + */ +export class PendingExtensionDialogStore { + private readonly now: () => Date; + private readonly createDialogId: () => string; + /** Per-session open dialogs in insertion order, so `pendingDialogs` reads oldest first. */ + private readonly openBySessionId = new Map>(); + + constructor(options: PendingExtensionDialogStoreOptions = {}) { + this.now = options.now ?? (() => new Date()); + this.createDialogId = options.createDialogId ?? randomUUID; + } + + /** The session's open dialogs, oldest first, for {@link SessionStatus} projection. */ + pendingDialogs(sessionId: string): PendingExtensionDialog[] { + const dialogs = this.openBySessionId.get(requireSessionId(sessionId)); + if (dialogs === undefined) return []; + return [...dialogs.values()].map(cloneDialog); + } + + open(input: PendingExtensionDialogOpenInput): PendingExtensionDialog { + const sessionId = requireSessionId(input.sessionId); + const kind = requireKind(input.kind); + const now = this.now(); + const dialog: PendingExtensionDialog = { + dialogId: requireId(this.createDialogId(), "dialogId"), + kind, + title: requireText(input.title, "dialog title"), + ...kindFields(kind, input), + askedAt: now.toISOString(), + ...timeoutField(input.timeoutMs, now), + runScoped: input.runScoped, + }; + const dialogs = this.openBySessionId.get(sessionId) ?? new Map(); + if (dialogs.has(dialog.dialogId)) { + throw new Error(`Dialog id ${dialog.dialogId} is already open in session ${sessionId}`); + } + dialogs.set(dialog.dialogId, dialog); + this.openBySessionId.set(sessionId, dialogs); + return cloneDialog(dialog); + } + + /** + * Record the user's answer and close the dialog. The answer is validated + * against the dialog's kind first, so an answer that does not fit leaves the + * dialog open for the browser to correct. + */ + answer(sessionId: string, dialogId: string, value: ExtensionDialogAnswer): PendingExtensionDialogCloseResult { + const dialog = this.openBySessionId.get(requireSessionId(sessionId))?.get(dialogId); + if (dialog === undefined) return { status: "stale" }; + const answer = validateAnswer(dialog, value); + return { status: "closed", outcome: this.requireClose(sessionId, dialog, "answered", answer) }; + } + + /** Close the dialog without an answer; the extension's wait settles with its kind's cancel value. */ + cancel(sessionId: string, dialogId: string, reason: ExtensionDialogCancelReason): PendingExtensionDialogCloseResult { + const dialog = this.openBySessionId.get(requireSessionId(sessionId))?.get(dialogId); + if (dialog === undefined) return { status: "stale" }; + return { status: "closed", outcome: this.requireClose(sessionId, dialog, reason, undefined) }; + } + + private requireClose( + sessionId: string, + dialog: PendingExtensionDialog, + reason: ExtensionDialogCloseReason, + answer: ExtensionDialogAnswer | undefined, + ): ExtensionDialogOutcome { + const dialogs = this.openBySessionId.get(sessionId); + if (dialogs?.delete(dialog.dialogId) !== true) { + throw new Error(`Dialog ${dialog.dialogId} of session ${sessionId} disappeared while closing`); + } + if (dialogs.size === 0) this.openBySessionId.delete(sessionId); + return { + dialogId: dialog.dialogId, + reason, + ...(answer === undefined ? {} : { answer }), + askedAt: dialog.askedAt, + closedAt: this.timestamp(), + }; + } + + private timestamp(): string { + return this.now().toISOString(); + } +} + +function validateAnswer(dialog: PendingExtensionDialog, value: ExtensionDialogAnswer): ExtensionDialogAnswer { + switch (dialog.kind) { + case "confirm": + if (typeof value !== "boolean") throw new PendingExtensionDialogValidationError(`Dialog ${dialog.dialogId} expects a boolean answer`); + return value; + case "select": + if (typeof value !== "string" || dialog.options?.includes(value) !== true) { + throw new PendingExtensionDialogValidationError(`Dialog ${dialog.dialogId} has no option ${String(value)}`); + } + return value; + case "input": + if (typeof value !== "string") throw new PendingExtensionDialogValidationError(`Dialog ${dialog.dialogId} expects a text answer`); + if (value.length > EXTENSION_DIALOG_INPUT_MAX_LENGTH) { + throw new PendingExtensionDialogValidationError(`Answer of dialog ${dialog.dialogId} exceeds its length limit`); + } + return value; + } +} + +/** Kind-specific fields of a validated record; irrelevant fields are dropped rather than rejected. */ +function kindFields( + kind: ExtensionDialogKind, + input: PendingExtensionDialogOpenInput, +): Pick { + switch (kind) { + case "confirm": { + const message = optionalText(input.message, "dialog message"); + return message === undefined ? {} : { message }; + } + case "select": + return { options: validateOptions(input.options) }; + case "input": { + const placeholder = optionalText(input.placeholder, "dialog placeholder"); + return placeholder === undefined ? {} : { placeholder }; + } + } +} + +function validateOptions(options: string[] | undefined): string[] { + if (options === undefined || options.length === 0) { + throw new PendingExtensionDialogValidationError("A select dialog must offer at least one option"); + } + if (options.length > EXTENSION_DIALOG_OPTION_LIMIT) { + throw new PendingExtensionDialogValidationError(`A select dialog must not offer more than ${EXTENSION_DIALOG_OPTION_LIMIT.toString()} options`); + } + const seen = new Set(); + return options.map((option) => { + const validated = requireText(option, "select option"); + if (seen.has(validated)) throw new PendingExtensionDialogValidationError(`Duplicate select option ${validated}`); + seen.add(validated); + return validated; + }); +} + +function timeoutField(timeoutMs: number | undefined, now: Date): Pick { + if (timeoutMs === undefined) return {}; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new PendingExtensionDialogValidationError("A dialog timeout must be a positive number of milliseconds"); + } + return { timeoutAt: new Date(now.getTime() + timeoutMs).toISOString() }; +} + +function cloneDialog(dialog: PendingExtensionDialog): PendingExtensionDialog { + return { ...dialog, ...(dialog.options === undefined ? {} : { options: [...dialog.options] }) }; +} + +function requireSessionId(sessionId: string): string { + if (sessionId === "") throw new Error("sessionId must not be empty"); + return sessionId; +} + +/** Runtime guard: the input crosses extension code, so the declared kind is checked despite its type. */ +function requireKind(kind: string): ExtensionDialogKind { + if (kind !== "confirm" && kind !== "select" && kind !== "input") { + throw new PendingExtensionDialogValidationError(`Unknown dialog kind ${kind}`); + } + return kind; +} + +function requireId(value: string, field: string): string { + if (value.trim() === "") throw new PendingExtensionDialogValidationError(`${field} must not be empty`); + if (value.length > EXTENSION_DIALOG_ID_MAX_LENGTH) throw new PendingExtensionDialogValidationError(`${field} exceeds its length limit`); + return value; +} + +function requireText(value: string, field: string): string { + if (value.trim() === "") throw new PendingExtensionDialogValidationError(`${field} must not be empty`); + if (value.length > EXTENSION_DIALOG_TEXT_MAX_LENGTH) throw new PendingExtensionDialogValidationError(`${field} exceeds its length limit`); + return value; +} + +/** Optional cosmetic prose: blank means absent rather than being a validation error. */ +function optionalText(value: string | undefined, field: string): string | undefined { + if (value === undefined || value.trim() === "") return undefined; + if (value.length > EXTENSION_DIALOG_TEXT_MAX_LENGTH) { + throw new PendingExtensionDialogValidationError(`${field} exceeds its length limit`); + } + return value; +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 2f9a17e..8c36b1a 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -591,6 +591,77 @@ export interface AskUserCloseResponse { sessionStatus: SessionStatus; } +/** Length bound for extension-dialog ids. */ +export const EXTENSION_DIALOG_ID_MAX_LENGTH = 128; +/** Length bound for extension-authored dialog prose: titles, messages, options, placeholders. */ +export const EXTENSION_DIALOG_TEXT_MAX_LENGTH = 1_000; +/** Largest option list one `select` dialog may offer. */ +export const EXTENSION_DIALOG_OPTION_LIMIT = 24; +/** Length bound for the text a user types into an `input` dialog. */ +export const EXTENSION_DIALOG_INPUT_MAX_LENGTH = 4_000; + +/** Which extension UI dialog primitive a pending dialog belongs to. */ +export type ExtensionDialogKind = "confirm" | "select" | "input"; + +/** + * The value a user gave in an extension dialog: a boolean for `confirm`, the + * chosen option for `select`, the typed text for `input`. Absent when the + * dialog closed without an answer. + */ +export type ExtensionDialogAnswer = boolean | string; + +/** + * Why a dialog stopped being open. `"answered"` carries an + * {@link ExtensionDialogAnswer}; every other reason is a close without one. + */ +export type ExtensionDialogCloseReason = "answered" | "cancelled" | "timeout" | "aborted" | "session-ended"; + +/** + * One open extension dialog of a session, opened by `ctx.ui.confirm()`, + * `ctx.ui.select()`, or `ctx.ui.input()`. Daemon-owned and reported in + * {@link SessionStatus.pendingDialogs}, so a reconnecting or reloading browser + * rehydrates it without depending on having seen the `dialog.opened` event. + * + * Unlike asks, several dialogs may be open per session at once: each dialog is + * an independent blocking wait inside extension code, so opening never + * supersedes an existing one. + */ +export interface PendingExtensionDialog { + dialogId: string; + kind: ExtensionDialogKind; + title: string; + /** Supporting line of a `confirm` dialog. */ + message?: string; + /** Offered choices of a `select` dialog. */ + options?: string[]; + /** Placeholder text of an `input` dialog. */ + placeholder?: string; + askedAt: string; + /** + * When the dialog auto-cancels, as ISO: the sooner of the extension's own + * `timeout` and the daemon's `extensionDialogsTimeoutMs` default. Absent + * when the dialog waits forever. + */ + timeoutAt?: string; + /** Opened while a run was in flight, so `agent_end` settles it as `"aborted"`. */ + runScoped: boolean; +} + +/** + * The complete result of a closed extension dialog. Unlike an ask outcome it + * stays small — the dialog itself is not embedded, because a closed dialog + * renders only transiently for browsers that saw it open; reloads rehydrate + * open dialogs from {@link SessionStatus.pendingDialogs} alone. + */ +export interface ExtensionDialogOutcome { + dialogId: string; + reason: ExtensionDialogCloseReason; + /** Present only when `reason` is `"answered"`. */ + answer?: ExtensionDialogAnswer; + askedAt: string; + closedAt: string; +} + /** * Progress of the session startup window, where the daemon is still * constructing the agent session and no `PiAgentSession` exists yet, so @@ -773,6 +844,12 @@ export interface SessionStatus { * user. Daemon-owned, so it survives browser reload and web/API restarts. */ pendingAsk?: PendingAskUser; + /** + * The session's open extension dialogs, oldest first, when any are waiting + * for the user. Daemon-owned, so they survive browser reload and web/API + * restarts. Several may be open at once; the UI presents them as a queue. + */ + pendingDialogs?: PendingExtensionDialog[]; } export interface WorkspaceActivity { @@ -1155,6 +1232,8 @@ type SessionUiEventBody = | { type: "session.error"; message: string } | { type: "ask.opened"; ask: PendingAskUser } | { type: "ask.closed"; askId: string; reason: AskUserCloseReason } + | { type: "dialog.opened"; dialog: PendingExtensionDialog } + | { type: "dialog.closed"; dialogId: string; reason: ExtensionDialogCloseReason; answer?: ExtensionDialogAnswer } | { type: "session.name"; sessionId: string; name?: string } | { type: "session.created"; session: SessionInfo } | { type: "pi.event"; eventType: string }; From d31c40db0be3c5f8aae8cebe844cbef940709c50 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 00:49:54 +0200 Subject: [PATCH 2/9] 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 From 87f5df663f8b5812b984401e903075fcfcb5b979 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 01:16:07 +0200 Subject: [PATCH 3/9] feat(ui): wire extension dialogs into the client data layer Parse pendingDialogs on the session status and the dialog.opened / dialog.closed socket events, track them per selected session in app state (array, no supersede; closed dialogs kept with their outcome for transient rendering), and add answerDialog/cancelDialog session API methods on the dedicated dialogs routes, allowlisted for remote machines. --- src/client/src/api.ts | 2 +- src/client/src/api/clients.test.ts | 45 +++ src/client/src/api/clients.ts | 5 +- .../src/api/federatedRouteContract.test.ts | 10 + src/client/src/api/parsers.test.ts | 107 ++++++- src/client/src/api/parsers.ts | 119 ++++++- src/client/src/appState.ts | 26 +- ...sessionController.extensionDialogs.test.ts | 293 ++++++++++++++++++ .../src/controllers/sessionController.ts | 98 +++++- src/client/src/sessionSocket.test.ts | 25 ++ src/client/src/sessionSocket.ts | 6 +- src/shared/federatedRoutes.ts | 2 + 12 files changed, 725 insertions(+), 13 deletions(-) create mode 100644 src/client/src/controllers/sessionController.extensionDialogs.test.ts diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 0e595bf..47c99c1 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; -export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, PendingExtensionDialog, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, ExtensionDialogCloseReason, ExtensionDialogCloseResponse, ExtensionDialogKind, ExtensionDialogOutcome, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 137adc2..7b91404 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -321,6 +321,34 @@ describe("session API compatibility", () => { expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" }); }); + it("answers and cancels extension dialogs through encoded machine routes", async () => { + const answered = { + result: "closed", + outcome: { dialogId: "dialog 1", reason: "answered", answer: true, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" }, + sessionStatus: dialogStatusWire(), + }; + const cancelled = { result: "stale", sessionStatus: dialogStatusWire() }; + const fetchMock = stubSequenceFetch([jsonResponse(answered), jsonResponse(cancelled)]); + const ref = { id: "s /?", cwd: "/repo with spaces" }; + + await expect(sessionsApi.answerDialog(ref, "dialog 1", true, "remote /?")).resolves.toEqual({ + result: "closed", + outcome: { dialogId: "dialog 1", reason: "answered", answer: true, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" }, + sessionStatus: parsedDialogStatus(), + }); + await expect(sessionsApi.cancelDialog(ref, "dialog 1", "remote /?")).resolves.toEqual({ result: "stale", sessionStatus: parsedDialogStatus() }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [answerUrl, answerInit] = fetchCall(fetchMock, 0); + expect(answerUrl).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/dialogs/answer"); + expect(answerInit?.method).toBe("POST"); + expect(JSON.parse(requestBody(answerInit))).toEqual({ cwd: "/repo with spaces", dialogId: "dialog 1", value: true }); + const [cancelUrl, cancelInit] = fetchCall(fetchMock, 1); + expect(cancelUrl).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/dialogs/cancel"); + expect(cancelInit?.method).toBe("POST"); + expect(JSON.parse(requestBody(cancelInit))).toEqual({ cwd: "/repo with spaces", dialogId: "dialog 1" }); + }); + it("posts session tree navigation through an encoded cwd-scoped machine route", async () => { const fetchMock = stubJsonFetch({ cancelled: false, editorText: "edit this" }); const navigation = { targetId: "entry /?", expectedLeafId: "leaf-1", summary: { mode: "custom" as const, instructions: "focus on tests" } }; @@ -592,6 +620,23 @@ function sessionInfoResponse(id: string) { return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" }; } +function dialogStatusWire() { + return { + sessionId: "s /?", + isStreaming: true, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + }; +} + +// The parsed status normalizes the wire shape (queuedMessages defaults to []). +function parsedDialogStatus() { + return { ...dialogStatusWire(), queuedMessages: [] }; +} + function piWebConfigResponse(config: PiWebConfigValues) { return { path: "/tmp/pi-web/config.json", diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 09ff57f..74efc59 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,4 @@ -import type { AskUserSubmission, DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; +import type { AskUserSubmission, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; import { resolveAppUrl } from "../appUrl"; import { request } from "./http"; import { @@ -13,6 +13,7 @@ import { parseDeleted, parseDeleteWorkspaceFileResponse, parseDetached, + parseExtensionDialogCloseResponse, parseFileContentResponse, parseFileSuggestion, parseFileTreeResponse, @@ -227,6 +228,8 @@ export const sessionsApi = { dismissWarning: (session: SessionLookup, dismissId: string, machineId = "local") => request(sessionPath(session, "warnings/dismiss", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { dismissId }) }), submitAsk: (session: SessionLookup, askId: string, submission: AskUserSubmission, machineId = "local") => request(sessionPath(session, "ask/submit", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId, answers: submission.answers }) }), cancelAsk: (session: SessionLookup, askId: string, machineId = "local") => request(sessionPath(session, "ask/cancel", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId }) }), + answerDialog: (session: SessionLookup, dialogId: string, value: ExtensionDialogAnswer, machineId = "local") => request(sessionPath(session, "dialogs/answer", machineId), parseExtensionDialogCloseResponse, { method: "POST", body: sessionBody(session, { dialogId, value }) }), + cancelDialog: (session: SessionLookup, dialogId: string, machineId = "local") => request(sessionPath(session, "dialogs/cancel", machineId), parseExtensionDialogCloseResponse, { method: "POST", body: sessionBody(session, { dialogId }) }), models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse), setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }), cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }), diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 84b5871..56cb49a 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -44,6 +44,14 @@ describe("federated route contract", () => { expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("ask"))).toBe(false); }); + it("allowlists both extension dialog routes on the existing session WebSocket", () => { + expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("/dialogs/"))).toEqual([ + { method: "POST", path: "/sessions/:sessionId/dialogs/answer" }, + { method: "POST", path: "/sessions/:sessionId/dialogs/cancel" }, + ]); + expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("dialogs"))).toBe(false); + }); + it("allowlists daemon-authoritative unread HTTP routes on the existing global socket", () => { expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("unread"))).toEqual([ { method: "GET", path: "/sessions/unread" }, @@ -107,6 +115,8 @@ describe("federated route contract", () => { ignoreParseFailure(sessionsApi.dismissWarning(session, "anthropicExtraUsage", machineId)), ignoreParseFailure(sessionsApi.submitAsk(session, "ask 1", { answers: [{ id: "q1", values: ["pg"] }] }, machineId)), ignoreParseFailure(sessionsApi.cancelAsk(session, "ask 1", machineId)), + ignoreParseFailure(sessionsApi.answerDialog(session, "dialog 1", true, machineId)), + ignoreParseFailure(sessionsApi.cancelDialog(session, "dialog 1", machineId)), ignoreParseFailure(sessionsApi.models(session, machineId)), ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)), ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)), diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 41f06db..27eb0f9 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { ASK_USER_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes"; -import { parseAskUserCloseResponse, parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStartupProgressEvent, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes"; +import { parseAskUserCloseResponse, parseAuthProvidersResponse, parseCommandResult, parseExtensionDialogCloseResponse, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStartupProgressEvent, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("preserves additive interactive API-key flow hints and defaults legacy options", () => { @@ -796,6 +796,65 @@ describe("API parsers", () => { sessionStatus: statusWire(), })).toThrow("Ask answer selected an option the question never offered"); }); + + it("parses open extension dialogs on the session status, oldest first", () => { + const parsed = parseSessionStatus({ ...statusWire(), pendingDialogs: [confirmDialogWire(), selectDialogWire(), inputDialogWire()] }); + + expect(parsed.pendingDialogs).toEqual([ + { dialogId: "dialog-1", kind: "confirm", title: "Delete the build cache?", message: "This cannot be undone", askedAt: "2026-07-20T00:00:00.000Z", runScoped: true }, + { dialogId: "dialog-2", kind: "select", title: "Pick a database", options: ["Postgres", "SQLite"], askedAt: "2026-07-20T00:01:00.000Z", timeoutAt: "2026-07-20T00:06:00.000Z", runScoped: false }, + { dialogId: "dialog-3", kind: "input", title: "Name the branch", placeholder: "feature/...", askedAt: "2026-07-20T00:02:00.000Z", runScoped: false }, + ]); + }); + + it("omits pending dialogs entirely when the field is absent", () => { + expect(parseSessionStatus(statusWire()).pendingDialogs).toBeUndefined(); + }); + + it("validates an extension dialog before rendering it", () => { + const dialog = confirmDialogWire(); + expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, kind: "modal" }] })).toThrow("Invalid extension dialog kind"); + expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, title: "" }] })).toThrow("Expected non-empty string field: title"); + expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, title: "x".repeat(EXTENSION_DIALOG_TEXT_MAX_LENGTH + 1) }] })).toThrow("String field exceeds limit: title"); + expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, runScoped: "yes" }] })).toThrow("Expected boolean field: runScoped"); + expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, timeoutAt: "" }] })).toThrow("Expected non-empty string field: timeoutAt"); + expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...selectDialogWire(), options: [] }] })).toThrow("Select dialog has no options"); + expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...selectDialogWire(), options: ["a", "a"] }] })).toThrow("Duplicate dialog option"); + expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [dialog, { ...inputDialogWire(), dialogId: "dialog-1" }] })).toThrow("Duplicate dialog id"); + }); + + it("parses a closed dialog response carrying the outcome and recomputed status", () => { + const response = parseExtensionDialogCloseResponse({ + result: "closed", + outcome: dialogOutcomeWire(), + sessionStatus: statusWire(), + }); + + expect(response.result).toBe("closed"); + expect(response.outcome).toEqual({ + dialogId: "dialog-1", + reason: "answered", + answer: true, + askedAt: "2026-07-20T00:00:00.000Z", + closedAt: "2026-07-20T00:01:00.000Z", + }); + expect(response.sessionStatus.sessionId).toBe("s1"); + }); + + it("parses a stale dialog close as an ordinary race with no outcome", () => { + const response = parseExtensionDialogCloseResponse({ result: "stale", sessionStatus: statusWire() }); + + expect(response).toEqual({ result: "stale", sessionStatus: parseSessionStatus(statusWire()) }); + }); + + it("rejects dialog close responses whose outcome contradicts itself", () => { + const outcome = dialogOutcomeWire(); + expect(() => parseExtensionDialogCloseResponse({ result: "closed", sessionStatus: statusWire() })).toThrow("Dialog close response outcome mismatch"); + expect(() => parseExtensionDialogCloseResponse({ result: "stale", outcome, sessionStatus: statusWire() })).toThrow("Dialog close response outcome mismatch"); + expect(() => parseExtensionDialogCloseResponse({ result: "closed", outcome: { ...outcome, reason: "timeout" }, sessionStatus: statusWire() })).toThrow("Dialog outcome answer mismatch"); + expect(() => parseExtensionDialogCloseResponse({ result: "closed", outcome: { ...outcome, answer: 1 }, sessionStatus: statusWire() })).toThrow("Invalid extension dialog answer"); + expect(() => parseExtensionDialogCloseResponse({ result: "closed", outcome: { ...outcome, reason: "ignored" }, sessionStatus: statusWire() })).toThrow("Invalid extension dialog close reason"); + }); }); function statusWire() { @@ -845,6 +904,50 @@ function askOutcomeWire() { }; } +function confirmDialogWire() { + return { + dialogId: "dialog-1", + kind: "confirm", + title: "Delete the build cache?", + message: "This cannot be undone", + askedAt: "2026-07-20T00:00:00.000Z", + runScoped: true, + }; +} + +function selectDialogWire() { + return { + dialogId: "dialog-2", + kind: "select", + title: "Pick a database", + options: ["Postgres", "SQLite"], + askedAt: "2026-07-20T00:01:00.000Z", + timeoutAt: "2026-07-20T00:06:00.000Z", + runScoped: false, + }; +} + +function inputDialogWire() { + return { + dialogId: "dialog-3", + kind: "input", + title: "Name the branch", + placeholder: "feature/...", + askedAt: "2026-07-20T00:02:00.000Z", + runScoped: false, + }; +} + +function dialogOutcomeWire() { + return { + dialogId: "dialog-1", + reason: "answered", + answer: true, + askedAt: "2026-07-20T00:00:00.000Z", + closedAt: "2026-07-20T00:01:00.000Z", + }; +} + function sessionTreeWire() { const kinds = [ "user", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index ddec44c..59823c7 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AskUserCloseReason, type AskUserCloseResponse, type AskUserOutcome, type AskUserQuestion, type AskUserQuestionOption, type AskUserQuestionRecord, type PendingAskUser, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, EXTENSION_DIALOG_OPTION_LIMIT, EXTENSION_DIALOG_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AskUserCloseReason, type AskUserCloseResponse, type AskUserOutcome, type AskUserQuestion, type AskUserQuestionOption, type AskUserQuestionRecord, type PendingAskUser, type PendingExtensionDialog, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogKind, type ExtensionDialogOutcome, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionActivity, SessionStartupProgressEvent, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes"; import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile"; import { parseKnownPiWebCapabilities } from "../../../shared/capabilities"; @@ -332,10 +332,126 @@ export function parseAskUserCloseResponse(value: unknown): AskUserCloseResponse }; } +export function parseExtensionDialogCloseResponse(value: unknown): ExtensionDialogCloseResponse { + const record = requireRecord(value); + const result = record["result"]; + if (result !== "closed" && result !== "stale") throw new Error("Invalid dialog close result"); + const outcome = record["outcome"] === undefined ? undefined : parseExtensionDialogOutcome(record["outcome"]); + // Only the call that actually closed the dialog carries an outcome; a stale + // close reports none and is trusted for the session status alone. + if ((result === "closed") !== (outcome !== undefined)) throw new Error("Dialog close response outcome mismatch"); + return { + result, + ...(outcome === undefined ? {} : { outcome }), + sessionStatus: parseSessionStatus(record["sessionStatus"]), + }; +} + function assertUniqueStrings(values: readonly string[], label: string): void { if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`); } +function parseExtensionDialogKind(value: unknown): ExtensionDialogKind { + if (value !== "confirm" && value !== "select" && value !== "input") throw new Error("Invalid extension dialog kind"); + return value; +} + +function parseExtensionDialogCloseReason(value: unknown): ExtensionDialogCloseReason { + if (value !== "answered" && value !== "cancelled" && value !== "timeout" && value !== "aborted" && value !== "session-ended") { + throw new Error("Invalid extension dialog close reason"); + } + return value; +} + +function parseExtensionDialogAnswer(value: unknown): ExtensionDialogAnswer { + if (typeof value === "boolean") return value; + if (typeof value === "string" && value.length <= EXTENSION_DIALOG_INPUT_MAX_LENGTH) return value; + throw new Error("Invalid extension dialog answer"); +} + +function parseExtensionDialogOption(value: unknown): string { + const option = parseNonEmptyString(value); + if (option.length > EXTENSION_DIALOG_TEXT_MAX_LENGTH) throw new Error("String field exceeds limit: option"); + return option; +} + +/** + * Validate one open extension dialog. A malformed dialog must be dropped rather + * than rendered: the card parks an extension's blocking wait on the user's + * answer, so a choice list or prompt the daemon did not really send must never + * appear. + */ +function parsePendingExtensionDialog(value: unknown): PendingExtensionDialog { + const record = requireRecord(value); + const kind = parseExtensionDialogKind(record["kind"]); + const options = record["options"] === undefined + ? undefined + : boundedArrayOf(record["options"], parseExtensionDialogOption, EXTENSION_DIALOG_OPTION_LIMIT, "options"); + if (options !== undefined) assertUniqueStrings(options, "dialog option"); + if (kind === "select" && (options === undefined || options.length === 0)) throw new Error("Select dialog has no options"); + return { + dialogId: requireBoundedNonEmptyString(record, "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH), + kind, + title: requireBoundedNonEmptyString(record, "title", EXTENSION_DIALOG_TEXT_MAX_LENGTH), + ...optionalField("message", optionalBoundedNonEmptyString(record, "message", EXTENSION_DIALOG_TEXT_MAX_LENGTH)), + ...(options === undefined ? {} : { options }), + ...optionalField("placeholder", optionalBoundedNonEmptyString(record, "placeholder", EXTENSION_DIALOG_TEXT_MAX_LENGTH)), + askedAt: requireNonEmptyString(record, "askedAt"), + ...optionalField("timeoutAt", optionalNonEmptyString(record, "timeoutAt")), + runScoped: requireBoolean(record, "runScoped"), + }; +} + +function optionalPendingDialogs(value: unknown): Pick | object { + if (value === undefined) return {}; + const dialogs = arrayOf(parsePendingExtensionDialog)(value); + assertUniqueStrings(dialogs.map((dialog) => dialog.dialogId), "dialog id"); + return { pendingDialogs: dialogs }; +} + +export function parseSessionDialogOpenedEvent(value: unknown): { type: "dialog.opened"; dialog: PendingExtensionDialog } { + const record = requireRecord(value); + if (record["type"] !== "dialog.opened") throw new Error("Invalid dialog opened event type"); + return { type: "dialog.opened", dialog: parsePendingExtensionDialog(record["dialog"]) }; +} + +export function parseSessionDialogClosedEvent(value: unknown): { type: "dialog.closed"; dialogId: string; reason: ExtensionDialogCloseReason; answer?: ExtensionDialogAnswer } { + const record = requireRecord(value); + if (record["type"] !== "dialog.closed") throw new Error("Invalid dialog closed event type"); + const reason = parseExtensionDialogCloseReason(record["reason"]); + const answer = record["answer"] === undefined ? undefined : parseExtensionDialogAnswer(record["answer"]); + // Only an answered close carries a value; any other combination cannot be + // rendered honestly as the dialog's result. + if ((reason === "answered") !== (answer !== undefined)) throw new Error("Dialog closed event answer mismatch"); + return { + type: "dialog.closed", + dialogId: requireBoundedNonEmptyString(record, "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH), + reason, + ...(answer === undefined ? {} : { answer }), + }; +} + +export function parseExtensionDialogOutcome(value: unknown): ExtensionDialogOutcome { + const record = requireRecord(value); + const reason = parseExtensionDialogCloseReason(record["reason"]); + const answer = record["answer"] === undefined ? undefined : parseExtensionDialogAnswer(record["answer"]); + if ((reason === "answered") !== (answer !== undefined)) throw new Error("Dialog outcome answer mismatch"); + return { + dialogId: requireBoundedNonEmptyString(record, "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH), + reason, + ...(answer === undefined ? {} : { answer }), + askedAt: requireNonEmptyString(record, "askedAt"), + closedAt: requireNonEmptyString(record, "closedAt"), + }; +} + +function optionalNonEmptyString(record: Record, key: string): string | undefined { + const value = optionalString(record, key); + if (value === undefined) return undefined; + if (value === "") throw new Error(`Expected non-empty string field: ${key}`); + return value; +} + export function parseSessionStatus(value: unknown): SessionStatus { const record = requireRecord(value); return { @@ -354,6 +470,7 @@ export function parseSessionStatus(value: unknown): SessionStatus { ...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")), ...optionalWarnings(record["warnings"]), ...optionalPendingAsk(record["pendingAsk"]), + ...optionalPendingDialogs(record["pendingDialogs"]), }; } diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index f48f2b4..74a6abe 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -1,4 +1,4 @@ -import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PendingAskUser, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api"; +import type { AuthProviderOption, CommandOption, CommandResult, ExtensionDialogAnswer, ExtensionDialogCloseReason, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PendingAskUser, PendingExtensionDialog, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api"; import type { ChatLine } from "./components/shared"; import type { QualifiedContributionId } from "./plugins/ids"; import type { SelectedSessionNotificationInbox } from "./sessionNotifications"; @@ -37,6 +37,20 @@ export interface AppState { * dropped when the machine reports no `sessions.askUser` support. */ pendingAsk: PendingAskUser | undefined; + /** + * The selected session's open extension dialogs, derived from the + * daemon-owned {@link SessionStatus.pendingDialogs} plus live dialog events. + * Oldest first; unlike an ask, opening never supersedes, so several dialogs + * may wait at once. + */ + pendingDialogs: PendingExtensionDialog[]; + /** + * Dialogs that closed while their session was selected, kept with the close + * reason and any answer so the card can render its outcome briefly. The wire + * outcome is deliberately small, so only a browser that saw the dialog open + * can show the closed card; deselection and reloads drop these. + */ + closedDialogs: ClosedExtensionDialog[]; /** Thinking levels available for the selected session's current model. */ availableThinkingLevels: readonly string[]; sessionStatuses: Record; @@ -76,6 +90,14 @@ export interface AppState { error: string; } +/** A closed extension dialog paired with the record the browser rendered while it was open. */ +export interface ClosedExtensionDialog { + dialog: PendingExtensionDialog; + reason: ExtensionDialogCloseReason; + /** Present only when `reason` is `"answered"`. */ + answer?: ExtensionDialogAnswer; +} + export type AuthDialogState = | { step: "method" } | { step: "providers"; mode: "login"; authType?: "oauth" | "api_key"; providers: AuthProviderOption[] } @@ -151,6 +173,8 @@ export function initialAppState(): AppState { status: undefined, activity: undefined, pendingAsk: undefined, + pendingDialogs: [], + closedDialogs: [], availableThinkingLevels: [], sessionStatuses: {}, sessionActivities: {}, diff --git a/src/client/src/controllers/sessionController.extensionDialogs.test.ts b/src/client/src/controllers/sessionController.extensionDialogs.test.ts new file mode 100644 index 0000000..6417720 --- /dev/null +++ b/src/client/src/controllers/sessionController.extensionDialogs.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; +import { initialAppState } from "../appState"; +import type { ExtensionDialogCloseResponse, ExtensionDialogKind, PendingExtensionDialog } from "../api"; +import { SessionController } from "./sessionController"; +import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, status, workspace, type AppState, type SessionStatus } from "./sessionController.testSupport"; + +function dialog(dialogId: string, kind: ExtensionDialogKind = "confirm"): PendingExtensionDialog { + return { + dialogId, + kind, + title: `Dialog ${dialogId}`, + ...(kind === "confirm" ? { message: "Are you sure?" } : {}), + ...(kind === "select" ? { options: ["Postgres", "SQLite"] } : {}), + ...(kind === "input" ? { placeholder: "type here" } : {}), + askedAt: "2026-07-20T00:00:00.000Z", + runScoped: true, + }; +} + +function statusWithDialogs(sessionId: string, pendingDialogs: PendingExtensionDialog[]): SessionStatus { + return { ...status(sessionId), pendingDialogs }; +} + +function closeResponse(sessionStatus: SessionStatus, dialogId = "dialog-1"): ExtensionDialogCloseResponse { + return { + result: "closed", + outcome: { + dialogId, + reason: "answered", + answer: true, + askedAt: "2026-07-20T00:00:00.000Z", + closedAt: "2026-07-20T00:01:00.000Z", + }, + sessionStatus, + }; +} + +function selectedState(patch: Partial = {}): AppState { + return { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession], + ...patch, + }; +} + +function selectableApi(sessionStatus: SessionStatus): typeof defaultApi { + return { + ...defaultApi, + messages: () => Promise.resolve(emptyPage), + status: () => Promise.resolve(sessionStatus), + streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }), + thinkingLevels: () => Promise.resolve({ levels: [] }), + }; +} + +interface LiveHarness { + controller: SessionController; + socket: EmitSocket; + state: () => AppState; +} + +async function liveSession(patch: Partial = {}, sessionStatus = status(oldSession.id)): Promise { + const socket = new EmitSocket(); + let state = selectedState({ selectedSession: undefined, ...patch }); + const controller = new SessionController( + () => state, + (statePatch) => { state = { ...state, ...statePatch }; }, + () => undefined, + undefined, + { api: selectableApi(sessionStatus), socket }, + ); + await controller.selectSession(oldSession, { updateUrl: false }); + return { controller, socket, state: () => state }; +} + +describe("SessionController extension dialog state", () => { + it("rehydrates open dialogs from the daemon-owned status on selection", async () => { + const pending = [dialog("dialog-1"), dialog("dialog-2", "select")]; + + const harness = await liveSession({}, statusWithDialogs(oldSession.id, pending)); + + expect(harness.state().pendingDialogs).toEqual(pending); + expect(harness.state().closedDialogs).toEqual([]); + }); + + it("opens and closes cards from live dialog events without superseding other dialogs", async () => { + const harness = await liveSession(); + + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2", "input") }); + expect(harness.state().pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-1", "dialog-2"]); + + harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: true }); + expect(harness.state().pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]); + }); + + it("keeps the closed dialog's outcome so the card can render what happened", async () => { + const harness = await liveSession(); + + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1", "select") }); + harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: "SQLite" }); + + expect(harness.state().closedDialogs).toEqual([{ dialog: dialog("dialog-1", "select"), reason: "answered", answer: "SQLite" }]); + }); + + it("records a close without an answer for cancel-like reasons", async () => { + const harness = await liveSession(); + + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "aborted" }); + + expect(harness.state().closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "aborted" }]); + }); + + it("ignores a close for a dialog that is not on screen", async () => { + const harness = await liveSession(); + + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2") }); + harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" }); + + expect(harness.state().pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]); + expect(harness.state().closedDialogs).toEqual([]); + }); + + it("does not duplicate a card when the open frame is already reflected", async () => { + const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1")])); + + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + + expect(harness.state().pendingDialogs).toHaveLength(1); + }); + + it("applies a status that no longer carries a dialog as the authoritative close", async () => { + const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1")])); + expect(harness.state().pendingDialogs).toHaveLength(1); + + harness.controller.applySessionStatus(status(oldSession.id)); + + expect(harness.state().pendingDialogs).toEqual([]); + }); + + it("does not adopt another session's open dialogs", async () => { + const harness = await liveSession(); + + harness.controller.applySessionStatus(statusWithDialogs("other-session", [dialog("dialog-1")])); + + expect(harness.state().pendingDialogs).toEqual([]); + }); + + it("clears open and closed dialogs when the session is deselected", async () => { + const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1"), dialog("dialog-2")])); + harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" }); + expect(harness.state().closedDialogs).toHaveLength(1); + + harness.controller.deselectSession({ updateUrl: false }); + + expect(harness.state().pendingDialogs).toEqual([]); + expect(harness.state().closedDialogs).toEqual([]); + }); + + it("drops a closed dialog's outcome card when it is dismissed", async () => { + const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1")])); + harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" }); + expect(harness.state().closedDialogs).toHaveLength(1); + + harness.controller.dismissClosedDialog("dialog-1"); + + expect(harness.state().closedDialogs).toEqual([]); + }); +}); + +describe("SessionController extension dialog answers", () => { + it("answers a dialog, records the outcome, and applies the returned status", async () => { + const answerCalls: { dialogId: string; value: unknown; machineId: string }[] = []; + const closedStatus = status(oldSession.id); + let state = selectedState({ status: statusWithDialogs(oldSession.id, [dialog("dialog-1")]), pendingDialogs: [dialog("dialog-1")] }); + const api: typeof defaultApi = { + ...defaultApi, + answerDialog: (_session, dialogId, value, machineId) => { + answerCalls.push({ dialogId, value, machineId: machineId ?? "local" }); + return Promise.resolve(closeResponse(closedStatus)); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + await controller.answerDialog("dialog-1", true); + + expect(answerCalls).toEqual([{ dialogId: "dialog-1", value: true, machineId: "local" }]); + expect(state.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "answered", answer: true }]); + expect(state.pendingDialogs).toEqual([]); + expect(state.status).toEqual(closedStatus); + }); + + it("cancels a dialog through its own route", async () => { + const cancelCalls: string[] = []; + let state = selectedState({ pendingDialogs: [dialog("dialog-1")] }); + const api: typeof defaultApi = { + ...defaultApi, + cancelDialog: (_session, dialogId) => { + cancelCalls.push(dialogId); + return Promise.resolve({ + result: "closed" as const, + outcome: { dialogId, reason: "cancelled" as const, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" }, + sessionStatus: status(oldSession.id), + }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + await controller.cancelDialog("dialog-1"); + + expect(cancelCalls).toEqual(["dialog-1"]); + expect(state.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "cancelled" }]); + expect(state.pendingDialogs).toEqual([]); + }); + + it("trusts the status of a stale close without an error or an outcome card", async () => { + let state = selectedState({ pendingDialogs: [dialog("dialog-1")] }); + const api: typeof defaultApi = { + ...defaultApi, + answerDialog: () => Promise.resolve({ result: "stale", sessionStatus: statusWithDialogs(oldSession.id, [dialog("dialog-2")]) }), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + await controller.answerDialog("dialog-1", true); + + expect(state.error).toBe(""); + expect(state.closedDialogs).toEqual([]); + expect(state.pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]); + }); + + it("keeps the dialog open and reports the failure when the answer request fails", async () => { + let state = selectedState({ pendingDialogs: [dialog("dialog-1")] }); + const api: typeof defaultApi = { ...defaultApi, answerDialog: () => Promise.reject(new Error("answer failed")) }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + await controller.answerDialog("dialog-1", true); + + expect(state.error).toBe("Error: answer failed"); + expect(state.pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-1"]); + expect(state.closedDialogs).toEqual([]); + }); + + it("does not answer for an archived session", async () => { + const archived = { ...oldSession, archived: true as const }; + let state = selectedState({ selectedSession: archived, sessions: [archived] }); + let answered = false; + const api: typeof defaultApi = { + ...defaultApi, + answerDialog: () => { + answered = true; + return Promise.resolve(closeResponse(status(oldSession.id))); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + await controller.answerDialog("dialog-1", true); + + expect(answered).toBe(false); + }); +}); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index bc76dd9..ee2e250 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -1,5 +1,5 @@ -import { api as defaultApi, type AskUserCloseResponse, type AskUserSubmission, type CommandResult, type PendingAskUser, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type Workspace } from "../api"; -import type { AppState } from "../appState"; +import { api as defaultApi, type AskUserCloseResponse, type AskUserSubmission, type CommandResult, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogOutcome, type PendingAskUser, type PendingExtensionDialog, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type Workspace } from "../api"; +import type { AppState, ClosedExtensionDialog } from "../appState"; import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions"; import { textMessage } from "../chatMessages"; import { machineSessionKey } from "../machineKeys"; @@ -162,7 +162,7 @@ export class SessionController { // session must not cancel the in-flight upload indicator of the session // that is still sending; the per-session entry is cleared by send()'s // finally block when the request settles. - this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, availableThinkingLevels: [], treeDialog: undefined }); + this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, pendingDialogs: [], closedDialogs: [], availableThinkingLevels: [], treeDialog: undefined }); } deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) { @@ -221,6 +221,8 @@ export class SessionController { status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id], activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id], pendingAsk: session.archived === true ? undefined : this.selectedPendingAsk(this.getState().sessionStatuses[session.id], machineId), + pendingDialogs: session.archived === true ? [] : (this.getState().sessionStatuses[session.id]?.pendingDialogs ?? []), + closedDialogs: [], availableThinkingLevels: [], }); let buffered: SessionUiEvent[] | undefined; @@ -229,7 +231,7 @@ export class SessionController { const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())); if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; const history = this.transcripts.mergeHistory(transcriptKey, page); - this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined }); + this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, pendingDialogs: [], closedDialogs: [] }); this.onSelectedSessionReady?.({ machineId, session }); if (options?.updateUrl !== false) this.updateUrl(); return; @@ -666,7 +668,7 @@ export class SessionController { sessions: nextSessions, sessionStatuses: omitKeys(state.sessionStatuses, affectedIds), sessionActivities: omitKeys(state.sessionActivities, affectedIds), - ...(selectedAffected ? { status: undefined, activity: undefined, pendingAsk: undefined } : {}), + ...(selectedAffected ? { status: undefined, activity: undefined, pendingAsk: undefined, pendingDialogs: [], closedDialogs: [] } : {}), }); if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) { @@ -897,6 +899,41 @@ export class SessionController { return this.closeOpenAsk(askId, (session, machineId) => this.api.cancelAsk(session, askId, machineId)); } + /** Send the value the user gave for one of the session's open extension dialogs. */ + answerDialog(dialogId: string, value: ExtensionDialogAnswer): Promise { + return this.closeOpenDialog(dialogId, (session, machineId) => this.api.answerDialog(session, dialogId, value, machineId)); + } + + /** Close one of the session's open extension dialogs without answering it. */ + cancelDialog(dialogId: string): Promise { + return this.closeOpenDialog(dialogId, (session, machineId) => this.api.cancelDialog(session, dialogId, machineId)); + } + + private async closeOpenDialog(dialogId: string, close: (session: SessionInfo, machineId: string) => Promise): Promise { + const state = this.getState(); + const session = state.selectedSession; + if (session === undefined || session.archived === true || isClientPendingStartSessionInfo(session)) return; + const machineId = selectedMachineId(state); + const selectionSeq = this.selectionSeq; + try { + const response = await close(session, machineId); + if (!this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) return; + // When this call closed the dialog, its outcome is recorded right away so + // the card shows what the user gave; the daemon's dialog.closed event + // then finds the dialog already closed here and stays a no-op. + const outcome: ExtensionDialogOutcome | undefined = response.outcome; + if (outcome !== undefined) { + const dialog = this.getState().pendingDialogs.find((pending) => pending.dialogId === outcome.dialogId); + if (dialog !== undefined) this.recordClosedDialog({ dialog, reason: outcome.reason, ...(outcome.answer === undefined ? {} : { answer: outcome.answer }) }); + } + // Both outcomes carry the recomputed status, so no follow-up status + // request is needed to learn what the session's open dialogs are now. + this.applyStatus(response.sessionStatus); + } catch (error) { + if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) }); + } + } + private async closeOpenAsk(askId: string, close: (session: SessionInfo, machineId: string) => Promise): Promise { const state = this.getState(); const session = state.selectedSession; @@ -1076,6 +1113,8 @@ export class SessionController { status: undefined, activity, pendingAsk: undefined, + pendingDialogs: [], + closedDialogs: [], availableThinkingLevels: [], treeDialog: undefined, ...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }), @@ -1116,7 +1155,7 @@ export class SessionController { sessionActivities: omitSessionActivity(state.sessionActivities, tempId), sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id), clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id), - ...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id], pendingAsk: this.selectedPendingAsk(state.sessionStatuses[cachedSession.id], pending.machineId) } : {}), + ...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id], pendingAsk: this.selectedPendingAsk(state.sessionStatuses[cachedSession.id], pending.machineId), pendingDialogs: state.sessionStatuses[cachedSession.id]?.pendingDialogs ?? [], closedDialogs: [] } : {}), error: "", }); this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); @@ -1274,9 +1313,48 @@ export class SessionController { // The daemon owns whether an ask is open, so every status it publishes is // authoritative for the selected session's card, including its removal. ...(isSelected ? { pendingAsk: this.selectedPendingAsk(status, selectedMachineId(state)) } : {}), + // Same for extension dialogs: the status projection is authoritative for + // the open list. Closed-card outcomes are event/response-driven instead, + // so a status without the dialog simply drops it from the open list. + ...(isSelected ? { pendingDialogs: status.pendingDialogs ?? [] } : {}), }); } + private applyOpenedDialog(dialog: PendingExtensionDialog): void { + const state = this.getState(); + if (state.selectedSession === undefined) return; + // Events apply exactly once, so an id already on screen means this frame + // was already reflected (e.g. a rehydrated open) and must not duplicate + // the card. + if (state.pendingDialogs.some((pending) => pending.dialogId === dialog.dialogId)) return; + this.setState({ pendingDialogs: [...state.pendingDialogs, dialog] }); + } + + private applyClosedDialog(dialogId: string, reason: ExtensionDialogCloseReason, answer: ExtensionDialogAnswer | undefined): void { + // A close for a dialog that is not on screen is already reflected here + // (e.g. the answering browser's own response landed first), so it must not + // clear or duplicate other cards. + const dialog = this.getState().pendingDialogs.find((pending) => pending.dialogId === dialogId); + if (dialog === undefined) return; + this.recordClosedDialog({ dialog, reason, ...(answer === undefined ? {} : { answer }) }); + } + + private recordClosedDialog(closed: ClosedExtensionDialog): void { + const state = this.getState(); + if (state.closedDialogs.some((entry) => entry.dialog.dialogId === closed.dialog.dialogId)) return; + this.setState({ + pendingDialogs: state.pendingDialogs.filter((pending) => pending.dialogId !== closed.dialog.dialogId), + closedDialogs: [...state.closedDialogs, closed], + }); + } + + /** Drop a closed dialog's transient outcome card (e.g. the user dismissed it). */ + dismissClosedDialog(dialogId: string): void { + const state = this.getState(); + if (!state.closedDialogs.some((entry) => entry.dialog.dialogId === dialogId)) return; + this.setState({ closedDialogs: state.closedDialogs.filter((entry) => entry.dialog.dialogId !== dialogId) }); + } + private applyOpenedAsk(ask: PendingAskUser): void { const state = this.getState(); if (state.selectedSession === undefined) return; @@ -1368,6 +1446,14 @@ export class SessionController { this.applyClosedAsk(event.askId); return; } + if (event.type === "dialog.opened") { + this.applyOpenedDialog(event.dialog); + return; + } + if (event.type === "dialog.closed") { + this.applyClosedDialog(event.dialogId, event.reason, event.answer); + return; + } const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event); if (transcript) { this.setState({ messages: transcript }); diff --git a/src/client/src/sessionSocket.test.ts b/src/client/src/sessionSocket.test.ts index 042a827..b934610 100644 --- a/src/client/src/sessionSocket.test.ts +++ b/src/client/src/sessionSocket.test.ts @@ -131,6 +131,31 @@ describe("notification socket guards", () => { expect(parseRealtimeSocketEvent({ type: "ask.opened", ask })).toBeUndefined(); }); + it("accepts validated dialog frames and drops malformed ones", () => { + const dialog = { + dialogId: "dialog-1", + kind: "select", + title: "Pick a database", + options: ["Postgres", "SQLite"], + askedAt: "2026-07-20T00:00:00.000Z", + runScoped: true, + }; + + expect(parseSessionSocketEvent({ type: "dialog.opened", dialog })).toEqual({ type: "dialog.opened", dialog }); + expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: "SQLite" })) + .toEqual({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: "SQLite" }); + expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" })) + .toEqual({ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" }); + expect(parseSessionSocketEvent({ type: "dialog.opened", dialog: { ...dialog, kind: "modal" } })).toBeUndefined(); + expect(parseSessionSocketEvent({ type: "dialog.opened" })).toBeUndefined(); + expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "ignored" })).toBeUndefined(); + // A close whose reason disagrees with its answer cannot be rendered honestly. + expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered" })).toBeUndefined(); + expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled", answer: true })).toBeUndefined(); + // Dialog frames are per-session only, so they must not be accepted globally. + expect(parseRealtimeSocketEvent({ type: "dialog.opened", dialog })).toBeUndefined(); + }); + it("preserves existing event acceptance without treating unknown types as realtime events", () => { expect(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" }); expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined(); diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index 8dc112f..f143c59 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -1,5 +1,5 @@ import { realtimeEvents, sessionEvents } from "./api"; -import { parseSessionAskClosedEvent, parseSessionAskOpenedEvent, parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers"; +import { parseSessionAskClosedEvent, parseSessionAskOpenedEvent, parseSessionDialogClosedEvent, parseSessionDialogOpenedEvent, parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers"; import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes"; export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes"; @@ -159,6 +159,10 @@ export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefi // so they are validated rather than accepted on their type alone. if (type === "ask.opened") return safelyParseValidatedEvent(() => parseSessionAskOpenedEvent(event)); if (type === "ask.closed") return safelyParseValidatedEvent(() => parseSessionAskClosedEvent(event)); + // Dialog frames drive an interactive card the user answers on the extension's + // behalf, so they are validated rather than accepted on their type alone. + if (type === "dialog.opened") return safelyParseValidatedEvent(() => parseSessionDialogOpenedEvent(event)); + if (type === "dialog.closed") return safelyParseValidatedEvent(() => parseSessionDialogClosedEvent(event)); return isLegacySessionUiEvent(event) ? event : undefined; } diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index 32e4de2..05be3cc 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -70,6 +70,8 @@ export const FEDERATED_HTTP_ROUTES = [ { method: "POST", path: "/sessions/:sessionId/queue/clear" }, { method: "POST", path: "/sessions/:sessionId/ask/submit" }, { method: "POST", path: "/sessions/:sessionId/ask/cancel" }, + { method: "POST", path: "/sessions/:sessionId/dialogs/answer" }, + { method: "POST", path: "/sessions/:sessionId/dialogs/cancel" }, { method: "POST", path: "/sessions/:sessionId/warnings/dismiss" }, { method: "POST", path: "/sessions/:sessionId/attachments" }, { method: "POST", path: "/sessions/:sessionId/shell" }, From d738d68647cac6602a27f3bce631a752cd214d6a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 01:36:56 +0200 Subject: [PATCH 4/9] feat(ui): render extension dialogs inline in the transcript --- .../ChatView.extensionDialogs.test.ts | 126 ++++++ src/client/src/components/ChatView.ts | 91 ++++- .../components/ExtensionDialogCard.test.ts | 307 ++++++++++++++ .../src/components/ExtensionDialogCard.ts | 379 ++++++++++++++++++ .../PiWebApp.extensionDialogs.test.ts | 136 +++++++ src/client/src/components/PiWebApp.ts | 12 +- src/client/src/components/shared.ts | 1 + 7 files changed, 1046 insertions(+), 6 deletions(-) create mode 100644 src/client/src/components/ChatView.extensionDialogs.test.ts create mode 100644 src/client/src/components/ExtensionDialogCard.test.ts create mode 100644 src/client/src/components/ExtensionDialogCard.ts create mode 100644 src/client/src/components/PiWebApp.extensionDialogs.test.ts diff --git a/src/client/src/components/ChatView.extensionDialogs.test.ts b/src/client/src/components/ChatView.extensionDialogs.test.ts new file mode 100644 index 0000000..4524a6e --- /dev/null +++ b/src/client/src/components/ChatView.extensionDialogs.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PendingExtensionDialog } from "../api"; +import type { ClosedExtensionDialog } from "../appState"; +import { ChatView } from "./ChatView"; +import { ExtensionDialogCard } from "./ExtensionDialogCard"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("ChatView open extension dialogs", () => { + it("renders the oldest pending dialog at the transcript foot with a stable chat-scroll anchor", async () => { + const view = await mountView(); + const oldest = openDialog("dlg-1", "Allow file writes?"); + view.pendingDialogs = [oldest, openDialog("dlg-2", "Pick a region", { kind: "select", options: ["eu", "us"] })]; + await view.updateComplete; + + const card = requiredElement(view.shadowRoot?.querySelector(".chat > extension-dialog-card.open-dialog-card"), "open dialog card"); + expect(card).toBeInstanceOf(ExtensionDialogCard); + expect(card.getAttribute("data-scroll-anchor-id")).toBe("dialog:dlg-1"); + expect(card.dialog).toBe(oldest); + expect(view.shadowRoot?.querySelector(".queued-dialogs")?.textContent).toContain("1 more extension dialog queued"); + }); + + it("renders no queued affordance for a single pending dialog", async () => { + const view = await mountView(); + view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")]; + await view.updateComplete; + + expect(view.shadowRoot?.querySelector(".chat > extension-dialog-card.open-dialog-card")).not.toBeNull(); + expect(view.shadowRoot?.querySelector(".queued-dialogs")).toBeNull(); + }); + + it("scrolls a newly opened dialog to its start", async () => { + const view = await mountView(); + let dialogStartScrolls = 0; + let bottomScrolls = 0; + if (!Reflect.set(view, "scrollToOpenDialog", () => { dialogStartScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToOpenDialog"); + if (!Reflect.set(view, "scrollToBottom", () => { bottomScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom"); + + view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")]; + await view.updateComplete; + + expect(dialogStartScrolls).toBe(1); + expect(bottomScrolls).toBe(0); + }); + + it("forwards the answer and cancel callbacks to the open dialog card", async () => { + const view = await mountView(); + const onAnswerDialog = vi.fn(); + const onCancelDialog = vi.fn(); + view.onAnswerDialog = onAnswerDialog; + view.onCancelDialog = onCancelDialog; + view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")]; + await view.updateComplete; + + const card = requiredElement(view.shadowRoot?.querySelector("extension-dialog-card.open-dialog-card"), "open dialog card"); + void card.onAnswer?.("dlg-1", true); + void card.onCancel?.("dlg-1"); + + expect(onAnswerDialog).toHaveBeenCalledWith("dlg-1", true); + expect(onCancelDialog).toHaveBeenCalledWith("dlg-1"); + }); +}); + +describe("ChatView closed extension dialogs", () => { + it("renders closed dialogs transiently above the open one and forwards the dismiss callback", async () => { + const view = await mountView(); + const onDismissClosedDialog = vi.fn(); + view.onDismissClosedDialog = onDismissClosedDialog; + const closed = closedDialog("dlg-0", "Allow reads?", "answered", true); + view.closedDialogs = [closed]; + view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")]; + await view.updateComplete; + + const cards = [...(view.shadowRoot?.querySelectorAll(".chat > extension-dialog-card") ?? [])]; + expect(cards).toHaveLength(2); + const closedCard = requiredElement(cards[0], "closed dialog card"); + expect(closedCard.classList.contains("closed-dialog-card")).toBe(true); + expect(closedCard.getAttribute("data-scroll-anchor-id")).toBe("closed-dialog:dlg-0"); + expect(closedCard.outcome).toBe(closed); + expect(cards[1]?.classList.contains("open-dialog-card")).toBe(true); + + closedCard.onDismiss?.("dlg-0"); + expect(onDismissClosedDialog).toHaveBeenCalledWith("dlg-0"); + }); +}); + +async function mountView(): Promise { + const view = new ChatView(); + view.sessionId = "session-1"; + document.body.append(view); + await view.updateComplete; + return view; +} + +function requiredElement(value: T | null | undefined, label: string): T { + if (value === null || value === undefined) throw new Error(`Expected ${label}`); + return value; +} + +function openDialog(dialogId: string, title: string, overrides: Partial = {}): PendingExtensionDialog { + return { + dialogId, + kind: "confirm", + title, + askedAt: "2026-07-27T10:00:00.000Z", + runScoped: false, + ...overrides, + }; +} + +function closedDialog( + dialogId: string, + title: string, + reason: ClosedExtensionDialog["reason"], + answer?: ClosedExtensionDialog["answer"], +): ClosedExtensionDialog { + return { + dialog: openDialog(dialogId, title), + reason, + ...(answer === undefined ? {} : { answer }), + }; +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index b883cf1..367e023 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -7,7 +7,8 @@ import { writeClipboardText } from "../clipboard"; import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring"; import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition"; -import type { AskUserSubmission, PendingAskUser, QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api"; +import type { AskUserSubmission, PendingAskUser, PendingExtensionDialog, QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api"; +import type { ClosedExtensionDialog } from "../appState"; import { notificationAnnouncementLabel, notificationDismissLabel, @@ -27,6 +28,8 @@ import { import type { ChatLine, ChatPart } from "./shared"; import { chatStyles, renderSessionWarningIcon } from "./shared"; import "./AskUserCard"; +import "./ExtensionDialogCard"; +import type { ExtensionDialogAnswerCallback, ExtensionDialogCancelCallback, ExtensionDialogDismissCallback } from "./ExtensionDialogCard"; import "./ConversationMeter"; import "./FormattedText"; import "./ToolExecutionView"; @@ -196,6 +199,11 @@ export class ChatView extends LitElement { @property({ attribute: false }) pendingAsk?: PendingAskUser; @property({ attribute: false }) askDraftSessionId = ""; @property({ attribute: false }) onSubmitAsk?: (askId: string, submission: AskUserSubmission) => void | Promise; + @property({ attribute: false }) pendingDialogs: PendingExtensionDialog[] = []; + @property({ attribute: false }) closedDialogs: ClosedExtensionDialog[] = []; + @property({ attribute: false }) onAnswerDialog?: ExtensionDialogAnswerCallback; + @property({ attribute: false }) onCancelDialog?: ExtensionDialogCancelCallback; + @property({ attribute: false }) onDismissClosedDialog?: ExtensionDialogDismissCallback; @property({ attribute: false }) notificationInbox?: SelectedSessionNotificationView; @property({ type: Boolean }) canClearServerQueue = false; @property({ attribute: false }) onClearServerQueue?: () => void; @@ -222,6 +230,7 @@ export class ChatView extends LitElement { private loadMoreCheckFrame: number | undefined; private scrollToBottomFrame: number | undefined; private scrollToOpenAskFrame: number | undefined; + private scrollToOpenDialogFrame: number | undefined; private conversationRailFrame: number | undefined; private groupedMessagesInput?: ChatLine[]; private groupedMessagesStart = 0; @@ -284,6 +293,10 @@ export class ChatView extends LitElement { cancelAnimationFrame(this.scrollToOpenAskFrame); this.scrollToOpenAskFrame = undefined; } + if (this.scrollToOpenDialogFrame !== undefined) { + cancelAnimationFrame(this.scrollToOpenDialogFrame); + this.scrollToOpenDialogFrame = undefined; + } if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame); window.removeEventListener("resize", this.onViewportResize); window.removeEventListener("pagehide", this.onPageHide); @@ -314,6 +327,10 @@ export class ChatView extends LitElement { cancelAnimationFrame(this.scrollToOpenAskFrame); this.scrollToOpenAskFrame = undefined; } + if (this.scrollToOpenDialogFrame !== undefined) { + cancelAnimationFrame(this.scrollToOpenDialogFrame); + this.scrollToOpenDialogFrame = undefined; + } } protected override willUpdate(changed: Map): void { @@ -324,7 +341,7 @@ export class ChatView extends LitElement { this.pendingNotificationFocus = undefined; this.retainedEmptyNotificationTrayTargetKey = undefined; } - if (changed.has("messages") || changed.has("pendingAsk")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom()); + if (changed.has("messages") || changed.has("pendingAsk") || changed.has("pendingDialogs") || changed.has("closedDialogs")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom()); } protected override update(changed: Map): void { @@ -338,12 +355,14 @@ export class ChatView extends LitElement { if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false; if (changed.has("sessionId")) this.restoreScrollPosition(); const openedAsk = changed.has("pendingAsk") && this.isNewPendingAsk(changed.get("pendingAsk")); + const openedDialog = changed.has("pendingDialogs") && this.isNewOpenDialog(changed.get("pendingDialogs")); // The form uses the transcript scroller. Start a new long form at question // one rather than applying the usual live-tail scroll and landing at its end. if (!changed.has("sessionId") && openedAsk && this.pinnedToBottom) this.scrollToOpenAsk(); - else if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk")) && this.pinnedToBottom) this.scrollToBottom(); + else if (!changed.has("sessionId") && openedDialog && this.pinnedToBottom) this.scrollToOpenDialog(); + else if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk") || changed.has("pendingDialogs") || changed.has("closedDialogs")) && this.pinnedToBottom) this.scrollToBottom(); if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate(); - if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore") || changed.has("pendingAsk")) this.continuePendingScrollRestore(); + if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore") || changed.has("pendingAsk") || changed.has("pendingDialogs") || changed.has("closedDialogs")) this.continuePendingScrollRestore(); if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded(); if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget(); if (changed.has("zoomedImage")) this.syncImageZoomDialog(); @@ -383,6 +402,7 @@ export class ChatView extends LitElement { ${this.renderQueuedMessages()} ${this.renderSessionActivity()} ${this.renderOpenAsk()} + ${this.renderExtensionDialogs()} ${this.renderActivityDock()} @@ -668,6 +688,38 @@ export class ChatView extends LitElement { `; } + private renderExtensionDialogs() { + const open = this.pendingDialogs[0]; + if (open === undefined && this.closedDialogs.length === 0) return null; + const queuedCount = this.pendingDialogs.length - 1; + return html` + ${repeat( + this.closedDialogs, + (closed) => closed.dialog.dialogId, + (closed) => html` + + `, + )} + ${open === undefined ? null : html` + + ${queuedCount > 0 + ? html`

${String(queuedCount)} more extension ${queuedCount === 1 ? "dialog" : "dialogs"} queued

` + : null} + `} + `; + } + private renderSessionActivity() { if (!this.isCompacting) return null; return html` @@ -1030,6 +1082,14 @@ export class ChatView extends LitElement { && (typeof previous !== "object" || previous === null || Reflect.get(previous, "askId") !== this.pendingAsk.askId); } + private isNewOpenDialog(previous: unknown): boolean { + const oldest = this.pendingDialogs[0]; + if (oldest === undefined) return false; + if (!Array.isArray(previous)) return true; + const previousOldest: unknown = previous[0]; + return typeof previousOldest !== "object" || previousOldest === null || Reflect.get(previousOldest, "dialogId") !== oldest.dialogId; + } + private scrollToOpenAsk(): void { if (this.scrollToOpenAskFrame !== undefined) return; if (this.scrollToBottomFrame !== undefined) { @@ -1052,6 +1112,28 @@ export class ChatView extends LitElement { return true; } + private scrollToOpenDialog(): void { + if (this.scrollToOpenDialogFrame !== undefined) return; + if (this.scrollToBottomFrame !== undefined) { + cancelAnimationFrame(this.scrollToBottomFrame); + this.scrollToBottomFrame = undefined; + } + this.scrollToOpenDialogFrame = requestAnimationFrame(() => { + this.scrollToOpenDialogFrame = undefined; + this.withSuppressedScrollSave(() => { this.alignOpenDialogToTop(); }); + }); + } + + private alignOpenDialogToTop(): boolean { + const chat = this.chat; + const card = this.renderRoot.querySelector(".chat > extension-dialog-card.open-dialog-card"); + if (chat === undefined || card === null) return false; + chat.scrollTop += card.getBoundingClientRect().top - chat.getBoundingClientRect().top; + this.syncScrollMetrics(); + this.pinnedToBottom = this.isNearBottom(); + return true; + } + restoreScrollPosition() { const sessionId = this.sessionId; if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame); @@ -1060,6 +1142,7 @@ export class ChatView extends LitElement { if (this.sessionId !== sessionId) return; this.withSuppressedScrollSave(() => { if (this.pendingAsk !== undefined && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenAskToTop()) return; + if (this.pendingDialogs.length > 0 && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenDialogToTop()) return; const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() }); this.handleScrollRestoreResult(sessionId, result); }); diff --git a/src/client/src/components/ExtensionDialogCard.test.ts b/src/client/src/components/ExtensionDialogCard.test.ts new file mode 100644 index 0000000..e0af15e --- /dev/null +++ b/src/client/src/components/ExtensionDialogCard.test.ts @@ -0,0 +1,307 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PendingExtensionDialog } from "../../../shared/apiTypes"; +import type { ClosedExtensionDialog } from "../appState"; +import { + ExtensionDialogCard, + extensionDialogCloseLabel, + extensionDialogCloseSummary, + extensionDialogCountdownText, + type ExtensionDialogAnswerCallback, + type ExtensionDialogCancelCallback, + type ExtensionDialogDismissCallback, +} from "./ExtensionDialogCard"; + +afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); + localStorage.clear(); +}); + +describe("extension-dialog-card confirm dialog", () => { + it("renders the title and message and answers Yes/No or cancels through the rendered buttons", async () => { + const onAnswer = vi.fn(); + const onCancel = vi.fn(); + const card = await mountOpenDialog(openDialog({ message: "The extension wants to write files." }), { onAnswer, onCancel }); + const root = renderRoot(card); + + expect(root.querySelector("h2")?.textContent).toBe("Allow file writes?"); + expect(root.querySelector(".dialog-message")?.textContent).toBe("The extension wants to write files."); + expect(root.querySelector("input, select, textarea")).toBeNull(); + + buttonWithText(root, "Yes").click(); + await flushClose(card); + expect(onAnswer).toHaveBeenCalledWith("dlg-1", true); + + buttonWithText(root, "No").click(); + await flushClose(card); + expect(onAnswer).toHaveBeenCalledWith("dlg-1", false); + + buttonWithText(root, "Cancel").click(); + await flushClose(card); + expect(onCancel).toHaveBeenCalledWith("dlg-1"); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("disables the answer controls while a close is in flight", async () => { + let resolveAnswer: (() => void) | undefined; + const onAnswer = vi.fn(() => new Promise((resolve) => { resolveAnswer = resolve; })); + const card = await mountOpenDialog(openDialog(), { onAnswer }); + const root = renderRoot(card); + + const yes = buttonWithText(root, "Yes"); + yes.click(); + await card.updateComplete; + + expect(yes.disabled).toBe(true); + expect(buttonWithText(root, "No").disabled).toBe(true); + expect(buttonWithText(root, "Cancel").disabled).toBe(true); + + resolveAnswer?.(); + await flushClose(card); + expect(yes.disabled).toBe(false); + expect(onAnswer).toHaveBeenCalledOnce(); + }); +}); + +describe("extension-dialog-card select dialog", () => { + it("answers with the clicked option", async () => { + const onAnswer = vi.fn(); + const card = await mountOpenDialog(openDialog({ + kind: "select", + title: "Deploy where?", + options: ["Staging", "Production"], + }), { onAnswer }); + const root = renderRoot(card); + + expect(buttonsWithText(root, "Yes")).toHaveLength(0); + buttonWithText(root, "Production").click(); + await Promise.resolve(); + + expect(onAnswer).toHaveBeenCalledWith("dlg-1", "Production"); + expect(onAnswer).toHaveBeenCalledOnce(); + }); +}); + +describe("extension-dialog-card input dialog", () => { + it("sends the typed text and keeps the placeholder and length bound", async () => { + const onAnswer = vi.fn(); + const card = await mountOpenDialog(openDialog({ + kind: "input", + title: "Name the branch", + placeholder: "feature/…", + }), { onAnswer }); + const root = renderRoot(card); + const input = requiredElement(root.querySelector("input"), "dialog input"); + + expect(input.placeholder).toBe("feature/…"); + expect(input.maxLength).toBe(4000); + + input.value = "feature/dialogs"; + input.dispatchEvent(new Event("input", { bubbles: true, composed: true })); + await card.updateComplete; + buttonWithText(root, "Send").click(); + await Promise.resolve(); + + expect(onAnswer).toHaveBeenCalledWith("dlg-1", "feature/dialogs"); + }); + + it("sends an empty string without typing", async () => { + const onAnswer = vi.fn(); + const card = await mountOpenDialog(openDialog({ kind: "input", title: "Notes?" }), { onAnswer }); + const root = renderRoot(card); + + const send = buttonWithText(root, "Send"); + expect(send.disabled).toBe(false); + send.click(); + await Promise.resolve(); + + expect(onAnswer).toHaveBeenCalledWith("dlg-1", ""); + }); + + it("keeps a half-typed answer when the same dialog is re-projected from a status refresh", async () => { + const card = await mountOpenDialog(openDialog({ kind: "input", title: "Notes?" })); + const root = renderRoot(card); + const input = requiredElement(root.querySelector("input"), "dialog input"); + input.value = "half typed"; + input.dispatchEvent(new Event("input", { bubbles: true, composed: true })); + await card.updateComplete; + + card.dialog = { ...openDialog({ kind: "input", title: "Notes?" }) }; + await card.updateComplete; + + expect(requiredElement(root.querySelector("input"), "dialog input").value).toBe("half typed"); + }); +}); + +describe("extension-dialog-card countdown", () => { + it("shows the remaining time and ticks down each second", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z")); + const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" })); + const root = renderRoot(card); + const status = requiredElement(root.querySelector("[role='status']"), "countdown status"); + + expect(status.textContent).toBe("Auto-cancels in 1m 30s"); + + await vi.advanceTimersByTimeAsync(30_000); + await card.updateComplete; + expect(status.textContent).toBe("Auto-cancels in 1m 0s"); + }); + + it("renders no countdown when the dialog waits forever", async () => { + vi.useFakeTimers(); + const card = await mountOpenDialog(openDialog()); + const root = renderRoot(card); + + expect(root.querySelector("[role='status']")).toBeNull(); + }); + + it("stops ticking once the dialog closes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z")); + const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" })); + card.outcome = closedDialog("timeout"); + await card.updateComplete; + + const before = renderRoot(card).textContent; + await vi.advanceTimersByTimeAsync(5_000); + await card.updateComplete; + + expect(renderRoot(card).textContent).toBe(before); + expect(renderRoot(card).querySelector("[role='status']")).toBeNull(); + }); +}); + +describe("extension-dialog-card closed outcome", () => { + it("shows the given answer and dismisses through the dismiss control", async () => { + const onDismiss = vi.fn(); + const card = new ExtensionDialogCard(); + card.outcome = closedDialog("answered", true); + card.onDismiss = onDismiss; + document.body.append(card); + await card.updateComplete; + const root = renderRoot(card); + + expect(root.querySelector(".header-status")?.textContent).toBe("Answered"); + expect(root.querySelector(".closed-summary")?.textContent).toBe("Answered: Yes"); + expect(root.querySelector("input, select, textarea")).toBeNull(); + expect(buttonsWithText(root, "Yes")).toHaveLength(0); + + buttonWithText(root, "Dismiss").click(); + expect(onDismiss).toHaveBeenCalledWith("dlg-1"); + }); + + it("shows the timeout outcome without an answer", async () => { + const card = new ExtensionDialogCard(); + card.outcome = closedDialog("timeout"); + document.body.append(card); + await card.updateComplete; + const root = renderRoot(card); + + expect(root.querySelector(".header-status")?.textContent).toBe("Timed out"); + expect(root.querySelector(".closed-summary")?.textContent).toContain("timed out"); + }); +}); + +describe("extensionDialogCountdownText", () => { + const now = Date.parse("2026-07-27T10:00:00.000Z"); + + it("is undefined without a deadline or with an unparseable one", () => { + expect(extensionDialogCountdownText(undefined, now)).toBeUndefined(); + expect(extensionDialogCountdownText("not-a-date", now)).toBeUndefined(); + }); + + it("formats seconds, minutes, and hours", () => { + expect(extensionDialogCountdownText("2026-07-27T10:00:45.000Z", now)).toBe("Auto-cancels in 45s"); + expect(extensionDialogCountdownText("2026-07-27T10:05:00.000Z", now)).toBe("Auto-cancels in 5m 0s"); + expect(extensionDialogCountdownText("2026-07-27T11:02:00.000Z", now)).toBe("Auto-cancels in 1h 2m"); + }); + + it("stays display-only once the deadline has passed", () => { + expect(extensionDialogCountdownText("2026-07-27T09:59:59.000Z", now)).toBe("Auto-cancel imminent"); + }); +}); + +describe("extensionDialogCloseLabel and extensionDialogCloseSummary", () => { + it("labels every close reason", () => { + expect(extensionDialogCloseLabel("answered")).toBe("Answered"); + expect(extensionDialogCloseLabel("cancelled")).toBe("Cancelled"); + expect(extensionDialogCloseLabel("timeout")).toBe("Timed out"); + expect(extensionDialogCloseLabel("aborted")).toBe("Aborted"); + expect(extensionDialogCloseLabel("session-ended")).toBe("Session ended"); + }); + + it("summarizes answers by kind", () => { + expect(extensionDialogCloseSummary(closedDialog("answered", false))).toBe("Answered: No"); + expect(extensionDialogCloseSummary(closedDialog("answered", "Staging"))).toBe("Answered: Staging"); + expect(extensionDialogCloseSummary(closedDialog("answered", ""))).toBe("Answered with an empty response."); + }); + + it("summarizes closes without an answer", () => { + expect(extensionDialogCloseSummary(closedDialog("cancelled"))).toBe("Dismissed without an answer."); + expect(extensionDialogCloseSummary(closedDialog("timeout"))).toContain("timed out"); + expect(extensionDialogCloseSummary(closedDialog("aborted"))).toContain("run ended"); + expect(extensionDialogCloseSummary(closedDialog("session-ended"))).toContain("session ended"); + }); +}); + +async function mountOpenDialog( + dialog: PendingExtensionDialog, + callbacks: { onAnswer?: ExtensionDialogAnswerCallback; onCancel?: ExtensionDialogCancelCallback } = {}, +): Promise { + const card = new ExtensionDialogCard(); + card.dialog = dialog; + if (callbacks.onAnswer !== undefined) card.onAnswer = callbacks.onAnswer; + if (callbacks.onCancel !== undefined) card.onCancel = callbacks.onCancel; + document.body.append(card); + await card.updateComplete; + return card; +} + +function renderRoot(card: ExtensionDialogCard): ShadowRoot { + return requiredElement(card.shadowRoot, "extension-dialog-card shadow root"); +} + +function buttonWithText(root: ShadowRoot, text: string): HTMLButtonElement { + const matches = buttonsWithText(root, text); + if (matches.length !== 1) throw new Error(`Expected exactly one button named ${text}, found ${String(matches.length)}`); + const match = matches[0]; + return requiredElement(match, `button named ${text}`); +} + +function buttonsWithText(root: ShadowRoot, text: string): HTMLButtonElement[] { + return [...root.querySelectorAll("button")].filter((candidate) => candidate.textContent.trim() === text); +} + +async function flushClose(card: ExtensionDialogCard): Promise { + // The card's close promise chain settles over several microtasks; a macrotask + // flush waits for all of them plus the state change they schedule. + await new Promise((resolve) => { setTimeout(resolve, 0); }); + await card.updateComplete; +} + +function requiredElement(value: T | null | undefined, label: string): T { + if (value === null || value === undefined) throw new Error(`Expected ${label}`); + return value; +} + +function openDialog(overrides: Partial = {}): PendingExtensionDialog { + return { + dialogId: "dlg-1", + kind: "confirm", + title: "Allow file writes?", + askedAt: "2026-07-27T10:00:00.000Z", + runScoped: false, + ...overrides, + }; +} + +function closedDialog(reason: ClosedExtensionDialog["reason"], answer?: ClosedExtensionDialog["answer"]): ClosedExtensionDialog { + return { + dialog: openDialog(), + reason, + ...(answer === undefined ? {} : { answer }), + }; +} diff --git a/src/client/src/components/ExtensionDialogCard.ts b/src/client/src/components/ExtensionDialogCard.ts new file mode 100644 index 0000000..2b861c2 --- /dev/null +++ b/src/client/src/components/ExtensionDialogCard.ts @@ -0,0 +1,379 @@ +import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { + EXTENSION_DIALOG_INPUT_MAX_LENGTH, + type ExtensionDialogAnswer, + type ExtensionDialogCloseReason, + type PendingExtensionDialog, +} from "../../../shared/apiTypes"; +import type { ClosedExtensionDialog } from "../appState"; + +export type ExtensionDialogAnswerCallback = (dialogId: string, value: ExtensionDialogAnswer) => void | Promise; +export type ExtensionDialogCancelCallback = (dialogId: string) => void | Promise; +export type ExtensionDialogDismissCallback = (dialogId: string) => void; + +const COUNTDOWN_TICK_MS = 1_000; + +/** Header status label for a closed extension dialog. */ +export function extensionDialogCloseLabel(reason: ExtensionDialogCloseReason): string { + switch (reason) { + case "answered": return "Answered"; + case "cancelled": return "Cancelled"; + case "timeout": return "Timed out"; + case "aborted": return "Aborted"; + case "session-ended": return "Session ended"; + } +} + +/** One-line summary of what a closed dialog resolved to, for the outcome card. */ +export function extensionDialogCloseSummary(closed: ClosedExtensionDialog): string { + switch (closed.reason) { + case "answered": { + const answer = closed.answer; + // An answered close without an answer value breaks the wire contract; + // the card still renders rather than crashing the transcript. + if (answer === undefined) return "Closed without an answer."; + if (typeof answer === "boolean") return `Answered: ${answer ? "Yes" : "No"}`; + return answer === "" ? "Answered with an empty response." : `Answered: ${answer}`; + } + case "cancelled": return "Dismissed without an answer."; + case "timeout": return "No answer was given before the dialog timed out."; + case "aborted": return "The run ended before this dialog was answered."; + case "session-ended": return "The session ended before this dialog was answered."; + } +} + +/** + * Remaining-time label for an open dialog's auto-cancel deadline. Display + * only: the daemon owns the real timeout and publishes `dialog.closed`, so a + * card whose countdown reaches zero simply waits for that event. + */ +export function extensionDialogCountdownText(timeoutAt: string | undefined, nowMs: number): string | undefined { + if (timeoutAt === undefined) return undefined; + const deadline = Date.parse(timeoutAt); + if (!Number.isFinite(deadline)) return undefined; + const remainingMs = deadline - nowMs; + if (remainingMs <= 0) return "Auto-cancel imminent"; + const seconds = Math.ceil(remainingMs / 1000); + if (seconds >= 3600) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.round((seconds % 3600) / 60); + return `Auto-cancels in ${String(hours)}h ${String(minutes)}m`; + } + if (seconds >= 60) { + const minutes = Math.floor(seconds / 60); + return `Auto-cancels in ${String(minutes)}m ${String(seconds % 60)}s`; + } + return `Auto-cancels in ${String(seconds)}s`; +} + +/** + * One extension dialog opened by `ctx.ui.confirm()`, `ctx.ui.select()`, or + * `ctx.ui.input()`. + * + * The card owns only browser-local form state (the half-typed input, the + * in-flight close flag, the display-only countdown); the daemon remains the + * source of truth for whether the dialog is open. Closed mode renders the + * transient outcome for a browser that saw the dialog open. + */ +@customElement("extension-dialog-card") +export class ExtensionDialogCard extends LitElement { + @property({ attribute: false }) dialog?: PendingExtensionDialog; + @property({ attribute: false }) outcome?: ClosedExtensionDialog; + @property({ attribute: false }) onAnswer?: ExtensionDialogAnswerCallback; + @property({ attribute: false }) onCancel?: ExtensionDialogCancelCallback; + @property({ attribute: false }) onDismiss?: ExtensionDialogDismissCallback; + + @state() private inputValue = ""; + @state() private closing = false; + @state() private countdownNow = 0; + private dialogIdentity: string | undefined; + private countdownTimer: number | undefined; + + override connectedCallback(): void { + super.connectedCallback(); + this.syncCountdownTimer(); + } + + override disconnectedCallback(): void { + this.stopCountdownTimer(); + super.disconnectedCallback(); + } + + protected override willUpdate(changed: PropertyValues): void { + if (!changed.has("dialog") && !changed.has("outcome")) return; + // Identity is keyed by dialogId, not object identity: status refreshes + // re-project the same open dialog as a new object and must not wipe a + // half-typed answer or an in-flight close. + const identity = this.currentIdentity(); + if (identity !== this.dialogIdentity) { + this.dialogIdentity = identity; + this.inputValue = ""; + this.closing = false; + } + this.syncCountdownTimer(); + } + + override render(): TemplateResult | null { + if (this.outcome !== undefined) return this.renderClosed(this.outcome); + if (this.dialog !== undefined) return this.renderOpen(this.dialog); + return null; + } + + private renderOpen(dialog: PendingExtensionDialog): TemplateResult { + const countdown = extensionDialogCountdownText(dialog.timeoutAt, this.countdownNow === 0 ? Date.now() : this.countdownNow); + return html` +
+
+

${dialog.title}

+ ${countdown === undefined + ? null + : html`${countdown}`} +
+ ${this.renderOpenBody(dialog)} +
+ `; + } + + private renderOpenBody(dialog: PendingExtensionDialog): TemplateResult { + if (dialog.kind === "select") return this.renderSelectBody(dialog); + if (dialog.kind === "input") return this.renderInputBody(dialog); + return this.renderConfirmBody(dialog); + } + + private renderConfirmBody(dialog: PendingExtensionDialog): TemplateResult { + return html` + ${dialog.message === undefined ? null : html`

${dialog.message}

`} +
+ + + +
+ `; + } + + private renderSelectBody(dialog: PendingExtensionDialog): TemplateResult { + return html` +
+ ${(dialog.options ?? []).map((option) => html` + + `)} +
+
+ +
+ `; + } + + private renderInputBody(dialog: PendingExtensionDialog): TemplateResult { + return html` +
{ this.submitInput(event, dialog); }}> + { this.changeInput(event); }} + /> +
+ + +
+
+ `; + } + + private renderClosed(closed: ClosedExtensionDialog): TemplateResult { + return html` +
+
+

${closed.dialog.title}

+ ${extensionDialogCloseLabel(closed.reason)} +
+

${extensionDialogCloseSummary(closed)}

+
+ +
+
+ `; + } + + private answerDialog(dialog: PendingExtensionDialog, value: ExtensionDialogAnswer): void { + this.closeWith(dialog, () => this.onAnswer?.(dialog.dialogId, value)); + } + + private cancelDialog(dialog: PendingExtensionDialog): void { + this.closeWith(dialog, () => this.onCancel?.(dialog.dialogId)); + } + + private submitInput(event: SubmitEvent, dialog: PendingExtensionDialog): void { + event.preventDefault(); + // An empty string is a valid input answer, so Send stays enabled. + this.answerDialog(dialog, this.inputValue); + } + + private closeWith(dialog: PendingExtensionDialog, close: () => void | Promise): void { + if (this.closing) return; + this.closing = true; + const dialogId = dialog.dialogId; + void Promise.resolve() + .then(close) + .catch(() => { + // The parent controller owns the visible transport error. Keeping this + // card usable is the only recovery needed at this boundary. + }) + .finally(() => { + if (this.dialog?.dialogId === dialogId) this.closing = false; + }); + } + + private changeInput(event: Event): void { + const input = event.currentTarget; + if (!(input instanceof HTMLInputElement)) return; + this.inputValue = input.value; + } + + private dismissClosed(closed: ClosedExtensionDialog): void { + this.onDismiss?.(closed.dialog.dialogId); + } + + private currentIdentity(): string | undefined { + if (this.outcome !== undefined) return `closed:${this.outcome.dialog.dialogId}`; + if (this.dialog !== undefined) return `open:${this.dialog.dialogId}`; + return undefined; + } + + private syncCountdownTimer(): void { + const needsTick = this.isConnected && this.outcome === undefined && this.dialog?.timeoutAt !== undefined; + if (needsTick && this.countdownTimer === undefined) { + this.countdownNow = Date.now(); + this.countdownTimer = window.setInterval(() => { this.countdownNow = Date.now(); }, COUNTDOWN_TICK_MS); + return; + } + if (!needsTick) this.stopCountdownTimer(); + } + + private stopCountdownTimer(): void { + if (this.countdownTimer === undefined) return; + window.clearInterval(this.countdownTimer); + this.countdownTimer = undefined; + } + + static override styles = css` + :host { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0 0 14px; + color: var(--pi-text); + font: 14px system-ui, sans-serif; + container-type: inline-size; + } + .card { + border: 1px solid var(--pi-border); + border-radius: 10px; + background: var(--pi-surface); + } + .card-header { + position: sticky; + top: var(--pi-chat-sticky-top, 0px); + z-index: 6; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 22px; + padding: 8px 16px 7px; + border-bottom: 1px solid var(--pi-border-muted); + border-radius: 9px 9px 0 0; + background: var(--pi-surface); + box-shadow: 0 8px 18px var(--pi-shadow-soft); + } + h2, p { margin-top: 0; } + h2 { + min-width: 0; + margin-bottom: 0; + font-size: 14px; + font-weight: 650; + line-height: 1.35; + overflow-wrap: anywhere; + } + .header-status { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; text-align: end; } + .header-status.answered { color: var(--pi-success); } + .header-status.timeout, .header-status.aborted, .header-status.session-ended { color: var(--pi-warning); } + .dialog-message { + margin: 0; + padding: 12px 16px; + line-height: 1.4; + overflow-wrap: anywhere; + } + .dialog-options { display: grid; gap: 7px; padding: 12px 16px; } + .option-button { + display: block; + width: 100%; + text-align: start; + line-height: 1.35; + overflow-wrap: anywhere; + } + .option-button:hover:not(:disabled) { border-color: var(--pi-accent); background: var(--pi-surface-hover); } + .dialog-input-form { display: grid; } + .dialog-input { + box-sizing: border-box; + width: calc(100% - 32px); + margin: 12px 16px 0; + border: 1px solid var(--pi-border); + border-radius: 8px; + background: var(--pi-bg); + color: var(--pi-text); + padding: 8px; + font: var(--pi-control-font-size, 16px)/1.4 var(--pi-control-font-family, system-ui, sans-serif); + } + .dialog-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + border-top: 1px solid var(--pi-border-muted); + padding: 12px 16px; + } + .dialog-message + .dialog-footer, .dialog-options + .dialog-footer { border-top: 0; } + button { + border: 1px solid var(--pi-border); + border-radius: 8px; + background: var(--pi-surface); + color: var(--pi-text); + padding: 7px 12px; + font: inherit; + cursor: pointer; + } + button:hover:not(:disabled) { background: var(--pi-surface-hover); } + button:disabled { cursor: wait; opacity: .65; } + button:focus-visible, .dialog-input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; } + .primary-action { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast, white); font-weight: 650; } + .primary-action:hover:not(:disabled) { background: color-mix(in srgb, var(--pi-accent) 86%, white); } + .closed-summary { + margin: 0; + padding: 12px 16px; + color: var(--pi-muted); + font-size: 13px; + line-height: 1.4; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + @container (max-width: 580px) { + .primary-action { min-height: 42px; } + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "extension-dialog-card": ExtensionDialogCard; + } +} diff --git a/src/client/src/components/PiWebApp.extensionDialogs.test.ts b/src/client/src/components/PiWebApp.extensionDialogs.test.ts new file mode 100644 index 0000000..075f000 --- /dev/null +++ b/src/client/src/components/PiWebApp.extensionDialogs.test.ts @@ -0,0 +1,136 @@ +import type { TemplateResult } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExtensionDialogAnswer, PendingExtensionDialog, SessionInfo, SessionStatus } from "../api"; +import { initialAppState, type AppState, type ClosedExtensionDialog } from "../appState"; +import { SessionController } from "../controllers/sessionController"; +// Template inspection here is the escape hatch for verifying the chat-view +// dialog callback wiring in a node environment (no DOM harness), mirroring +// PiWebApp.clearQueue.test.ts. See templateInspection.testSupport for the +// proportionality rationale. +import { templateValueAfterMarker } from "../templateInspection.testSupport"; +import { PiWebApp } from "./PiWebApp"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("PiWebApp extension-dialog wiring", () => { + it("passes dialog state and stable SessionController callbacks through to chat-view", () => { + const app = createApp(); + const state = stateWithDialogs(); + setAppState(app, state); + const controller = appSessionController(app); + const answerDialog = vi.spyOn(controller, "answerDialog").mockResolvedValue(undefined); + const cancelDialog = vi.spyOn(controller, "cancelDialog").mockResolvedValue(undefined); + const dismissClosedDialog = vi.spyOn(controller, "dismissClosedDialog").mockReturnValue(undefined); + + const firstRender = renderChatView(app, state); + const secondRender = renderChatView(app, state); + const onAnswer = templateDialogCallback(firstRender, ".onAnswerDialog="); + const onCancel = templateDialogCallback(firstRender, ".onCancelDialog="); + const onDismiss = templateDialogCallback(firstRender, ".onDismissClosedDialog="); + + expect(templateValueAfterMarker(firstRender, ".pendingDialogs=")).toBe(state.pendingDialogs); + expect(templateValueAfterMarker(firstRender, ".closedDialogs=")).toBe(state.closedDialogs); + expect(templateDialogCallback(secondRender, ".onAnswerDialog=")).toBe(onAnswer); + expect(templateDialogCallback(secondRender, ".onCancelDialog=")).toBe(onCancel); + expect(templateDialogCallback(secondRender, ".onDismissClosedDialog=")).toBe(onDismiss); + + onAnswer("dlg-1", true); + onCancel("dlg-2"); + onDismiss("dlg-0"); + expect(answerDialog).toHaveBeenCalledWith("dlg-1", true); + expect(cancelDialog).toHaveBeenCalledWith("dlg-2"); + expect(dismissClosedDialog).toHaveBeenCalledWith("dlg-0"); + }); +}); + +type RenderChatView = (this: PiWebApp, state: AppState, session: SessionInfo) => TemplateResult; +type DialogCallback = (dialogId: string, value?: ExtensionDialogAnswer) => void; + +function createApp(): PiWebApp { + const storage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + }; + vi.stubGlobal("window", { location: { search: "" }, localStorage: storage }); + return new PiWebApp(); +} + +function stateWithDialogs(): AppState { + const session: SessionInfo = { + id: "session-1", + cwd: "/repo", + path: "/repo/session-1.jsonl", + created: "2026-07-27T00:00:00.000Z", + modified: "2026-07-27T00:00:00.000Z", + messageCount: 1, + firstMessage: "hello", + }; + const open: PendingExtensionDialog = { + dialogId: "dlg-1", + kind: "confirm", + title: "Allow file writes?", + askedAt: "2026-07-27T10:00:00.000Z", + runScoped: false, + }; + const closed: ClosedExtensionDialog = { + dialog: { ...open, dialogId: "dlg-0", title: "Allow reads?" }, + reason: "answered", + answer: true, + }; + return { + ...initialAppState(), + selectedSession: session, + status: dialogStatus(), + pendingDialogs: [open], + closedDialogs: [closed], + }; +} + +function dialogStatus(): SessionStatus { + return { + sessionId: "session-1", + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + queuedMessages: [], + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + }; +} + +function setAppState(app: PiWebApp, state: AppState): void { + if (!Reflect.set(app, "state", state)) throw new Error("Could not set PiWebApp state"); +} + +function appSessionController(app: PiWebApp): SessionController { + const controller: unknown = Reflect.get(app, "sessions"); + if (!(controller instanceof SessionController)) throw new Error("PiWebApp SessionController was unavailable"); + return controller; +} + +function renderChatView(app: PiWebApp, state: AppState): TemplateResult { + const method: unknown = Reflect.get(app, "renderChatView"); + if (!isRenderChatView(method)) throw new Error("PiWebApp.renderChatView is not callable"); + const session = state.selectedSession; + if (session === undefined) throw new Error("Expected a selected session"); + return method.call(app, state, session); +} + +function isRenderChatView(value: unknown): value is RenderChatView { + return typeof value === "function"; +} + +function templateDialogCallback(template: TemplateResult, marker: string): DialogCallback { + const value = templateValueAfterMarker(template, marker); + if (!isDialogCallback(value)) throw new Error(`Expected callback after ${marker}`); + return value; +} + +function isDialogCallback(value: unknown): value is DialogCallback { + return typeof value === "function"; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 0672518..34727ca 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type AskUserSubmission, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; +import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type AskUserSubmission, type ExtensionDialogAnswer, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { isSessionActive } from "../../../shared/activity"; @@ -2147,6 +2147,14 @@ export class PiWebApp extends LitElement { private readonly handleSubmitAsk = (askId: string, submission: AskUserSubmission): Promise => this.sessions.submitAsk(askId, submission); + private readonly handleAnswerDialog = (dialogId: string, value: ExtensionDialogAnswer): Promise => this.sessions.answerDialog(dialogId, value); + + private readonly handleCancelDialog = (dialogId: string): Promise => this.sessions.cancelDialog(dialogId); + + private readonly handleDismissClosedDialog = (dialogId: string): void => { + this.sessions.dismissClosedDialog(dialogId); + }; + private readonly handleDismissNotification = (notificationId: string): void => { void this.notifications.dismissNotification(notificationId); }; @@ -2172,7 +2180,7 @@ export class PiWebApp extends LitElement { private renderChatView(state: AppState, session: SessionInfo) { return html` - 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .pendingAsk=${state.pendingAsk} .askDraftSessionId=${machineSessionKey(selectedMachineId(state), session.id)} .onSubmitAsk=${this.handleSubmitAsk} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .warningsVisible=${!this.sessionWarningVisibility.collapsed} .onToggleWarnings=${this.handleToggleWarnings} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> + 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .pendingAsk=${state.pendingAsk} .pendingDialogs=${state.pendingDialogs} .closedDialogs=${state.closedDialogs} .onAnswerDialog=${this.handleAnswerDialog} .onCancelDialog=${this.handleCancelDialog} .onDismissClosedDialog=${this.handleDismissClosedDialog} .askDraftSessionId=${machineSessionKey(selectedMachineId(state), session.id)} .onSubmitAsk=${this.handleSubmitAsk} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .warningsVisible=${!this.sessionWarningVisibility.collapsed} .onToggleWarnings=${this.handleToggleWarnings} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> `; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index bd4d75d..41b93c4 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -421,6 +421,7 @@ export const chatStyles = css` .queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--pi-border); } .queued-message:first-of-type { padding-top: 0; border-top: 0; } .queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } + .queued-dialogs { margin: -8px 0 14px; padding: 0 4px; color: var(--pi-muted); font-size: 12px; text-align: center; } .session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); color: var(--pi-text); overflow: hidden; } .session-activity.compacting { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); } .session-activity strong { color: var(--pi-purple); } From 8a429e45faac02552deb0871b0b6dc2c843d532a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 08:47:36 +0200 Subject: [PATCH 5/9] fix(sessions): settle run-scoped extension dialogs at abort-request time A user abort while a tool_call dialog is parked deadlocked the dialog until its timeout: pi's agent loop waits for the parked dialog handler before emitting agent_end, and run-scoped dialogs were settled only on agent_end. Settle them synchronously at abort-request time, before awaiting the runtime abort, so a hung or failing abort cannot strand the parked waiter. Keep the agent_end settlement as the run-crash backstop; the store makes the double settlement a stale no-op. --- .../piSessionService.extensionDialogs.test.ts | 99 +++++++++++++++++++ src/server/sessions/piSessionService.ts | 17 +++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/server/sessions/piSessionService.extensionDialogs.test.ts b/src/server/sessions/piSessionService.extensionDialogs.test.ts index 51b91be..88a137f 100644 --- a/src/server/sessions/piSessionService.extensionDialogs.test.ts +++ b/src/server/sessions/piSessionService.extensionDialogs.test.ts @@ -403,6 +403,105 @@ describe("PiSessionService extension dialog run end and teardown", () => { }); }); +describe("PiSessionService extension dialog abort request", () => { + it("settles a parked run-scoped dialog as aborted when an abort is requested", 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?"); + + await service.abort(sessionRef(ACTIVE_SESSION_ID)); + + await expect(consent).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: "aborted" }, + ]); + const statuses = events.sessionEvents.flatMap(({ event }) => (event.type === "status.update" ? [event.status] : [])); + expect(statuses.at(-1)?.pendingDialogs).toBeUndefined(); + expect(fake.calls.abort).toBe(1); + await service.dispose(); + }); + + it("settles the dialog before the runtime abort completes, so a parked handler cannot deadlock it", async () => { + const { service, store, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + fake.session.isStreaming = true; + // Model pi's agent loop parked behind the dialog handler: the runtime + // abort can only finish once the handler (and so the dialog) has ended. + const healthyAbort: typeof fake.session.abort = () => Promise.resolve(); + let releaseAbort: (() => void) | undefined; + fake.session.abort = () => + new Promise((resolve) => { + releaseAbort = resolve; + }); + const consent = ui.confirm("Run consent", "Allow this tool call?"); + + const aborting = service.abort(sessionRef(ACTIVE_SESSION_ID)); + + await expect(consent).resolves.toBe(false); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + if (releaseAbort === undefined) throw new Error("runtime abort was not requested"); + releaseAbort(); + await aborting; + fake.session.abort = healthyAbort; + await service.dispose(); + }); + + it("settles the dialog even when the runtime abort itself fails", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + fake.session.isStreaming = true; + const healthyAbort: typeof fake.session.abort = () => Promise.resolve(); + fake.session.abort = () => Promise.reject(new Error("abort blew up")); + const consent = ui.confirm("Run consent", "Allow this tool call?"); + + await expect(service.abort(sessionRef(ACTIVE_SESSION_ID))).rejects.toThrow("abort blew up"); + + await expect(consent).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: "aborted" }, + ]); + fake.session.abort = healthyAbort; + await service.dispose(); + }); + + it("leaves idle-opened dialogs parked across an abort request", async () => { + const { service, store, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + const idle = ui.input("Session note?"); + + await service.abort(sessionRef(ACTIVE_SESSION_ID)); + + await expect(settledValue(idle)).resolves.toEqual({ settled: false }); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([expect.objectContaining({ dialogId: "dialog-1" })]); + expect(dialogEvents(events).map(({ event }) => event)).toEqual([ + { type: "dialog.opened", dialog: openDialog(events) }, + ]); + await service.dispose(); + }); + + it("does not close the dialog a second time when agent_end arrives after the abort", async () => { + const { service, events, fake } = dialogService(); + const ui = await boundUiContext(service, fake); + fake.session.isStreaming = true; + const consent = ui.confirm("Run consent", "Allow this tool call?"); + + await service.abort(sessionRef(ACTIVE_SESSION_ID)); + fake.emit({ type: "agent_end" }); + + await expect(consent).resolves.toBe(false); + expect(dialogEvents(events).map(({ event }) => event)).toEqual([ + { type: "dialog.opened", dialog: openDialog(events) }, + { type: "dialog.closed", dialogId: "dialog-1", reason: "aborted" }, + ]); + 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(); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 39ec003..478dfb6 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -1379,10 +1379,13 @@ export class PiSessionService implements SessionRouteService { } /** - * 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`. + * Settle the session's run-scoped dialogs as `"aborted"`. Runs at + * abort-request time (a user abort parks the agent loop behind the dialog + * handler, so `agent_end` would never arrive on its own) and again from + * the `agent_end` observer as the run-crash backstop — the store makes the + * second settlement a stale no-op. Idle-opened dialogs (a `session_start` + * probe, say) are not run-scoped and survive, because their waiter + * outlives the run. */ private abortRunScopedExtensionDialogs(sessionId: string): void { let closedAny = false; @@ -2361,6 +2364,12 @@ export class PiSessionService implements SessionRouteService { const sessionId = active.runtime.session.sessionId; this.clearCompactionPromptQueue(sessionId); clearSessionQueue(active.runtime.session); + // Settle run-scoped dialogs now, at abort-request time: pi's agent loop + // waits for a parked `tool_call` dialog handler before it can emit + // `agent_end`, so leaving settlement to the `agent_end` observer would + // strand the dialog until its timeout. Settling before the runtime abort + // also means a failing or hung abort cannot strand the parked waiter. + this.abortRunScopedExtensionDialogs(sessionId); try { await this.abortSessionOperations(active.runtime.session); this.publishActivity(active.runtime.session, "stopped", "idle"); From 346607e8bc67f1e500c46f47de30beafc24c31d1 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 09:49:45 +0200 Subject: [PATCH 6/9] fix(sessions): make session_start extension dialogs answerable mid-startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dialog opened from a session_start hook parks session construction before the session ever becomes active, but every servable path gated on readiness: the answer/cancel routes and status 404'd (or parked behind the in-flight open), and the client only subscribed once its create request resolved — so the dialog that gated readiness could never be answered and always rode to the daemon timeout. Daemon: hold a startupSessions registry for the duration of extension binding and let status, answerDialog, and cancelDialog resolve active → startup → getOrOpen fallback. getOrOpen itself is untouched, so prompts and other mutations still cannot reach a half-constructed session. status() no longer parks behind an in-flight open; it resolves from the startup window (intended semantics change, lifecycle test updated). Client: the pending-start row learns the real session id from the first token-matched session.startup event, connects its otherwise idle session socket to the constructing session, and recovers pre-subscription opens with a merge-based status resync (the unordered HTTP snapshot only adopts dialog ids the ordered socket channel never reported). The leg-4 dialog card renders in the startup view with no component changes, and answers go out under the real id. Readiness proceeds as before once the hook settles. --- .../sessionController.startupDialogs.test.ts | 301 ++++++++++++++++++ .../src/controllers/sessionController.ts | 136 +++++++- .../piSessionService.extensionDialogs.test.ts | 78 +++++ .../piSessionService.lifecycle.test.ts | 13 +- src/server/sessions/piSessionService.ts | 68 +++- 5 files changed, 579 insertions(+), 17 deletions(-) create mode 100644 src/client/src/controllers/sessionController.startupDialogs.test.ts diff --git a/src/client/src/controllers/sessionController.startupDialogs.test.ts b/src/client/src/controllers/sessionController.startupDialogs.test.ts new file mode 100644 index 0000000..e533596 --- /dev/null +++ b/src/client/src/controllers/sessionController.startupDialogs.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it, vi } from "vitest"; +import { initialAppState } from "../appState"; +import type { ExtensionDialogCloseResponse, ExtensionDialogKind, PendingExtensionDialog } from "../api"; +import { SessionController } from "./sessionController"; +import { defaultApi, deferred, EmitSocket, emptyPage, oldSession, runPendingAnimationFrames, sessionLookupId, status, workspace, type AppState, type SessionActivity, type SessionInfo, type SessionStatus } from "./sessionController.testSupport"; + +const BACKEND_SESSION_ID = "backend-session"; + +function startupActivity(patch: Partial = {}): SessionActivity { + return { + sessionId: BACKEND_SESSION_ID, + phase: "active", + label: "Creating session", + detail: "Loading session extensions", + at: "2026-07-20T00:00:01.000Z", + startup: true, + ...patch, + }; +} + +function dialog(dialogId: string, kind: ExtensionDialogKind = "confirm"): PendingExtensionDialog { + return { + dialogId, + kind, + title: `Dialog ${dialogId}`, + ...(kind === "confirm" ? { message: "Are you sure?" } : {}), + askedAt: "2026-07-20T00:00:00.000Z", + runScoped: false, + }; +} + +function statusWithDialogs(sessionId: string, pendingDialogs: PendingExtensionDialog[]): SessionStatus { + return { ...status(sessionId), pendingDialogs }; +} + +function closeResponse(sessionStatus: SessionStatus, dialogId = "dialog-1"): ExtensionDialogCloseResponse { + return { + result: "closed", + outcome: { + dialogId, + reason: "answered", + answer: true, + askedAt: "2026-07-20T00:00:00.000Z", + closedAt: "2026-07-20T00:01:00.000Z", + }, + sessionStatus, + }; +} + +interface PendingStartHarness { + controller: SessionController; + socket: EmitSocket; + startRequest: ReturnType>; + state: { current: AppState }; +} + +/** + * A controller with one in-flight create whose start request stays open until + * the test resolves it — the browser side of a `session_start` dialog parking + * session readiness. + */ +function pendingStartController(state: { current: AppState }, api: Partial = {}): PendingStartHarness { + const startRequest = deferred(); + const socket = new EmitSocket(); + const controller = new SessionController( + () => state.current, + (patch) => { state.current = { ...state.current, ...patch }; }, + () => undefined, + undefined, + { + api: { + ...defaultApi, + startSession: () => startRequest.promise, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }), + thinkingLevels: () => Promise.resolve({ levels: [] }), + ...api, + }, + socket, + }, + ); + return { controller, socket, startRequest, state }; +} + +function beginPendingStart(harness: PendingStartHarness): { start: Promise; tempId: string } { + const start = harness.controller.startSession(); + const tempId = harness.state.current.selectedSession?.id; + if (tempId === undefined) throw new Error("Expected a pending-start row to be selected"); + if (!tempId.startsWith("pending-session-")) throw new Error("Expected a pending-start row to be selected"); + return { start, tempId }; +} + +function reportBackendSessionId(harness: PendingStartHarness, tempId: string): void { + harness.controller.applyGlobalEvent({ type: "session.startup", startupToken: tempId, activity: startupActivity() }); + runPendingAnimationFrames(); +} + +function resolveBackendSession(harness: PendingStartHarness): void { + harness.startRequest.resolve({ ...oldSession, id: BACKEND_SESSION_ID, path: "/tmp/backend-session.jsonl" }); +} + +describe("SessionController session_start dialog startup reachability", () => { + it("subscribes to the backend session as soon as startup progress names it", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + const statusCalls: string[] = []; + const harness = pendingStartController(state, { + status: (session) => { + statusCalls.push(sessionLookupId(session)); + return Promise.resolve(status(sessionLookupId(session))); + }, + }); + const { start, tempId } = beginPendingStart(harness); + expect(harness.socket.connectedSessionIds).toEqual([]); + + reportBackendSessionId(harness, tempId); + + expect(harness.socket.connectedSessionIds).toEqual([BACKEND_SESSION_ID]); + await vi.waitFor(() => { expect(statusCalls).toEqual([BACKEND_SESSION_ID]); }); + resolveBackendSession(harness); + await start; + }); + + it("shows a dialog that opens mid-startup on the pending row, answerable before readiness", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + const harness = pendingStartController(state); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + + expect(harness.state.current.selectedSession?.id).toBe(tempId); + expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); + resolveBackendSession(harness); + await start; + }); + + it("recovers a dialog that opened before the subscription from the mid-startup status", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + const harness = pendingStartController(state, { + status: (session) => Promise.resolve(sessionLookupId(session) === BACKEND_SESSION_ID ? statusWithDialogs(BACKEND_SESSION_ID, [dialog("dialog-1")]) : status(sessionLookupId(session))), + }); + const { start, tempId } = beginPendingStart(harness); + + reportBackendSessionId(harness, tempId); + + await vi.waitFor(() => { expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); }); + // The per-session map holds the backend session's status for the readiness + // swap, while the row keeps its own temporary identity. + expect(harness.state.current.sessionStatuses[BACKEND_SESSION_ID]?.pendingDialogs).toEqual([dialog("dialog-1")]); + expect(harness.state.current.selectedSession?.id).toBe(tempId); + resolveBackendSession(harness); + await start; + }); + + it("tolerates a daemon that cannot serve status mid-startup", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + // Older daemons 404 the status route until the session is ready; the + // rejection must not disturb the event-driven dialog flow. + let createResolved = false; + const harness = pendingStartController(state, { + status: (session) => sessionLookupId(session) === BACKEND_SESSION_ID && !createResolved + ? Promise.reject(new Error("Session not found")) + : Promise.resolve(status(sessionLookupId(session))), + }); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + await vi.waitFor(() => { expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); }); + + expect(harness.state.current.error).toBe(""); + createResolved = true; + resolveBackendSession(harness); + await start; + }); + + it("answers a startup dialog through the real session id and proceeds to the chat view at readiness", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + const answerCalls: { sessionId: string; dialogId: string; value: unknown; machineId: string }[] = []; + const harness = pendingStartController(state, { + answerDialog: (session, dialogId, value, machineId) => { + answerCalls.push({ sessionId: sessionLookupId(session), dialogId, value, machineId: machineId ?? "local" }); + return Promise.resolve(closeResponse(status(BACKEND_SESSION_ID))); + }, + }); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + + await harness.controller.answerDialog("dialog-1", true); + + expect(answerCalls).toEqual([{ sessionId: BACKEND_SESSION_ID, dialogId: "dialog-1", value: true, machineId: "local" }]); + expect(harness.state.current.pendingDialogs).toEqual([]); + expect(harness.state.current.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "answered", answer: true }]); + expect(harness.state.current.error).toBe(""); + + // The answer settled the hook daemon-side, so the create resolves and the + // normal selection flow takes over the now-real session. + resolveBackendSession(harness); + await start; + await vi.waitFor(() => { expect(harness.state.current.selectedSession?.id).toBe(BACKEND_SESSION_ID); }); + expect(harness.state.current.sessions.some((session) => session.id === tempId)).toBe(false); + expect(harness.state.current.sessions.some((session) => session.id === BACKEND_SESSION_ID)).toBe(true); + }); + + it("cancels a startup dialog through the cancel route under the real session id", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + const cancelCalls: { sessionId: string; dialogId: string }[] = []; + const harness = pendingStartController(state, { + cancelDialog: (session, dialogId) => { + cancelCalls.push({ sessionId: sessionLookupId(session), dialogId }); + return Promise.resolve({ + result: "closed" as const, + outcome: { dialogId, reason: "cancelled" as const, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" }, + sessionStatus: status(BACKEND_SESSION_ID), + }); + }, + }); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + + await harness.controller.cancelDialog("dialog-1"); + + expect(cancelCalls).toEqual([{ sessionId: BACKEND_SESSION_ID, dialogId: "dialog-1" }]); + expect(harness.state.current.pendingDialogs).toEqual([]); + expect(harness.state.current.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "cancelled" }]); + resolveBackendSession(harness); + await start; + }); + + it("trusts the returned status when the answer loses the race", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + const harness = pendingStartController(state, { + answerDialog: () => Promise.resolve({ result: "stale", sessionStatus: statusWithDialogs(BACKEND_SESSION_ID, [dialog("dialog-2")]) }), + }); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + + await harness.controller.answerDialog("dialog-1", true); + + expect(harness.state.current.error).toBe(""); + expect(harness.state.current.closedDialogs).toEqual([]); + expect(harness.state.current.pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]); + resolveBackendSession(harness); + await start; + }); + + it("cannot answer before startup progress names the backend session", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + let answered = false; + const harness = pendingStartController(state, { + answerDialog: () => { + answered = true; + return Promise.resolve(closeResponse(status(BACKEND_SESSION_ID))); + }, + }); + const { start } = beginPendingStart(harness); + + await harness.controller.answerDialog("dialog-1", true); + + expect(answered).toBe(false); + resolveBackendSession(harness); + await start; + }); + + it("re-subscribes when the pending row is re-selected mid-startup", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] } }; + const harness = pendingStartController(state); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + expect(harness.socket.connectedSessionIds).toEqual([BACKEND_SESSION_ID]); + + await harness.controller.selectSession(oldSession, { updateUrl: false }); + const pendingRow = harness.state.current.sessions.find((session) => session.id === tempId); + if (pendingRow === undefined) throw new Error("Expected the pending row to stay in the session list"); + await harness.controller.selectSession(pendingRow, { updateUrl: false }); + + expect(harness.socket.connectedSessionIds).toEqual([BACKEND_SESSION_ID, oldSession.id, BACKEND_SESSION_ID]); + // Dialog state keeps flowing after the detour. + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); + resolveBackendSession(harness); + await start; + }); + + it("does not subscribe for another browser's create", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + const harness = pendingStartController(state); + const { start } = beginPendingStart(harness); + + harness.controller.applyGlobalEvent({ type: "session.startup", startupToken: "pending-session-9-other-tab", activity: startupActivity() }); + runPendingAnimationFrames(); + + expect(harness.socket.connectedSessionIds).toEqual([]); + resolveBackendSession(harness); + await start; + }); +}); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index ee2e250..127c9c8 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -84,6 +84,13 @@ interface PendingSessionStart { session: ClientPendingStartSessionInfo; queuedSends: QueuedPendingSessionSend[]; discarded: boolean; + /** + * The real session id, learned from the daemon's `session.startup` events + * long before the create request resolves. It is what lets the startup view + * subscribe to the constructing session and answer its `session_start` + * dialogs — the dialogs that gate the readiness the create request waits on. + */ + backendSessionId?: string; } interface SuppressedCreatedSession { @@ -912,7 +919,11 @@ export class SessionController { private async closeOpenDialog(dialogId: string, close: (session: SessionInfo, machineId: string) => Promise): Promise { const state = this.getState(); const session = state.selectedSession; - if (session === undefined || session.archived === true || isClientPendingStartSessionInfo(session)) return; + if (session === undefined || session.archived === true) return; + if (isClientPendingStartSessionInfo(session)) { + await this.closePendingStartDialog(session, dialogId, close); + return; + } const machineId = selectedMachineId(state); const selectionSeq = this.selectionSeq; try { @@ -934,6 +945,36 @@ export class SessionController { } } + /** + * Answer or cancel a `session_start` dialog from the startup view. The row + * is still the pending start, but the dialog belongs to the constructing + * backend session the startup events named, so the close goes out under the + * real id — the only route the daemon can serve before readiness. + */ + private async closePendingStartDialog(session: ClientPendingStartSessionInfo, dialogId: string, close: (session: SessionInfo, machineId: string) => Promise): Promise { + const pending = this.pendingSessionStarts.get(session.id); + const backendSessionId = pending?.backendSessionId; + // Without the real id there is no route to answer through — and no way a + // dialog card could be on screen yet either. + if (pending === undefined || backendSessionId === undefined) return; + const selectionSeq = this.selectionSeq; + try { + const response = await close({ ...session, id: backendSessionId }, pending.machineId); + if (selectionSeq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; + // Same outcome-first ordering as the ready-session path: the card shows + // what the user gave, and the daemon's dialog.closed frame then finds + // the dialog already closed here and stays a no-op. + const outcome: ExtensionDialogOutcome | undefined = response.outcome; + if (outcome !== undefined) { + const dialog = this.getState().pendingDialogs.find((candidate) => candidate.dialogId === outcome.dialogId); + if (dialog !== undefined) this.recordClosedDialog({ dialog, reason: outcome.reason, ...(outcome.answer === undefined ? {} : { answer: outcome.answer }) }); + } + this.applyPendingStartStatus(pending, response.sessionStatus); + } catch (error) { + if (selectionSeq === this.selectionSeq && this.getState().selectedSession?.id === session.id) this.setState({ error: String(error) }); + } + } + private async closeOpenAsk(askId: string, close: (session: SessionInfo, machineId: string) => Promise): Promise { const state = this.getState(); const session = state.selectedSession; @@ -1120,6 +1161,9 @@ export class SessionController { ...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }), error: "", }); + // Re-selecting the row mid-startup re-establishes the constructing + // session's subscription; the close above dropped it with the old selection. + if (pendingStart?.backendSessionId !== undefined) this.connectPendingStartSocket(pendingStart); if (options?.updateUrl !== false) this.updateUrl(); } @@ -1492,6 +1536,7 @@ export class SessionController { } const pending = event.startupToken === undefined ? undefined : this.pendingSessionStarts.get(event.startupToken); if (pending === undefined || pending.discarded) return; + this.learnPendingStartBackendSession(pending, event.activity.sessionId); // An idle startup phase means the daemon has nothing left to attribute, so // restore this row's own generic wording rather than clearing the text of a // creation request that has not returned yet. @@ -1500,6 +1545,95 @@ export class SessionController { : pendingStartActivity(event.activity, pending.tempId)); } + private learnPendingStartBackendSession(pending: PendingSessionStart, sessionId: string): void { + if (sessionId === "" || pending.backendSessionId !== undefined) return; + pending.backendSessionId = sessionId; + if (this.getState().selectedSession?.id === pending.tempId) this.connectPendingStartSocket(pending); + } + + /** + * Subscribe the selected pending-start row to its constructing session. + * `session_start` dialogs park the create request until answered, so waiting + * for readiness to subscribe would make them unanswerable; the per-session + * event channel and (on this daemon version) the status route both serve a + * session whose startup is still waiting on the user. + */ + private connectPendingStartSocket(pending: PendingSessionStart): void { + const backendSessionId = pending.backendSessionId; + if (backendSessionId === undefined || pending.discarded) return; + const ref: SessionRef = { id: backendSessionId, cwd: pending.cwd }; + this.socket.connect( + ref, + (event) => { this.applyPendingStartEvent(pending, event); }, + () => { this.resyncPendingStartDialogs(pending); }, + pending.machineId, + ); + this.resyncPendingStartDialogs(pending); + } + + /** + * Recover dialogs that opened before this subscription connected (or during + * a reconnect gap) from the daemon's status projection. The HTTP snapshot is + * unordered against socket frames — dialogs the socket already opened or + * closed are newer than anything it can say about them — so only genuinely + * unknown opens are adopted, never a wholesale replace. A daemon that + * predates mid-startup status answers 404 until readiness: tolerated, since + * everything that opens from here still arrives as an event. + */ + private resyncPendingStartDialogs(pending: PendingSessionStart): void { + const backendSessionId = pending.backendSessionId; + if (backendSessionId === undefined) return; + void this.api.status({ id: backendSessionId, cwd: pending.cwd }, pending.machineId).then( + (status) => { + this.applyStatus(status); + if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return; + const state = this.getState(); + const knownIds = new Set([ + ...state.pendingDialogs.map((pendingDialog) => pendingDialog.dialogId), + ...state.closedDialogs.map((closed) => closed.dialog.dialogId), + ]); + const recovered = (status.pendingDialogs ?? []).filter((recoveredDialog) => !knownIds.has(recoveredDialog.dialogId)); + if (recovered.length > 0) this.setState({ pendingDialogs: [...state.pendingDialogs, ...recovered] }); + }, + () => undefined, + ); + } + + /** + * Route a constructing session's events onto its pending-start row. Only + * dialog frames and their status reconciliation apply here: everything else + * (transcript, activity, naming) is re-fetched authoritatively by the + * readiness join, and routing it onto a temporary row would pollute state + * keyed for a session that does not exist yet. Frames that arrive after the + * row stopped being the selected pending start belong to the selection flow + * that took over. + */ + private applyPendingStartEvent(pending: PendingSessionStart, event: SessionUiEvent): void { + if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return; + if (event.type === "dialog.opened") { + this.applyOpenedDialog(event.dialog); + return; + } + if (event.type === "dialog.closed") { + this.applyClosedDialog(event.dialogId, event.reason, event.answer); + return; + } + if (event.type === "status.update") this.applyPendingStartStatus(pending, event.status); + } + + /** + * Apply a constructing session's status from an ordered channel (the + * socket's own status frame, or a dialog close response): the daemon's + * projection is authoritative there, so the open list is replaced wholesale, + * exactly as applyStatus does for a ready session. The per-session map stays + * truthful too — the readiness swap seeds the selected status from it. + */ + private applyPendingStartStatus(pending: PendingSessionStart, status: SessionStatus): void { + this.applyStatus(status); + if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return; + this.setState({ pendingDialogs: status.pendingDialogs ?? [] }); + } + private schedulePendingFlush(): void { if (this.pendingFrame !== undefined) return; this.pendingFrame = requestAnimationFrame(() => { diff --git a/src/server/sessions/piSessionService.extensionDialogs.test.ts b/src/server/sessions/piSessionService.extensionDialogs.test.ts index 88a137f..47e805d 100644 --- a/src/server/sessions/piSessionService.extensionDialogs.test.ts +++ b/src/server/sessions/piSessionService.extensionDialogs.test.ts @@ -520,3 +520,81 @@ describe("PiSessionService extension dialog status projection", () => { await service.dispose(); }); }); + +describe("PiSessionService session_start dialog startup reachability", () => { + /** + * A `session_start` dialog parks session construction before the session + * ever becomes active: the bind below models the issue's probe by awaiting + * a confirm inside extension binding. The dialog must stay reachable — + * statusable and answerable — in that window, or startup could never be + * unblocked from the browser. + */ + function startupDialogService() { + const harness = dialogService(); + const confirmAnswers: (boolean | string | undefined)[] = []; + harness.fake.session.bindExtensions = (bindings) => { + harness.fake.calls.bindExtensions.push(bindings); + if (bindings.uiContext === undefined) return Promise.resolve(); + return bindings.uiContext.confirm("Proceed at startup?", "Really?").then((answer) => { + confirmAnswers.push(answer); + }); + }; + return { ...harness, confirmAnswers }; + } + + async function parkOnStartupDialog(store: PendingExtensionDialogStore): Promise { + await vi.waitFor(() => { + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toHaveLength(1); + }); + } + + it("serves status for a session still parked on a session_start dialog", async () => { + const { service, store } = startupDialogService(); + const started = service.start("/workspace"); + await parkOnStartupDialog(store); + + const status = await service.status(sessionRef(ACTIVE_SESSION_ID)); + + expect(status.pendingDialogs).toEqual([ + expect.objectContaining({ dialogId: "dialog-1", kind: "confirm", title: "Proceed at startup?", runScoped: false }), + ]); + await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true); + await started; + await service.dispose(); + }); + + it("answers a session_start dialog mid-startup so creation can finish", async () => { + const { service, store, confirmAnswers } = startupDialogService(); + const started = service.start("/workspace"); + await parkOnStartupDialog(store); + + const response = await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true); + + expect(response.result).toBe("closed"); + expect(response.outcome).toMatchObject({ dialogId: "dialog-1", reason: "answered", answer: true }); + expect(response.sessionStatus.pendingDialogs ?? []).toEqual([]); + const created = await started; + expect(created.id).toBe(ACTIVE_SESSION_ID); + expect(confirmAnswers).toEqual([true]); + expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]); + // Readiness handed the session to the active path: a repeat answer races + // lost against the already-closed dialog instead of erroring. + const repeat = await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true); + expect(repeat.result).toBe("stale"); + await service.dispose(); + }); + + it("cancels a session_start dialog mid-startup with the kind's cancel value", async () => { + const { service, store, confirmAnswers } = startupDialogService(); + const started = service.start("/workspace"); + await parkOnStartupDialog(store); + + const response = await service.cancelDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1"); + + expect(response.result).toBe("closed"); + expect(response.outcome).toMatchObject({ dialogId: "dialog-1", reason: "cancelled" }); + await started; + expect(confirmAnswers).toEqual([false]); + await service.dispose(); + }); +}); diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts index beeb862..50a7af7 100644 --- a/src/server/sessions/piSessionService.lifecycle.test.ts +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -246,10 +246,15 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const outcomes = await failedLookups; expect(callsWhileOpening).toBe(1); expect(outcomes).toHaveLength(2); - for (const outcome of outcomes) { - expect(outcome.status).toBe("rejected"); - if (outcome.status === "rejected") expect(outcome.reason).toBe(openingError); - } + const [messagesOutcome, statusOutcome] = outcomes; + expect(messagesOutcome.status).toBe("rejected"); + if (messagesOutcome.status === "rejected") expect(messagesOutcome.reason).toBe(openingError); + // Status no longer parks behind the in-flight open: a session still + // binding its extensions is statusable (its session_start dialogs must + // stay answerable for startup to be unblockable at all), so the lookup + // resolves from the startup window rather than sharing the open's fate. + expect(statusOutcome.status).toBe("fulfilled"); + if (statusOutcome.status === "fulfilled") expect(statusOutcome.value).toMatchObject({ sessionId }); expect(service.activeCount()).toBe(0); expect(failed.calls.abort).toBe(1); expect(failed.calls.dispose).toBe(1); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 478dfb6..01a3ef3 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -136,6 +136,10 @@ function lookupMatchesActiveSession(ref: PiSessionLookup, active: ActiveSession< return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd); } +function lookupMatchesStartupSession(ref: PiSessionLookup, session: PiAgentSession): boolean { + return !isPiSessionRef(ref) || cwdPathsEqual(session.sessionManager.getCwd(), ref.cwd); +} + type QueuedPromptKind = "steer" | "followUp"; interface QueuedPrompt { @@ -755,6 +759,14 @@ export interface PiSessionServiceDependencies { export class PiSessionService implements SessionRouteService { private readonly active = new Map>(); private readonly pendingSessionOpens = new Map(); + /** + * Sessions whose extension binding is still in flight. A `session_start` + * dialog parks that window before the session ever becomes active, so this + * is the only way the dialog answer/cancel and status paths can reach it; + * {@link getOrOpen} never consults it, keeping every other operation gated + * on full readiness. + */ + private readonly startupSessions = new Map(); private readonly activities = new Map(); private readonly heartbeat: NodeJS.Timeout; private readonly commandService: SessionCommandService; @@ -1003,6 +1015,7 @@ export class PiSessionService implements SessionRouteService { } this.active.clear(); this.pendingSessionOpens.clear(); + this.startupSessions.clear(); this.activities.clear(); this.compactionPromptQueues.clear(); this.authLossWarnings.clear(); @@ -1300,7 +1313,7 @@ export class PiSessionService implements SessionRouteService { */ async answerDialog(ref: PiSessionLookup, dialogId: string, value: ExtensionDialogAnswer): Promise { await this.assertWritable(ref); - const session = await this.getOrOpen(ref); + const session = await this.sessionForStatusOrDialogClose(ref); const result = this.pendingExtensionDialogStore.answer(session.sessionId, dialogId, value); if (result.status === "stale") return { result: "stale", sessionStatus: this.statusFromSession(session) }; const { outcome } = result; @@ -1314,7 +1327,7 @@ export class PiSessionService implements SessionRouteService { /** 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 session = await this.sessionForStatusOrDialogClose(ref); const result = this.pendingExtensionDialogStore.cancel(session.sessionId, dialogId, "cancelled"); if (result.status === "stale") return { result: "stale", sessionStatus: this.statusFromSession(session) }; const { outcome } = result; @@ -1781,7 +1794,7 @@ export class PiSessionService implements SessionRouteService { } async status(ref: PiSessionLookup): Promise { - return this.statusFromSession(await this.getOrOpen(ref)); + return this.statusFromSession(await this.sessionForStatusOrDialogClose(ref)); } /** @@ -2718,6 +2731,28 @@ export class PiSessionService implements SessionRouteService { return undefined; } + private startupSessionForLookup(ref: PiSessionLookup): PiAgentSession | undefined { + const sessionId = sessionIdFromLookup(ref); + const exact = this.startupSessions.get(sessionId); + if (exact !== undefined && lookupMatchesStartupSession(ref, exact)) return exact; + for (const [candidateId, session] of this.startupSessions.entries()) { + if (candidateId.startsWith(sessionId) && lookupMatchesStartupSession(ref, session)) return session; + } + return undefined; + } + + /** + * The session to serve a read-only status or a dialog close for, while it + * can still be found: active first, then still starting up, and only then + * the on-demand open path (which a stale close on an idle session needs for + * its status projection). + */ + private async sessionForStatusOrDialogClose(ref: PiSessionLookup): Promise { + const reachable = this.activeForLookup(ref)?.runtime.session ?? this.startupSessionForLookup(ref); + if (reachable !== undefined) return reachable; + return this.getOrOpen(ref); + } + /** * Construct a session while telling waiting browsers which phase of startup * they are waiting on. The reporting wraps the *whole* construction rather @@ -2871,15 +2906,24 @@ export class PiSessionService implements SessionRouteService { generation: SessionNotificationGeneration | undefined, ): Promise { const uiContext = this.sessionUiContext(session, generation); - await session.bindExtensions({ - uiContext, - mode: "rpc", - onError: (error) => { - const message = `${error.extensionPath}: ${error.error}`; - this.publishActivity(session, "extension error", "error", message); - this.events.publish(session.sessionId, { type: "session.error", message }); - }, - }); + // A `session_start` hook can park this bind on a dialog the browser has + // not answered yet. On the initial create/open path the session becomes + // active only after this returns, so register it for the duration: the + // answer that unblocks startup has to be reachable while it waits. + this.startupSessions.set(session.sessionId, session); + try { + await session.bindExtensions({ + uiContext, + mode: "rpc", + onError: (error) => { + const message = `${error.extensionPath}: ${error.error}`; + this.publishActivity(session, "extension error", "error", message); + this.events.publish(session.sessionId, { type: "session.error", message }); + }, + }); + } finally { + this.startupSessions.delete(session.sessionId); + } } private replaceSessionNotificationContext(session: PiAgentSession, generation: SessionNotificationGeneration): void { From 5759201a399d14011d9d6f362c2e88cbd13bc876 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 10:53:35 +0200 Subject: [PATCH 7/9] docs: document Pi extension dialogs and extensionDialogsTimeoutMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the close-out documentation for extension dialog support: - docs/config.md (+ synchronized config.html): extensionDialogsTimeoutMs in the config matrix, reload/restart guidance, the global config example, and a new Extension dialogs key-detail section covering the unattended-dialog safety valve (default 5 min, 0 = forever, effective deadline is the sooner of the extension's own timeout and this knob). - docs/plugins.md (+ synchronized plugins.html): new Pi extension dialogs in PI WEB behavior note for extension authors — confirm/select/ input render inline in the transcript and resolve with the real answer, answers use a dedicated daemon channel (never the prompt queue, so tool_call hooks park safely), session_start dialogs are reachable during create and open, reload rehydration, first-answer-wins across tabs, abort/runtime-replacement settlement, and the reload-mid-startup browser-local caveat. - Add the extension-dialogs changeset (patch) for the release notes. --- .changeset/extension-dialogs.md | 5 +++++ docs/config.html | 38 ++++++++++++++++++++++++++++++++- docs/config.md | 12 ++++++++++- docs/plugins.html | 27 +++++++++++++++++++++++ docs/plugins.md | 13 +++++++++++ 5 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 .changeset/extension-dialogs.md diff --git a/.changeset/extension-dialogs.md b/.changeset/extension-dialogs.md new file mode 100644 index 0000000..42d3b10 --- /dev/null +++ b/.changeset/extension-dialogs.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Support Pi extension dialogs in the browser: `ctx.ui.confirm()`, `ctx.ui.select()`, and `ctx.ui.input()` now render as cards inline in the session transcript and resolve with the user's actual answer — including dialogs opened from `session_start` hooks while the session is still starting and from in-flight `tool_call` hooks, which previously resolved `false` immediately despite `hasUI === true`. Answers travel over a dedicated session-daemon channel rather than the prompt queue, so a dialog parked inside a `tool_call` hook cannot deadlock the run. Open dialogs survive browser reloads, the first answer wins across browser tabs, and unanswered dialogs settle safely on run abort, runtime replacement, or timeout. Adds the `extensionDialogsTimeoutMs` config key (default 5 minutes, `0` waits forever) as the unattended-dialog safety valve; dialog support is always on. Other `ExtensionUIContext` surfaces (widgets, status, editor, `custom`) remain unimplemented. diff --git a/docs/config.html b/docs/config.html index 1e3de64..47b2dfd 100644 --- a/docs/config.html +++ b/docs/config.html @@ -102,6 +102,7 @@ Pi extension providers Model catalog refresh Session tools + Extension dialogs Completion tools @@ -173,7 +174,7 @@
  • host / port: restart the gateway web/API service or process.
  • maxUploadBytes: restart both the web/API process and the session daemon on that machine.
  • -
  • agent.command / agent.dir / spawnSessions / subsessions / askUser: restart the session daemon on that machine.
  • +
  • agent.command / agent.dir / spawnSessions / subsessions / askUser / extensionDialogsTimeoutMs: restart the session daemon on that machine.
  • pathAccess: applies on the next request; existing file views may need a browser refresh.
  • uploads.defaultFolder: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
  • plugins: reload the browser tab after changing PI WEB plugin enablement.
  • @@ -213,6 +214,7 @@ "spawnSessions": true, "subsessions": false, "askUser": true, + "extensionDialogsTimeoutMs": 300000, "plugins": { "workspace-tasks": { "enabled": true }, "updates": { "enabled": true }, @@ -376,6 +378,14 @@ Not supported locally Restart session daemon on that machine + + Extension dialog auto-cancel timeout + extensionDialogsTimeoutMs + — + Global/session daemon + Not supported locally + Restart session daemon on that machine + PI WEB plugin enablement/settings plugins.<id>.enabled, plugins.<id>.settings @@ -842,6 +852,32 @@ +
    +

    Extension dialogs

    +

    + Pi extensions can ask the user questions from ctx.ui.confirm(), + ctx.ui.select(), and ctx.ui.input() — including from + session_start hooks and in-flight tool_call hooks. PI WEB renders these dialogs + inline in the session transcript and answers them through a dedicated session-daemon channel, never the + prompt queue, so a dialog parked inside a tool_call hook cannot deadlock the run. Dialog + support is always on; there is no enable flag. See + Pi extension dialogs in PI WEB for behavior details and author + guidance. +

    +

    + extensionDialogsTimeoutMs is the unattended-dialog safety valve: how long the session daemon + waits for an answer before settling the dialog with its kind's cancel value (false for + confirm, undefined for select and input). It defaults to 300000 (5 minutes); + set it to 0 to wait forever. An extension's own timeout option still applies, + and the effective deadline is the sooner of the two. +

    +
    + Restart required: extensionDialogsTimeoutMs is edited directly in the global + config file. Restart the session daemon after changing it — for the systemd user service, run + systemctl --user restart pi-web-sessiond. +
    +
    +

    Optional completion tools

    diff --git a/docs/config.md b/docs/config.md index d55a734..515f912 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,7 +39,7 @@ Process restarts depend on the key: - `host` / `port`: restart the gateway web/API service or process. - `maxUploadBytes`: restart both the web/API process and the session daemon on that machine. -- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions` / `askUser`: restart the session daemon on that machine. +- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions` / `askUser` / `extensionDialogsTimeoutMs`: restart the session daemon on that machine. - `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `plugins`: reload the browser tab after changing PI WEB plugin enablement. @@ -66,6 +66,7 @@ Process restarts depend on the key: "spawnSessions": true, "subsessions": false, "askUser": true, + "extensionDialogsTimeoutMs": 300000, "plugins": { "workspace-tasks": { "enabled": true }, "updates": { "enabled": true }, @@ -118,6 +119,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine | | Agent can post question forms | `askUser` | `PI_WEB_ASK_USER` | Global/session daemon | Not supported locally | Restart session daemon on that machine | +| Extension dialog auto-cancel timeout | `extensionDialogsTimeoutMs` | — | Global/session daemon | Not supported locally | Restart session daemon on that machine | | Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | | Keyboard shortcuts | `shortcuts.` | — | Global | Not supported locally | Applies after settings save/config refresh | | Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read | @@ -289,6 +291,14 @@ Sending an ordinary chat message while a form is open voids the form: the card c Restart the session daemon after changing `askUser` or after upgrading PI WEB to a version that introduces this tool. For the systemd user service, run `systemctl --user restart pi-web-sessiond`. +### Extension dialogs + +Pi extensions can ask the user questions from `ctx.ui.confirm()`, `ctx.ui.select()`, and `ctx.ui.input()` — including from `session_start` hooks and in-flight `tool_call` hooks. PI WEB renders these dialogs inline in the session transcript and answers them through a dedicated session-daemon channel, never the prompt queue, so a dialog parked inside a `tool_call` hook cannot deadlock the run. Dialog support is always on; there is no enable flag. See [Pi extension dialogs in PI WEB](https://pi-web.dev/plugins#pi-extension-dialogs) for behavior details and author guidance. + +`extensionDialogsTimeoutMs` is the unattended-dialog safety valve: how long the session daemon waits for an answer before settling the dialog with its kind's cancel value (`false` for confirm, `undefined` for select and input). It defaults to `300000` (5 minutes); set it to `0` to wait forever. An extension's own `timeout` option still applies, and the effective deadline is the sooner of the two. + +The key is edited directly in the global config file. Restart the session daemon after changing it — for the systemd user service, run `systemctl --user restart pi-web-sessiond`. + ### Plugin config The `plugins` key is only for PI WEB browser plugin enablement/settings on the machine whose config you are editing. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations. In a federated setup, **Settings → PI WEB plugins** and **Settings → Pi packages** both target the currently selected machine, and each panel labels where changes will be saved or run. diff --git a/docs/plugins.html b/docs/plugins.html index 2b58fec..ba9b525 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -91,6 +91,7 @@ On this page What can be extended Pi packages, extensions, and plugins + Pi extension dialogs What to ask AI to build Canonical example Built-in plugins @@ -174,6 +175,32 @@

    +
    +

    Pi extension dialogs in PI WEB

    +

    + Pi extensions running under PI WEB's session daemon can ask the user questions with + ctx.ui.confirm(), ctx.ui.select(), and ctx.ui.input(). For these + three methods ctx.hasUI is true in fact: the call renders a dialog card inline in the + session transcript — including from session_start hooks while the session is still starting + and from in-flight tool_call hooks — and resolves with the user's actual answer. +

    +

    + Answers travel over a dedicated session-daemon channel, never the prompt queue, so a parked + tool_call hook cannot deadlock the run. Open dialogs survive browser reloads, the first + answer wins across tabs, and unanswered dialogs settle safely: aborting the run or replacing the runtime + resolves them immediately with the kind's cancel value (false for confirm, + undefined for select and input), and the effective deadline — the sooner of the extension's + own timeout and the daemon's extensionDialogsTimeoutMs safety valve (default 5 + minutes, 0 waits forever) — does the same when no one answers. Other + ExtensionUIContext surfaces (widgets, status, editor, custom) remain no-ops + despite hasUI === true. +

    +

    + For the full behavior notes and author guidance, read plugins.md; for the + timeout key, see Extension dialogs in the configuration reference. +

    +
    +

    What to ask AI to build

    diff --git a/docs/plugins.md b/docs/plugins.md index 31c4b8e..5362b88 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -29,6 +29,19 @@ Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB brows After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A provider-registering Pi extension follows a separate daemon-start policy; see [Pi extension provider baseline](https://pi-web.dev/config#pi-extension-provider-baseline). +## Pi extension dialogs in PI WEB + +Pi extensions running under PI WEB's session daemon can ask the user questions with `ctx.ui.confirm()`, `ctx.ui.select()`, and `ctx.ui.input()`. PI WEB reports `ctx.hasUI === true`, and for these three dialog methods that is true in fact: the call renders a dialog card inline in the session transcript and the returned Promise resolves with the user's actual answer — a boolean for confirm, the chosen option for select, the typed text for input. + +- **Works from hooks, without the prompt queue.** Answers travel over a dedicated session-daemon channel, so a dialog opened inside an in-flight `tool_call` hook parks safely — the agent loop waits for the hook and the run continues with the answer. Consent-gating a tool from a `tool_call` hook is a supported pattern. +- **`session_start` dialogs are reachable.** A dialog opened from a `session_start` hook is answerable while the session is still starting, both when creating a session and when opening an existing one; startup completes once the dialog settles. +- **Survives browser reloads; first answer wins.** Reloading the browser re-renders open dialogs from the session status. With several tabs on the same session, the first answer settles the dialog and the other tabs re-render the settled card. +- **Timeouts.** The extension's own `timeout` option applies, and the daemon adds an unattended-dialog safety valve, `extensionDialogsTimeoutMs` (default 5 minutes, `0` waits forever — see [Extension dialogs](https://pi-web.dev/config#extension-dialogs)). The effective deadline is the sooner of the two. A dialog that closes without an answer resolves with its kind's cancel value: `false` for confirm, `undefined` for select and input. +- **Abort and runtime replacement.** Aborting the current run settles a dialog opened during that run immediately, at abort-request time, with its cancel value. Replacing the session runtime (`/reload`, session disposal) settles any still-open dialog the same way; hooks on the new runtime open fresh dialogs. The extension's own `AbortSignal` is honored: aborting it dismisses the dialog and resolves with the cancel value. +- **Other UI surfaces are still no-ops.** `ExtensionUIContext` methods beyond the three dialogs (widgets, status, editor, `custom`) remain unimplemented under PI WEB even though `hasUI` is `true`; do not rely on `hasUI` alone to detect them. + +One browser-local caveat: reloading the browser while a new session is still being created loses the browser-local pending-start row, so the dialog card disappears from view. The daemon-side dialog still settles at its deadline and the session appears in the sidebar once creation completes. + ## Trust model Plugins run as JavaScript in the browser app. Treat them as trusted code: From 5420869c527c994e718a824cb1049874b3108dcb Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 14:04:45 +0200 Subject: [PATCH 8/9] fix(sessions): close extension dialog lifecycle edge cases Post-review hardening for the extension dialog feature: - dispose() and closeActive() now settle startup-parked session_start dialogs before awaiting pending opens, so daemon shutdown or closing a session whose open is parked on a dialog can no longer block behind the dialog timeout (infinite with extensionDialogsTimeoutMs: 0). - A failed create now drops its dead dialog cards, closes the early-subscribed socket, and ignores late dialog frames instead of leaving an unanswerable card on the failed row. - The pending-start status resync checks its staleness guard before applying the unordered snapshot, so a late response can no longer clobber the post-swap session state. - The dialog countdown no longer queues one screen-reader announcement per second (decorative; the daemon-owned dialog.closed event is the real signal) and no longer renders "1h 60m" near hour boundaries. --- .../components/ExtensionDialogCard.test.ts | 25 ++++++-- .../src/components/ExtensionDialogCard.ts | 8 ++- .../sessionController.startupDialogs.test.ts | 64 +++++++++++++++++++ .../src/controllers/sessionController.ts | 16 ++++- .../piSessionService.extensionDialogs.test.ts | 33 ++++++++++ src/server/sessions/piSessionService.ts | 7 ++ 6 files changed, 145 insertions(+), 8 deletions(-) diff --git a/src/client/src/components/ExtensionDialogCard.test.ts b/src/client/src/components/ExtensionDialogCard.test.ts index e0af15e..87f1117 100644 --- a/src/client/src/components/ExtensionDialogCard.test.ts +++ b/src/client/src/components/ExtensionDialogCard.test.ts @@ -141,13 +141,25 @@ describe("extension-dialog-card countdown", () => { vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z")); const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" })); const root = renderRoot(card); - const status = requiredElement(root.querySelector("[role='status']"), "countdown status"); + const countdown = requiredElement(root.querySelector(".countdown"), "countdown"); - expect(status.textContent).toBe("Auto-cancels in 1m 30s"); + expect(countdown.textContent).toBe("Auto-cancels in 1m 30s"); await vi.advanceTimersByTimeAsync(30_000); await card.updateComplete; - expect(status.textContent).toBe("Auto-cancels in 1m 0s"); + expect(countdown.textContent).toBe("Auto-cancels in 1m 0s"); + }); + + it("is decorative: no live region announcing every second", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z")); + const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" })); + const root = renderRoot(card); + + // A ticking live region would queue a screen-reader announcement per + // second; the daemon-owned dialog.closed event is the real signal. + expect(requiredElement(root.querySelector(".countdown"), "countdown").getAttribute("role")).toBeNull(); + expect(root.querySelector("[aria-live]")).toBeNull(); }); it("renders no countdown when the dialog waits forever", async () => { @@ -155,7 +167,7 @@ describe("extension-dialog-card countdown", () => { const card = await mountOpenDialog(openDialog()); const root = renderRoot(card); - expect(root.querySelector("[role='status']")).toBeNull(); + expect(root.querySelector(".countdown")).toBeNull(); }); it("stops ticking once the dialog closes", async () => { @@ -219,6 +231,11 @@ describe("extensionDialogCountdownText", () => { expect(extensionDialogCountdownText("2026-07-27T11:02:00.000Z", now)).toBe("Auto-cancels in 1h 2m"); }); + it("never rounds the minutes up to 60 near an hour boundary", () => { + expect(extensionDialogCountdownText("2026-07-27T11:59:55.000Z", now)).toBe("Auto-cancels in 1h 59m"); + expect(extensionDialogCountdownText("2026-07-27T12:59:40.000Z", now)).toBe("Auto-cancels in 2h 59m"); + }); + it("stays display-only once the deadline has passed", () => { expect(extensionDialogCountdownText("2026-07-27T09:59:59.000Z", now)).toBe("Auto-cancel imminent"); }); diff --git a/src/client/src/components/ExtensionDialogCard.ts b/src/client/src/components/ExtensionDialogCard.ts index 2b861c2..fb51096 100644 --- a/src/client/src/components/ExtensionDialogCard.ts +++ b/src/client/src/components/ExtensionDialogCard.ts @@ -58,7 +58,8 @@ export function extensionDialogCountdownText(timeoutAt: string | undefined, nowM const seconds = Math.ceil(remainingMs / 1000); if (seconds >= 3600) { const hours = Math.floor(seconds / 3600); - const minutes = Math.round((seconds % 3600) / 60); + // Floor, not round: rounding yields "1h 60m" in the last half-minute of an hour. + const minutes = Math.floor((seconds % 3600) / 60); return `Auto-cancels in ${String(hours)}h ${String(minutes)}m`; } if (seconds >= 60) { @@ -129,7 +130,10 @@ export class ExtensionDialogCard extends LitElement {

    ${dialog.title}

    ${countdown === undefined ? null - : html`${countdown}`} + // Decorative only — no live region: a polite region would queue one + // announcement per second. The daemon-owned dialog.closed event is + // the real signal, and the settled card announces the outcome. + : html`${countdown}`} ${this.renderOpenBody(dialog)} diff --git a/src/client/src/controllers/sessionController.startupDialogs.test.ts b/src/client/src/controllers/sessionController.startupDialogs.test.ts index e533596..3b116bf 100644 --- a/src/client/src/controllers/sessionController.startupDialogs.test.ts +++ b/src/client/src/controllers/sessionController.startupDialogs.test.ts @@ -298,4 +298,68 @@ describe("SessionController session_start dialog startup reachability", () => { resolveBackendSession(harness); await start; }); + + it("drops a mid-startup status snapshot that lands after the readiness swap", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + const resyncRequest = deferred(); + let resyncIssued = false; + const harness = pendingStartController(state, { + status: (session) => { + // The first backend status call is the subscribe-time resync; hold it + // until after the swap. Later calls (the readiness join) answer fresh. + if (sessionLookupId(session) === BACKEND_SESSION_ID && !resyncIssued) { + resyncIssued = true; + return resyncRequest.promise; + } + return Promise.resolve(status(sessionLookupId(session))); + }, + }); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); + + resolveBackendSession(harness); + await start; + await vi.waitFor(() => { expect(harness.state.current.selectedSession?.id).toBe(BACKEND_SESSION_ID); }); + + // The stale snapshot — issued before the swap and claiming dialog-1 is + // still open — must not clobber the real session's fresher state. + resyncRequest.resolve(statusWithDialogs(BACKEND_SESSION_ID, [dialog("dialog-1")])); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.state.current.pendingDialogs).toEqual([]); + expect(harness.state.current.sessionStatuses[BACKEND_SESSION_ID]?.pendingDialogs ?? []).toEqual([]); + }); + + it("drops the dead card, closes the socket, and ignores late frames when the create fails mid-startup", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + let answerCalled = false; + const harness = pendingStartController(state, { + answerDialog: () => { + answerCalled = true; + return Promise.resolve(closeResponse(status(BACKEND_SESSION_ID))); + }, + }); + const closeSpy = vi.spyOn(harness.socket, "close"); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); + + closeSpy.mockClear(); + harness.startRequest.reject(new Error("create exploded")); + await start; + + expect(harness.state.current.error).toBe("Failed to start session: create exploded"); + expect(harness.state.current.pendingDialogs).toEqual([]); + expect(closeSpy).toHaveBeenCalled(); + + // Late frames from the dead session are dropped, and no answer can leave. + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2") }); + expect(harness.state.current.pendingDialogs).toEqual([]); + await harness.controller.answerDialog("dialog-2", true); + expect(answerCalled).toBe(false); + }); }); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 127c9c8..362e219 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -1214,9 +1214,15 @@ export class SessionController { const pending = this.pendingSessionStarts.get(tempId); if (pending === undefined) return; this.pendingSessionStarts.delete(tempId); + const wasDiscarded = pending.discarded; + // The pending start is dead: stop routing its dialog frames (a card on the + // failed row could never be answered) and drop the early-subscribed socket + // so it stops reconnecting against a session that may not exist. + pending.discarded = true; + if (this.getState().selectedSession?.id === tempId) this.socket.close(); const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId); const isCurrentPendingStart = this.isCurrentPendingStart(pending); - if (pending.discarded || !isCurrentPendingStart) { + if (wasDiscarded || !isCurrentPendingStart) { if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); return; } @@ -1228,6 +1234,9 @@ export class SessionController { sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions], sessionActivities: { ...state.sessionActivities, [tempId]: activity }, activity: state.selectedSession?.id === tempId ? activity : state.activity, + // Open cards on the failed row are dead: the create is gone, so no + // answer could ever reach the daemon. Settled outcomes stay as history. + ...(state.selectedSession?.id === tempId ? { pendingDialogs: [] } : {}), error: `Failed to start session: ${message}`, }); this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); @@ -1585,8 +1594,11 @@ export class SessionController { if (backendSessionId === undefined) return; void this.api.status({ id: backendSessionId, cwd: pending.cwd }, pending.machineId).then( (status) => { - this.applyStatus(status); + // Guard before applying: this unordered snapshot can land after the + // readiness swap made the real session selected, and a stale replace + // must not clobber the socket's fresher dialog state. if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return; + this.applyStatus(status); const state = this.getState(); const knownIds = new Set([ ...state.pendingDialogs.map((pendingDialog) => pendingDialog.dialogId), diff --git a/src/server/sessions/piSessionService.extensionDialogs.test.ts b/src/server/sessions/piSessionService.extensionDialogs.test.ts index 47e805d..d682550 100644 --- a/src/server/sessions/piSessionService.extensionDialogs.test.ts +++ b/src/server/sessions/piSessionService.extensionDialogs.test.ts @@ -597,4 +597,37 @@ describe("PiSessionService session_start dialog startup reachability", () => { expect(confirmAnswers).toEqual([false]); await service.dispose(); }); + + it("dispose settles a startup-parked dialog instead of blocking behind its timeout", async () => { + const { service, store, events, confirmAnswers } = startupDialogService(); + // The open flow registers in pendingSessionOpens, which dispose awaits: + // without settling the dialog first, disposal would ride its timeout. + const opening = service.messages(sessionRef(ACTIVE_SESSION_ID)); + await parkOnStartupDialog(store); + + await service.dispose(); + + expect(confirmAnswers).toEqual([false]); + const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed"); + expect(closedEvents).toHaveLength(1); + expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" }); + // The released open completed inside dispose's awaited window; the late + // messages read neither hangs nor rejects the test run. + await Promise.allSettled([opening]); + }); + + it("closing a session whose open is parked on a session_start dialog settles the dialog first", async () => { + const { service, store, events, confirmAnswers } = startupDialogService(); + const opening = service.messages(sessionRef(ACTIVE_SESSION_ID)); + await parkOnStartupDialog(store); + + await service.stop(ACTIVE_SESSION_ID); + + expect(confirmAnswers).toEqual([false]); + const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed"); + expect(closedEvents).toHaveLength(1); + expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" }); + await Promise.allSettled([opening]); + await service.dispose(); + }); }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 01a3ef3..f8f7552 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -1005,6 +1005,9 @@ export class PiSessionService implements SessionRouteService { this.clearUnreadPublicationRetry(); clearInterval(this.heartbeat); this.clearCompactionDrainTimers(); + // Same startup-park hazard as closeActive(): settle `session_start` dialogs + // of sessions still binding extensions before awaiting their pending opens. + for (const sessionId of this.startupSessions.keys()) this.endSessionExtensionDialogs(sessionId); const pendingOpens = this.pendingSessionOpenPromises(); if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens); const activeSessions = Array.from(new Set(this.active.values())); @@ -2592,6 +2595,10 @@ export class PiSessionService implements SessionRouteService { } private async closeActive(sessionId: string, notificationPolicy: NotificationClosePolicy = CLEAR_RUNTIME_NOTIFICATIONS): Promise { + // A session whose open is parked on a `session_start` dialog holds its + // pending open until the dialog settles; settle it first so closing cannot + // block behind the dialog timeout (which `0` makes infinite). + if (this.startupSessions.has(sessionId)) this.endSessionExtensionDialogs(sessionId); const pendingOpens = this.pendingSessionOpenPromises(sessionId); if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens); const active = this.active.get(sessionId); From 7a426571b8a020e1c80b7ddfd3935e8e41644b7b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 23:35:01 +0200 Subject: [PATCH 9/9] docs(sessions): document settled extension dialog cards as dismiss-stay records --- docs/plugins.md | 1 + src/client/src/appState.ts | 7 ++++--- src/client/src/components/ExtensionDialogCard.ts | 3 ++- src/shared/apiTypes.ts | 6 +++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 5362b88..933cb17 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -36,6 +36,7 @@ Pi extensions running under PI WEB's session daemon can ask the user questions w - **Works from hooks, without the prompt queue.** Answers travel over a dedicated session-daemon channel, so a dialog opened inside an in-flight `tool_call` hook parks safely — the agent loop waits for the hook and the run continues with the answer. Consent-gating a tool from a `tool_call` hook is a supported pattern. - **`session_start` dialogs are reachable.** A dialog opened from a `session_start` hook is answerable while the session is still starting, both when creating a session and when opening an existing one; startup completes once the dialog settles. - **Survives browser reloads; first answer wins.** Reloading the browser re-renders open dialogs from the session status. With several tabs on the same session, the first answer settles the dialog and the other tabs re-render the settled card. +- **Settled cards stay until dismissed.** An answered or closed dialog leaves its outcome card in the transcript so the user can see what became of it — answers travel to the extension alone, so the card is the only record of the exchange. The card is browser-local: only a browser that saw the dialog open renders it, and switching sessions or reloading drops it. - **Timeouts.** The extension's own `timeout` option applies, and the daemon adds an unattended-dialog safety valve, `extensionDialogsTimeoutMs` (default 5 minutes, `0` waits forever — see [Extension dialogs](https://pi-web.dev/config#extension-dialogs)). The effective deadline is the sooner of the two. A dialog that closes without an answer resolves with its kind's cancel value: `false` for confirm, `undefined` for select and input. - **Abort and runtime replacement.** Aborting the current run settles a dialog opened during that run immediately, at abort-request time, with its cancel value. Replacing the session runtime (`/reload`, session disposal) settles any still-open dialog the same way; hooks on the new runtime open fresh dialogs. The extension's own `AbortSignal` is honored: aborting it dismisses the dialog and resolves with the cancel value. - **Other UI surfaces are still no-ops.** `ExtensionUIContext` methods beyond the three dialogs (widgets, status, editor, `custom`) remain unimplemented under PI WEB even though `hasUI` is `true`; do not rely on `hasUI` alone to detect them. diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 74a6abe..aa56295 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -46,9 +46,10 @@ export interface AppState { pendingDialogs: PendingExtensionDialog[]; /** * Dialogs that closed while their session was selected, kept with the close - * reason and any answer so the card can render its outcome briefly. The wire - * outcome is deliberately small, so only a browser that saw the dialog open - * can show the closed card; deselection and reloads drop these. + * reason and any answer so the settled card can show what became of the + * dialog. The card stays until the user dismisses it. The wire outcome is + * deliberately small, so only a browser that saw the dialog open can show + * the closed card; deselection and reloads drop these. */ closedDialogs: ClosedExtensionDialog[]; /** Thinking levels available for the selected session's current model. */ diff --git a/src/client/src/components/ExtensionDialogCard.ts b/src/client/src/components/ExtensionDialogCard.ts index fb51096..f33a5f8 100644 --- a/src/client/src/components/ExtensionDialogCard.ts +++ b/src/client/src/components/ExtensionDialogCard.ts @@ -76,7 +76,8 @@ export function extensionDialogCountdownText(timeoutAt: string | undefined, nowM * The card owns only browser-local form state (the half-typed input, the * in-flight close flag, the display-only countdown); the daemon remains the * source of truth for whether the dialog is open. Closed mode renders the - * transient outcome for a browser that saw the dialog open. + * settled outcome — a browser-local record that stays until dismissed — for a + * browser that saw the dialog open. */ @customElement("extension-dialog-card") export class ExtensionDialogCard extends LitElement { diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 01285a7..38fe4c6 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -656,9 +656,9 @@ export interface PendingExtensionDialog { /** * The complete result of a closed extension dialog. Unlike an ask outcome it - * stays small — the dialog itself is not embedded, because a closed dialog - * renders only transiently for browsers that saw it open; reloads rehydrate - * open dialogs from {@link SessionStatus.pendingDialogs} alone. + * stays small — the dialog itself is not embedded, because a settled card is a + * browser-local record that stays until the user dismisses it; reloads + * rehydrate open dialogs from {@link SessionStatus.pendingDialogs} alone. */ export interface ExtensionDialogOutcome { dialogId: string;