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
This commit is contained in:
Federico Jaramillo Martinez
2026-07-28 23:38:44 +02:00
parent 87c09982e9
commit 50801e4f85
3 changed files with 615 additions and 0 deletions
@@ -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<string, unknown>) => () =>
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" });
});
});
@@ -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<ExtensionDialogCloseReason, "answered">;
/**
* 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<string, Map<string, PendingExtensionDialog>>();
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<string, PendingExtensionDialog>();
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<PendingExtensionDialog, "message" | "options" | "placeholder"> {
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<string>();
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<PendingExtensionDialog, "timeoutAt"> {
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;
}
+79
View File
@@ -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 };