Archived
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.
This commit is contained in:
+29
-1
@@ -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);
|
||||
|
||||
+20
-1
@@ -15,11 +15,12 @@ export interface LoadedPiWebConfig {
|
||||
config: PiWebConfig;
|
||||
}
|
||||
|
||||
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "askUser" | "agent"> {
|
||||
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "askUser" | "agent" | "extensionDialogsTimeoutMs"> {
|
||||
uploads: NonNullable<PiWebConfig["uploads"]>;
|
||||
spawnSessions: boolean;
|
||||
subsessions: boolean;
|
||||
askUser: boolean;
|
||||
extensionDialogsTimeoutMs: number;
|
||||
agent: Required<NonNullable<PiWebConfig["agent"]>>;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>, 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
|
||||
|
||||
@@ -87,6 +87,7 @@ async function createSessionDaemonRuntime() {
|
||||
projectWorkspaces,
|
||||
subsessionsEnabled: config.subsessions,
|
||||
askUserEnabled: config.askUser,
|
||||
extensionDialogsTimeoutMs: config.extensionDialogsTimeoutMs,
|
||||
notificationStore,
|
||||
unreadStore,
|
||||
catalogRefreshStatus: catalogRefresher,
|
||||
|
||||
@@ -25,6 +25,7 @@ function daemonCollaborators(patch: Partial<SessionServiceDependencyInput> = {})
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {}): 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<boolean | string | undefined>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, ParkedExtensionDialog>();
|
||||
|
||||
/**
|
||||
* 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<boolean | string | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof fakeRuntime>): Promise<ExtensionUIContext> {
|
||||
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<boolean | string | undefined>): 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<void>)[] = [];
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,7 @@ export function testModel(): NonNullable<PiAgentSession["model"]> {
|
||||
export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||
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 };
|
||||
|
||||
@@ -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<PiAgentSession, SessionNotificationGeneration>();
|
||||
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<ExtensionDialogCloseResponse> {
|
||||
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<ExtensionDialogCloseResponse> {
|
||||
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<boolean | string | undefined> {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>[] = [
|
||||
{ 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<ExtensionDialogCloseResponse> {
|
||||
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<ExtensionDialogCloseResponse> {
|
||||
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<SessionCleanupPreviewResponse> {
|
||||
this.cleanupPreviewCalls.push(request);
|
||||
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>): ExtensionDialogCancelRequest {
|
||||
return { dialogId: requireNonEmptyBoundedString(body["dialogId"], "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH) };
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
if (value === undefined || value === null) return {};
|
||||
return requireRecord(value);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
AskUserCloseResponse,
|
||||
AskUserSubmission,
|
||||
ExtensionDialogAnswer,
|
||||
ExtensionDialogCloseResponse,
|
||||
SavedPromptAttachment,
|
||||
SessionBulkArchiveResponse,
|
||||
SessionBulkDeleteArchivedResponse,
|
||||
@@ -60,6 +62,8 @@ export interface SessionRouteService {
|
||||
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||
submitAsk(ref: SessionRouteLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse>;
|
||||
cancelAsk(ref: SessionRouteLookup, askId: string): Promise<AskUserCloseResponse>;
|
||||
answerDialog(ref: SessionRouteLookup, dialogId: string, value: ExtensionDialogAnswer): Promise<ExtensionDialogCloseResponse>;
|
||||
cancelDialog(ref: SessionRouteLookup, dialogId: string): Promise<ExtensionDialogCloseResponse>;
|
||||
dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise<ClientSessionStatus>;
|
||||
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
|
||||
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user