diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1c5e9d3f8..9075d1181 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -44,6 +44,7 @@ - Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable. - Added Bun HTTP/2 fetch opt-in. Dev scripts (`bun run dev`, `bun run stats`) now pass `bun --experimental-http2-fetch` so every `fetch()` advertises `h2` in the TLS ALPN list and falls back to HTTP/1.1 when the server doesn't select it. Multiplexing collapses parallel requests to the same origin onto one TLS connection. For the installed `omp` binary, export `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1` in your shell to enable the same behavior (the flag has to be set before Bun starts; `process.env` from inside JS is too late). Requires Bun **1.3.14**. - Added per-subagent cost display (`$X.XX` in the task progress tree and the session-observer stats line). Cost is accumulated incrementally from `message_end` events and shown only when non-zero, using the `statusLineCost` theme color. Providers that do not report per-turn cost data (e.g. subscription/OAuth usage) continue to show nothing. +- Added ACP elicitation bridge so skills/extensions calling `select`, `confirm`, or `input` on the extension UI context now produce real `unstable_createElicitation` form requests to the ACP client (rather than always resolving to `undefined` / `false`). The `acpExtensionUiContext` constant is promoted to `createAcpExtensionUiContext(connection, getSessionId, clientCapabilities)` — invoked once per session inside `#configureExtensions`, with `getSessionId: () => string` so the live `record.session.sessionId` is read on every elicitation (the underlying id mutates when an extension command calls `ctx.newSession` / `ctx.switchSession`). Each method maps to a single-property `value` schema: `select` → `{type: "string", enum}`, `confirm` → `{type: "boolean"}` (joined `title` + `message` when the trimmed message is non-empty; otherwise just `title`), `input` → `{type: "string", description: placeholder?}` (ACP has no `placeholder` field on `StringPropertySchema`; empty / whitespace-only placeholders are treated as absent). `accept` responses narrow the returned `ElicitationContentValue` back to the method's declared type with a runtime `typeof` guard; `decline` / `cancel` / transport failures fall back to the prior stub return values. `dialogOptions.signal` is honored: an already-aborted signal short-circuits before any SDK round-trip, and an abort mid-flight races against the elicitation so the caller's promise resolves to the stub fallback (the ACP request itself keeps running on the client side — the SDK exposes no form-mode cancel surface; `unstable_completeElicitation` is URL-mode only — matching the in-flight pattern used by `requestRpcEditor`). `dialogOptions.timeout` is honored on parity with `RpcExtensionUIContext`: when the timer fires before the client responds, `onTimeout` is invoked and the caller resolves to the stub fallback. A throwing `onTimeout` is caught and logged (`logger.warn`) so the elicitation promise still settles. Late SDK rejections that arrive after abort/timeout are dropped silently to keep operator logs clean; transport failures still emit `logger.warn` with `{ sessionId, method, error }`. Calls are skipped when the client did not advertise `clientCapabilities.elicitation.form` during `initialize`, so non-elicitation clients are unaffected. `createAcpExtensionUiContext` is exported for tests. ### Changed diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 9a34ccda0..5d506a77d 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -9,6 +9,9 @@ import { type ClientCapabilities, type CloseSessionRequest, type CloseSessionResponse, + type CreateElicitationResponse, + type ElicitationContentValue, + type ElicitationPropertySchema, type ForkSessionRequest, type ForkSessionResponse, type InitializeRequest, @@ -44,7 +47,7 @@ import { logger, VERSION } from "@oh-my-pi/pi-utils"; import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability"; import { Settings } from "../../config/settings"; import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers"; -import type { ExtensionUIContext } from "../../extensibility/extensions"; +import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions"; import { runExtensionCompact } from "../../extensibility/extensions/compact-handler"; import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills"; import { loadSlashCommands } from "../../extensibility/slash-commands"; @@ -138,35 +141,185 @@ type MCPSourceMap = { type CreateAcpSession = (cwd: string) => Promise; -const acpExtensionUiContext: ExtensionUIContext = { - select: async () => undefined, - confirm: async () => false, - input: async () => undefined, - notify: (message, type) => { - logger.debug("ACP extension notification", { message, type }); - }, - onTerminalInput: () => () => {}, - setStatus: () => {}, - setWorkingMessage: () => {}, - setWidget: () => {}, - setFooter: () => {}, - setHeader: () => {}, - setTitle: () => {}, - custom: async () => undefined as never, - pasteToEditor: () => {}, - setEditorText: () => {}, - getEditorText: () => "", - editor: async () => undefined, - setEditorComponent: () => {}, - get theme() { - return theme; - }, - getAllThemes: async () => [], - getTheme: async () => undefined, - setTheme: async () => ({ success: false, error: "Theme changes are unavailable in ACP mode" }), - getToolsExpanded: () => false, - setToolsExpanded: () => {}, -}; +/** + * Bridge a single ExtensionUIContext call to the ACP `unstable_createElicitation` + * surface. Skills/extensions ask for one value at a time (a chosen option, a + * confirmation, a piece of text), so every elicitation here uses a one-property + * `value` schema; the caller narrows the resulting `ElicitationContentValue` + * back to its concrete primitive type. + * + * `dialogOptions.signal` short-circuits the elicitation if it is already + * aborted and races the in-flight request against the abort event. The SDK + * exposes no `cancel_elicitation` surface for form-mode elicitations + * (`unstable_completeElicitation` is URL-mode only), so the ACP request itself + * keeps running on the client side until the user dismisses it — but + * resolving the local promise unblocks the caller (matches the RPC mode + * pattern in `requestRpcEditor`). The abort listener is removed once the + * elicitation settles so that callers which reuse the same signal across many + * elicitations (e.g. `ask` multi-select loops) don't accumulate listeners and + * trip Node's `MaxListeners` warning. + * + * `dialogOptions.timeout` mirrors `RpcExtensionUIContext.#createDialogPromise`: + * when the timer fires before the client responds, `onTimeout` is invoked and + * the caller's promise resolves to the stub fallback. Late SDK responses that + * arrive after abort/timeout — both rejections and successful `accept`s — + * are dropped silently (no `logger.warn`) to keep operator logs clean. + */ +async function elicitFromAcpClient( + connection: AgentSideConnection, + sessionId: string, + method: "select" | "confirm" | "input", + message: string, + property: ElicitationPropertySchema, + dialogOptions: ExtensionUIDialogOptions | undefined, +): Promise { + const signal = dialogOptions?.signal; + if (signal?.aborted) { + return undefined; + } + const { promise, resolve } = Promise.withResolvers(); + let settled = false; + let timeoutId: NodeJS.Timeout | undefined; + const finish = (value: CreateElicitationResponse | undefined) => { + if (settled) return; + settled = true; + if (timeoutId !== undefined) clearTimeout(timeoutId); + signal?.removeEventListener("abort", onAbort); + resolve(value); + }; + const onAbort = () => finish(undefined); + signal?.addEventListener("abort", onAbort, { once: true }); + if (dialogOptions?.timeout !== undefined) { + timeoutId = setTimeout(() => { + if (settled) return; + try { + dialogOptions.onTimeout?.(); + } catch (error) { + // A throwing `onTimeout` must not leave the elicitation promise + // pending — settle it via `finish` below regardless. + logger.warn("ACP elicitation onTimeout threw", { sessionId, method, error }); + } + finish(undefined); + }, dialogOptions.timeout); + // A long pending timeout alone shouldn't keep the event loop alive when + // the rest of the agent has shut down — matches `job-manager.ts` / + // `executor.ts` timer hygiene. Connection + session lifetimes keep the + // loop alive on the happy path. + timeoutId.unref(); + } + connection + .unstable_createElicitation({ + mode: "form", + sessionId, + message, + requestedSchema: { + type: "object", + properties: { value: property }, + required: ["value"], + }, + }) + .then(finish, error => { + // Caller may already have moved on via abort/timeout; suppress noise. + if (settled) return; + logger.warn("ACP elicitation failed", { sessionId, method, error }); + finish(undefined); + }); + const response = await promise; + if (!response || response.action !== "accept" || !response.content) { + return undefined; + } + return response.content.value; +} + +/** + * Build an {@link ExtensionUIContext} that translates skill/extension UI + * requests into ACP elicitations against `connection` for the session + * returned by `getSessionId()`. The id is read lazily at each elicitation + * because `AgentSession.sessionId` is a getter over `sessionManager` state + * that mutates when an extension command calls `ctx.newSession` / + * `ctx.switchSession` — snapshotting it once at factory time would route + * later elicitations to the pre-switch id. Live reads keep the bridge + * symmetric with every other `sessionUpdate` call in this file + * (`record.session.sessionId` is always evaluated at emit time). + * + * The non-elicitation surface (custom components, editor, theming, + * terminal input) remains stubbed — ACP clients render those themselves + * or not at all. Capability gating respects the client's `initialize` + * advertisement. + */ +export function createAcpExtensionUiContext( + connection: AgentSideConnection, + getSessionId: () => string, + clientCapabilities: ClientCapabilities | undefined, +): ExtensionUIContext { + const supportsForm = clientCapabilities?.elicitation?.form != null; + return { + select: async (title, options, dialogOptions) => { + if (!supportsForm) return undefined; + const value = await elicitFromAcpClient( + connection, + getSessionId(), + "select", + title, + { type: "string", enum: options }, + dialogOptions, + ); + return typeof value === "string" ? value : undefined; + }, + confirm: async (title, message, dialogOptions) => { + if (!supportsForm) return false; + const value = await elicitFromAcpClient( + connection, + getSessionId(), + "confirm", + message.trim().length > 0 ? `${title}\n\n${message}` : title, + { type: "boolean" }, + dialogOptions, + ); + return typeof value === "boolean" ? value : false; + }, + input: async (title, placeholder, dialogOptions) => { + if (!supportsForm) return undefined; + const value = await elicitFromAcpClient( + connection, + getSessionId(), + "input", + title, + // ACP's `StringPropertySchema` has no `placeholder` field, so we + // surface the placeholder text as `description` — the closest + // semantic field a client can render alongside the input. + // Empty / whitespace-only placeholders are treated as absent. + { type: "string", ...(placeholder?.trim() ? { description: placeholder } : {}) }, + dialogOptions, + ); + return typeof value === "string" ? value : undefined; + }, + notify: (message, type) => { + logger.debug("ACP extension notification", { message, type }); + }, + onTerminalInput: () => () => {}, + setStatus: () => {}, + setWorkingMessage: () => {}, + setWidget: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + pasteToEditor: () => {}, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + setEditorComponent: () => {}, + get theme() { + return theme; + }, + getAllThemes: async () => [], + getTheme: async () => undefined, + setTheme: async () => ({ success: false, error: "Theme changes are unavailable in ACP mode" }), + getToolsExpanded: () => false, + setToolsExpanded: () => {}, + }; +} export class AcpAgent implements Agent { #connection: AgentSideConnection; @@ -1607,7 +1760,15 @@ export class AcpAgent implements Agent { }, compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions), }, - acpExtensionUiContext, + // Per-session getter: `record.session.sessionId` reads through to + // `sessionManager.getSessionId()` (it's a getter, not a field), so an + // extension command that calls `ctx.newSession` / `ctx.switchSession` + // — both exposed in the block just above — mutates the underlying id + // mid-flight. Reading lazily on each elicitation matches every other + // `sessionUpdate` call in this file. Hoisting the factory to an + // `AcpAgent` field would still be wrong because it would also lose + // the per-`record` binding. + createAcpExtensionUiContext(this.#connection, () => record.session.sessionId, this.#clientCapabilities), ); await extensionRunner.emit({ type: "session_start" }); record.extensionsConfigured = true; diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index 8da9766ee..d7ada16e9 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -2,7 +2,14 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentSideConnection, PromptRequest, SessionNotification } from "@agentclientprotocol/sdk"; +import type { + AgentSideConnection, + ClientCapabilities, + CreateElicitationRequest, + CreateElicitationResponse, + PromptRequest, + SessionNotification, +} from "@agentclientprotocol/sdk"; import { zForkSessionResponse, zLoadSessionResponse, @@ -13,7 +20,7 @@ import { import type { Model } from "@oh-my-pi/pi-ai"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; import { resetSettingsForTest, Settings } from "../src/config/settings"; -import { ACP_BOOTSTRAP_RACE_GUARD_MS, AcpAgent } from "../src/modes/acp/acp-agent"; +import { ACP_BOOTSTRAP_RACE_GUARD_MS, AcpAgent, createAcpExtensionUiContext } from "../src/modes/acp/acp-agent"; import type { PlanModeState } from "../src/plan-mode/state"; import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; import { SILENT_ABORT_MARKER } from "../src/session/messages"; @@ -886,4 +893,298 @@ describe("ACP agent", () => { harness.abortController.abort(); await Bun.sleep(0); }); + + describe("ACP elicitation bridge", () => { + const FORM_CAPABILITIES: ClientCapabilities = { elicitation: { form: {} } }; + + function createElicitConnection(handler: (req: CreateElicitationRequest) => Promise): { + connection: AgentSideConnection; + calls: CreateElicitationRequest[]; + } { + const calls: CreateElicitationRequest[] = []; + const connection = { + unstable_createElicitation: async (req: CreateElicitationRequest) => { + calls.push(req); + return handler(req); + }, + } as unknown as AgentSideConnection; + return { connection, calls }; + } + + it("translates select to a single-property string-enum elicitation", async () => { + const { connection, calls } = createElicitConnection(async () => ({ + action: "accept", + content: { value: "second" }, + })); + const ctx = createAcpExtensionUiContext(connection, () => "session-select", FORM_CAPABILITIES); + + const result = await ctx.select("Pick one", ["first", "second", "third"]); + + expect(result).toBe("second"); + expect(calls).toHaveLength(1); + const request = calls[0]!; + expect(request.mode).toBe("form"); + expect(request.message).toBe("Pick one"); + if (request.mode !== "form" || !("sessionId" in request)) { + throw new Error("expected session-scoped form elicitation"); + } + expect(request.sessionId).toBe("session-select"); + expect(request.requestedSchema).toEqual({ + type: "object", + properties: { value: { type: "string", enum: ["first", "second", "third"] } }, + required: ["value"], + }); + }); + + it("translates confirm to a boolean elicitation and returns the accepted value", async () => { + const { connection, calls } = createElicitConnection(async () => ({ + action: "accept", + content: { value: true }, + })); + const ctx = createAcpExtensionUiContext(connection, () => "session-confirm", FORM_CAPABILITIES); + + const result = await ctx.confirm("Proceed?", "This will overwrite the file."); + + expect(result).toBe(true); + expect(calls).toHaveLength(1); + const request = calls[0]!; + if (request.mode !== "form") { + throw new Error("expected form-mode elicitation"); + } + expect(request.message).toBe("Proceed?\n\nThis will overwrite the file."); + expect(request.requestedSchema.properties?.value).toEqual({ type: "boolean" }); + expect(request.requestedSchema.required).toEqual(["value"]); + }); + + it("translates input to a string elicitation and surfaces the placeholder as description", async () => { + const { connection, calls } = createElicitConnection(async () => ({ + action: "accept", + content: { value: "claude" }, + })); + const ctx = createAcpExtensionUiContext(connection, () => "session-input", FORM_CAPABILITIES); + + const result = await ctx.input("Your name?", "e.g. claude"); + + expect(result).toBe("claude"); + expect(calls).toHaveLength(1); + const request = calls[0]!; + if (request.mode !== "form") { + throw new Error("expected form-mode elicitation"); + } + expect(request.message).toBe("Your name?"); + expect(request.requestedSchema.properties?.value).toEqual({ + type: "string", + description: "e.g. claude", + }); + }); + + it("returns undefined / false for decline and cancel actions", async () => { + let nextAction: "decline" | "cancel" = "decline"; + const { connection } = createElicitConnection(async () => ({ action: nextAction })); + const ctx = createAcpExtensionUiContext(connection, () => "session-cancel", FORM_CAPABILITIES); + + for (const action of ["decline", "cancel"] as const) { + nextAction = action; + expect(await ctx.select("X", ["a"])).toBeUndefined(); + expect(await ctx.confirm("X", "Y")).toBe(false); + expect(await ctx.input("X")).toBeUndefined(); + } + }); + + it("falls back to the stubbed behaviour when the client does not advertise form elicitation", async () => { + const { connection, calls } = createElicitConnection(async () => ({ + action: "accept", + content: { value: "ignored" }, + })); + const ctx = createAcpExtensionUiContext(connection, () => "session-nocaps", {}); + + expect(await ctx.select("X", ["a"])).toBeUndefined(); + expect(await ctx.confirm("X", "Y")).toBe(false); + expect(await ctx.input("X")).toBeUndefined(); + expect(calls).toHaveLength(0); + }); + + it("treats transport-level elicitation failures as undecided input", async () => { + const { connection, calls } = createElicitConnection(async () => { + throw new Error("connection closed"); + }); + const ctx = createAcpExtensionUiContext(connection, () => "session-throw", FORM_CAPABILITIES); + + expect(await ctx.select("X", ["a"])).toBeUndefined(); + expect(await ctx.confirm("X", "Y")).toBe(false); + expect(await ctx.input("X")).toBeUndefined(); + expect(calls).toHaveLength(3); + }); + + it("skips the SDK call entirely when dialogOptions.signal is already aborted", async () => { + const { connection, calls } = createElicitConnection(async () => ({ + action: "accept", + content: { value: "ignored" }, + })); + const ctx = createAcpExtensionUiContext(connection, () => "session-preabort", FORM_CAPABILITIES); + const controller = new AbortController(); + controller.abort(); + + expect(await ctx.select("X", ["a"], { signal: controller.signal })).toBeUndefined(); + expect(await ctx.confirm("X", "Y", { signal: controller.signal })).toBe(false); + expect(await ctx.input("X", undefined, { signal: controller.signal })).toBeUndefined(); + expect(calls).toHaveLength(0); + }); + + it("resolves to the stub fallback when dialogOptions.signal aborts mid-flight", async () => { + const { resolve, promise: never } = Promise.withResolvers(); + const { connection, calls } = createElicitConnection(() => never); + const ctx = createAcpExtensionUiContext(connection, () => "session-midabort", FORM_CAPABILITIES); + const controller = new AbortController(); + + const pending = ctx.select("X", ["a"], { signal: controller.signal }); + controller.abort(); + expect(await pending).toBeUndefined(); + expect(calls).toHaveLength(1); + // Resolve the never-promise so the bridge's `.then(finish)` chain settles + // and Bun's promise tracker doesn't flag a leaked pending promise. + resolve({ action: "decline" }); + }); + + it("returns the stub fallback when the client sends a wrong-typed accept payload", async () => { + // confirm expects a boolean; a string `value` must narrow to `false`. + const stringForBool = createElicitConnection(async () => ({ + action: "accept", + content: { value: "yes" }, + })); + const boolCtx = createAcpExtensionUiContext( + stringForBool.connection, + () => "session-wrongtype-bool", + FORM_CAPABILITIES, + ); + expect(await boolCtx.confirm("Proceed?", "")).toBe(false); + + // select expects a string; a boolean `value` must narrow to `undefined`. + const boolForString = createElicitConnection(async () => ({ + action: "accept", + content: { value: true }, + })); + const selectCtx = createAcpExtensionUiContext( + boolForString.connection, + () => "session-wrongtype-str", + FORM_CAPABILITIES, + ); + expect(await selectCtx.select("Pick", ["a"])).toBeUndefined(); + }); + + it("returns the stub fallback when accept arrives without the expected `value` key", async () => { + // content present but missing the `value` key — the bridge looks up + // `response.content.value` which is `undefined`, so the typeof guard fires. + const missingKey = createElicitConnection(async () => ({ + action: "accept", + content: { other: "noise" } as never, + })); + const ctx = createAcpExtensionUiContext(missingKey.connection, () => "session-missingkey", FORM_CAPABILITIES); + expect(await ctx.select("Pick", ["a"])).toBeUndefined(); + expect(await ctx.confirm("Proceed?", "")).toBe(false); + expect(await ctx.input("Name?")).toBeUndefined(); + }); + + it("returns the stub fallback when accept arrives with no content at all", async () => { + // content omitted entirely — the `!response.content` guard short-circuits + // before the per-method narrow has a chance to run. + const noContent = createElicitConnection(async () => ({ action: "accept" })); + const ctx = createAcpExtensionUiContext(noContent.connection, () => "session-nocontent", FORM_CAPABILITIES); + expect(await ctx.select("Pick", ["a"])).toBeUndefined(); + expect(await ctx.confirm("Proceed?", "")).toBe(false); + expect(await ctx.input("Name?")).toBeUndefined(); + }); + + it("fires onTimeout and resolves to the stub fallback when dialogOptions.timeout expires", async () => { + const { promise: never } = Promise.withResolvers(); + const { connection, calls } = createElicitConnection(() => never); + const ctx = createAcpExtensionUiContext(connection, () => "session-timeout", FORM_CAPABILITIES); + let timeoutFired = 0; + const result = await ctx.select("Pick", ["a"], { timeout: 1, onTimeout: () => timeoutFired++ }); + expect(result).toBeUndefined(); + expect(timeoutFired).toBe(1); + expect(calls).toHaveLength(1); + }); + + it("treats whitespace-only placeholder as absent on `input`", async () => { + const { connection, calls } = createElicitConnection(async () => ({ + action: "accept", + content: { value: "n" }, + })); + const ctx = createAcpExtensionUiContext(connection, () => "session-ws-placeholder", FORM_CAPABILITIES); + + await ctx.input("Name?", " "); + + expect(calls).toHaveLength(1); + const request = calls[0]!; + if (request.mode !== "form") throw new Error("expected form-mode elicitation"); + expect(request.requestedSchema.properties?.value).toEqual({ type: "string" }); + }); + + it("sends `message === title` on `confirm` when the message is empty (no join)", async () => { + const { connection, calls } = createElicitConnection(async () => ({ + action: "accept", + content: { value: true }, + })); + const ctx = createAcpExtensionUiContext(connection, () => "session-confirm-empty", FORM_CAPABILITIES); + + await ctx.confirm("Proceed?", ""); + // Whitespace-only message must follow the same branch as empty — + // CHANGELOG says join only when the message is non-empty. + await ctx.confirm("Proceed?", " "); + + expect(calls).toHaveLength(2); + expect(calls[0]!.message).toBe("Proceed?"); + expect(calls[1]!.message).toBe("Proceed?"); + }); + + it("still resolves to the stub fallback when dialogOptions.onTimeout throws", async () => { + const { promise: never } = Promise.withResolvers(); + const { connection } = createElicitConnection(() => never); + const ctx = createAcpExtensionUiContext(connection, () => "session-timeout-throw", FORM_CAPABILITIES); + + const result = await ctx.select("Pick", ["a"], { + timeout: 1, + onTimeout: () => { + throw new Error("boom"); + }, + }); + + expect(result).toBeUndefined(); + }); + + it("reads the sessionId getter on every elicitation so mid-flight session changes are reflected", async () => { + // `record.session.sessionId` mutates when an extension command calls + // `ctx.switchSession` / `ctx.newSession`. Snapshotting it once at + // factory time would route later elicitations to the pre-switch id. + const { connection, calls } = createElicitConnection(async () => ({ + action: "accept", + content: { value: "ok" }, + })); + let currentSessionId = "session-before-switch"; + const ctx = createAcpExtensionUiContext(connection, () => currentSessionId, FORM_CAPABILITIES); + + await ctx.select("Pick", ["a"]); + currentSessionId = "session-after-switch"; + await ctx.confirm("Continue?", "post-switch"); + await ctx.input("Name?"); + + expect(calls).toHaveLength(3); + // Each call must be a session-scoped form elicitation. Spelled as three + // separate narrows because `mode === "form"` alone leaves both + // `ElicitationRequestScope` and `ElicitationSessionScope` in the union — + // only `"sessionId" in call` picks the session-scoped variant — and + // loop-style narrows don't propagate to the assertions below. + const [first, second, third] = calls; + if (!first || first.mode !== "form" || !("sessionId" in first)) + throw new Error("first call missing sessionId"); + if (!second || second.mode !== "form" || !("sessionId" in second)) + throw new Error("second call missing sessionId"); + if (!third || third.mode !== "form" || !("sessionId" in third)) + throw new Error("third call missing sessionId"); + expect(first.sessionId).toBe("session-before-switch"); + expect(second.sessionId).toBe("session-after-switch"); + expect(third.sessionId).toBe("session-after-switch"); + }); + }); });