Archived
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:
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user