Merge pull request #1065 from dmarsh-gusto/feat/acp-elicitation

feat(coding-agent/acp): bridge ExtensionUIContext to ACP unstable_createElicitation
This commit is contained in:
Can Bölük
2026-05-15 05:11:10 +02:00
committed by GitHub
3 changed files with 496 additions and 33 deletions
+1
View File
@@ -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://<id>`) 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
+192 -31
View File
@@ -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<AgentSession>;
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<ElicitationContentValue | undefined> {
const signal = dialogOptions?.signal;
if (signal?.aborted) {
return undefined;
}
const { promise, resolve } = Promise.withResolvers<CreateElicitationResponse | undefined>();
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;
+303 -2
View File
@@ -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<CreateElicitationResponse>): {
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<CreateElicitationResponse>();
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<CreateElicitationResponse>();
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<CreateElicitationResponse>();
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");
});
});
});