From 50801e4f85bbb2e65a786a4fdb4acd8a8be32e21 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 28 Jul 2026 00:05:21 +0200 Subject: [PATCH] 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 };