From 4438bb906faaa4c2efeca54540e06381de8b6aee Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 23 Mar 2026 17:15:49 -0700 Subject: [PATCH 1/2] [Node] Add onElicitationRequest Callback for Elicitation Provider Support --- nodejs/README.md | 42 ++++++- nodejs/src/client.ts | 50 ++++++++ nodejs/src/index.ts | 2 + nodejs/src/session.ts | 30 +++++ nodejs/src/types.ts | 32 ++++++ nodejs/test/client.test.ts | 46 ++++++++ nodejs/test/e2e/ui_elicitation.test.ts | 152 ++++++++++++++++++++++++- 7 files changed, 350 insertions(+), 4 deletions(-) diff --git a/nodejs/README.md b/nodejs/README.md index c3503d4f..64c6868d 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -117,6 +117,7 @@ Create a new conversation session. - `provider?: ProviderConfig` - Custom API provider configuration (BYOK - Bring Your Own Key). See [Custom Providers](#custom-providers) section. - `onPermissionRequest: PermissionHandler` - **Required.** Handler called before each tool execution to approve or deny it. Use `approveAll` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section. - `onUserInputRequest?: UserInputHandler` - Handler for user input requests from the agent. Enables the `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `onElicitationRequest?: ElicitationHandler` - Handler for elicitation requests dispatched by the server. Enables this client to present form-based UI dialogs on behalf of the agent or other session participants. See [Elicitation Requests](#elicitation-requests) section. - `hooks?: SessionHooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. ##### `resumeSession(sessionId: string, config?: ResumeSessionConfig): Promise` @@ -289,6 +290,8 @@ if (session.capabilities.ui?.elicitation) { } ``` +Capabilities may update during the session. For example, when another client joins or disconnects with an elicitation handler. The SDK automatically applies `capabilities.changed` events, so this property always reflects the current state. + ##### `ui: SessionUiApi` Interactive UI methods for showing dialogs to the user. Only available when the CLI host supports elicitation (`session.capabilities.ui?.elicitation === true`). See [UI Elicitation](#ui-elicitation) for full details. @@ -497,9 +500,9 @@ Commands are sent to the CLI on both `createSession` and `resumeSession`, so you ### UI Elicitation -When the CLI is running with a TUI (not in headless mode), the SDK can request interactive form dialogs from the user. The `session.ui` object provides convenience methods built on a single generic `elicitation` RPC. +When the session has elicitation support — either from the CLI's TUI or from another client that registered an `onElicitationRequest` handler (see [Elicitation Requests](#elicitation-requests)). The SDK can request interactive form dialogs from the user. The `session.ui` object provides convenience methods built on a single generic `elicitation` RPC. -> **Capability check:** Elicitation is only available when the host advertises support. Always check `session.capabilities.ui?.elicitation` before calling UI methods. +> **Capability check:** Elicitation is only available when at least one connected participant advertises support. Always check `session.capabilities.ui?.elicitation` before calling UI methods — this property updates automatically as participants join and leave. ```ts const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -885,6 +888,41 @@ const session = await client.createSession({ }); ``` +## Elicitation Requests + +Register an `onElicitationRequest` handler to let your client act as an elicitation provider — presenting form-based UI dialogs on behalf of the agent. When provided, the server dispatches `elicitation.request` RPCs to your client whenever a tool or MCP server needs structured user input. + +```typescript +const session = await client.createSession({ + model: "gpt-5", + onPermissionRequest: approveAll, + onElicitationRequest: async (request, invocation) => { + // request.message - Description of what information is needed + // request.requestedSchema - JSON Schema describing the form fields + // request.mode - "form" (structured input) or "url" (browser redirect) + // request.elicitationSource - Origin of the request (e.g. MCP server name) + + console.log(`Elicitation from ${request.elicitationSource}: ${request.message}`); + + // Present UI to the user and collect their response... + return { + action: "accept", // "accept", "decline", or "cancel" + content: { region: "us-east", dryRun: true }, + }; + }, +}); + +// The session now reports elicitation capability +console.log(session.capabilities.ui?.elicitation); // true +``` + +When `onElicitationRequest` is provided, the SDK sends `requestElicitation: true` during session create/resume, which enables `session.capabilities.ui.elicitation` on the session. + +In multi-client scenarios: +- If no connected client was previously providing an elicitation capability, but a new client joins that can, all clients will receive a `capabilities.changed` event to notify them that elicitation is now possible. The SDK automatically updates `session.capabilities` when these events arrive. +- Similarly, if the last elicitation provider disconnects, all clients receive a `capabilities.changed` event indicating elicitation is no longer available. +- The server fans out elicitation requests to **all** connected clients that registered a handler — the first response wins. + ## Session Hooks Hook into session lifecycle events by providing handlers in the `hooks` configuration: diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index dc710325..415a2a98 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,6 +31,8 @@ import { getTraceContext } from "./telemetry.js"; import type { ConnectionState, CopilotClientOptions, + ElicitationRequest, + ElicitationResult, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, @@ -644,6 +646,9 @@ export class CopilotClient { if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); } + if (config.onElicitationRequest) { + session.registerElicitationHandler(config.onElicitationRequest); + } if (config.hooks) { session.registerHooks(config.hooks); } @@ -685,6 +690,7 @@ export class CopilotClient { provider: config.provider, requestPermission: true, requestUserInput: !!config.onUserInputRequest, + requestElicitation: !!config.onElicitationRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, streaming: config.streaming, @@ -766,6 +772,9 @@ export class CopilotClient { if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); } + if (config.onElicitationRequest) { + session.registerElicitationHandler(config.onElicitationRequest); + } if (config.hooks) { session.registerHooks(config.hooks); } @@ -807,6 +816,7 @@ export class CopilotClient { provider: config.provider, requestPermission: true, requestUserInput: !!config.onUserInputRequest, + requestElicitation: !!config.onElicitationRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, configDir: config.configDir, @@ -1541,6 +1551,18 @@ export class CopilotClient { await this.handleUserInputRequest(params) ); + this.connection.onRequest( + "elicitation.request", + async (params: { + sessionId: string; + requestId: string; + message: string; + requestedSchema?: unknown; + mode?: "form" | "url"; + elicitationSource?: string; + }): Promise => await this.handleElicitationRequest(params) + ); + this.connection.onRequest( "hooks.invoke", async (params: { @@ -1648,6 +1670,34 @@ export class CopilotClient { return result; } + private async handleElicitationRequest(params: { + sessionId: string; + requestId: string; + message: string; + requestedSchema?: unknown; + mode?: "form" | "url"; + elicitationSource?: string; + }): Promise { + if (!params || typeof params.sessionId !== "string" || typeof params.message !== "string") { + throw new Error("Invalid elicitation request payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + return await session._handleElicitationRequest( + { + message: params.message, + requestedSchema: params.requestedSchema as ElicitationRequest["requestedSchema"], + mode: params.mode, + elicitationSource: params.elicitationSource, + }, + params.sessionId + ); + } + private async handleHooksInvoke(params: { sessionId: string; hookType: string; diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index c42935a2..4fc1b75f 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -19,7 +19,9 @@ export type { CopilotClientOptions, CustomAgentConfig, ElicitationFieldValue, + ElicitationHandler, ElicitationParams, + ElicitationRequest, ElicitationResult, ElicitationSchema, ElicitationSchemaField, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 7a0220f6..11dc8c19 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -13,8 +13,10 @@ import { createSessionRpc } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { CommandHandler, + ElicitationHandler, ElicitationParams, ElicitationResult, + ElicitationRequest, InputOptions, MessageOptions, PermissionHandler, @@ -77,6 +79,7 @@ export class CopilotSession { private commandHandlers: Map = new Map(); private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; + private elicitationHandler?: ElicitationHandler; private hooks?: SessionHooks; private transformCallbacks?: Map; private _rpc: ReturnType | null = null; @@ -414,6 +417,9 @@ export class CopilotSession { args: string; }; void this._executeCommandAndRespond(requestId, commandName, command, args); + } else if ((event as { type: string }).type === "capabilities.changed") { + const data = (event as { data: Partial }).data; + this._capabilities = { ...this._capabilities, ...data }; } } @@ -581,6 +587,30 @@ export class CopilotSession { } } + /** + * Registers the elicitation handler for this session. + * + * @param handler - The handler to invoke when the server dispatches an elicitation request + * @internal This method is typically called internally when creating/resuming a session. + */ + registerElicitationHandler(handler?: ElicitationHandler): void { + this.elicitationHandler = handler; + } + + /** + * Handles an elicitation.request RPC callback from the server. + * @internal + */ + async _handleElicitationRequest( + request: ElicitationRequest, + sessionId: string + ): Promise { + if (!this.elicitationHandler) { + throw new Error("Elicitation requested but no handler registered"); + } + return await this.elicitationHandler(request, { sessionId }); + } + /** * Sets the host capabilities for this session. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 96694137..a84f5089 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -409,6 +409,30 @@ export interface ElicitationParams { requestedSchema: ElicitationSchema; } +/** + * Request payload passed to an elicitation handler callback. + * Extends ElicitationParams with optional metadata fields. + */ +export interface ElicitationRequest { + /** Message describing what information is needed from the user. */ + message: string; + /** JSON Schema describing the form fields to present. */ + requestedSchema?: ElicitationSchema; + /** Elicitation mode: "form" for structured input, "url" for browser redirect. */ + mode?: "form" | "url"; + /** The source that initiated the request (e.g. MCP server name). */ + elicitationSource?: string; +} + +/** + * Handler invoked when the server dispatches an elicitation request to this client. + * Return an {@link ElicitationResult} with the user's response. + */ +export type ElicitationHandler = ( + request: ElicitationRequest, + invocation: { sessionId: string } +) => Promise | ElicitationResult; + /** * Options for the `input()` convenience method. */ @@ -1082,6 +1106,13 @@ export interface SessionConfig { */ onUserInputRequest?: UserInputHandler; + /** + * Handler for elicitation requests from the agent. + * When provided, the server calls back to this client for form-based UI dialogs. + * Also enables the `elicitation` capability on the session. + */ + onElicitationRequest?: ElicitationHandler; + /** * Hook handlers for intercepting session lifecycle events. * When provided, enables hooks callback allowing custom logic at various points. @@ -1167,6 +1198,7 @@ export type ResumeSessionConfig = Pick< | "reasoningEffort" | "onPermissionRequest" | "onUserInputRequest" + | "onElicitationRequest" | "hooks" | "workingDirectory" | "configDir" diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 0612cc39..3037a5c9 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -897,5 +897,51 @@ describe("CopilotClient", () => { }) ).rejects.toThrow(/not supported/); }); + + it("sends requestElicitation flag when onElicitationRequest is provided", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + onElicitationRequest: async () => ({ + action: "accept" as const, + content: {}, + }), + }); + + const createCall = rpcSpy.mock.calls.find((c) => c[0] === "session.create"); + expect(createCall).toBeDefined(); + expect(createCall![1]).toEqual( + expect.objectContaining({ + requestElicitation: true, + }) + ); + rpcSpy.mockRestore(); + }); + + it("does not send requestElicitation when no handler provided", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + }); + + const createCall = rpcSpy.mock.calls.find((c) => c[0] === "session.create"); + expect(createCall).toBeDefined(); + expect(createCall![1]).toEqual( + expect.objectContaining({ + requestElicitation: false, + }) + ); + rpcSpy.mockRestore(); + }); }); }); diff --git a/nodejs/test/e2e/ui_elicitation.test.ts b/nodejs/test/e2e/ui_elicitation.test.ts index 212f481f..099aad0c 100644 --- a/nodejs/test/e2e/ui_elicitation.test.ts +++ b/nodejs/test/e2e/ui_elicitation.test.ts @@ -2,8 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { describe, expect, it } from "vitest"; -import { approveAll } from "../../src/index.js"; +import { afterAll, describe, expect, it } from "vitest"; +import { CopilotClient, approveAll } from "../../src/index.js"; +import type { SessionEvent } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("UI Elicitation", async () => { @@ -19,3 +20,150 @@ describe("UI Elicitation", async () => { await expect(session.ui.confirm("test")).rejects.toThrow(/not supported/); }); }); + +describe("UI Elicitation Callback", async () => { + const ctx = await createSdkTestContext(); + const client = ctx.copilotClient; + + it( + "session created with onElicitationRequest reports elicitation capability", + { timeout: 20_000 }, + async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + onElicitationRequest: async () => ({ action: "accept", content: {} }), + }); + + expect(session.capabilities.ui?.elicitation).toBe(true); + } + ); + + it( + "session created without onElicitationRequest reports no elicitation capability", + { timeout: 20_000 }, + async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + }); + + expect(session.capabilities.ui?.elicitation).toBe(false); + } + ); +}); + +describe("UI Elicitation Multi-Client Capabilities", async () => { + // Use TCP mode so a second client can connect to the same CLI process + const ctx = await createSdkTestContext({ useStdio: false }); + const client1 = ctx.copilotClient; + + // Trigger connection so we can read the port + const initSession = await client1.createSession({ onPermissionRequest: approveAll }); + await initSession.disconnect(); + + const actualPort = (client1 as unknown as { actualPort: number }).actualPort; + const client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}` }); + + afterAll(async () => { + await client2.stop(); + }); + + it( + "capabilities.changed fires when second client joins with elicitation handler", + { timeout: 20_000 }, + async () => { + // Client1 creates session without elicitation + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + }); + expect(session1.capabilities.ui?.elicitation).toBe(false); + + // Listen for capabilities.changed event + const capChangedPromise = new Promise((resolve) => { + session1.on((event) => { + if ((event as { type: string }).type === "capabilities.changed") { + resolve(event); + } + }); + }); + + // Client2 joins WITH elicitation handler — triggers capabilities.changed + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + onElicitationRequest: async () => ({ action: "accept", content: {} }), + disableResume: true, + }); + + const capEvent = await capChangedPromise; + const data = (capEvent as { data: { ui?: { elicitation?: boolean } } }).data; + expect(data.ui?.elicitation).toBe(true); + + // Client1's capabilities should have been auto-updated + expect(session1.capabilities.ui?.elicitation).toBe(true); + + await session2.disconnect(); + } + ); + + it( + "capabilities.changed fires when elicitation provider disconnects", + { timeout: 20_000 }, + async () => { + // Client1 creates session without elicitation + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + }); + expect(session1.capabilities.ui?.elicitation).toBe(false); + + // Wait for elicitation to become available + const capEnabledPromise = new Promise((resolve) => { + session1.on((event) => { + const data = event as { + type: string; + data: { ui?: { elicitation?: boolean } }; + }; + if ( + data.type === "capabilities.changed" && + data.data.ui?.elicitation === true + ) { + resolve(); + } + }); + }); + + // Use a dedicated client so we can stop it without affecting shared client2 + const client3 = new CopilotClient({ cliUrl: `localhost:${actualPort}` }); + + // Client3 joins WITH elicitation handler + await client3.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + onElicitationRequest: async () => ({ action: "accept", content: {} }), + disableResume: true, + }); + + await capEnabledPromise; + expect(session1.capabilities.ui?.elicitation).toBe(true); + + // Now listen for the capability being removed + const capDisabledPromise = new Promise((resolve) => { + session1.on((event) => { + const data = event as { + type: string; + data: { ui?: { elicitation?: boolean } }; + }; + if ( + data.type === "capabilities.changed" && + data.data.ui?.elicitation === false + ) { + resolve(); + } + }); + }); + + // Force-stop client3 — destroys the socket, triggering server-side cleanup + await client3.forceStop(); + + await capDisabledPromise; + expect(session1.capabilities.ui?.elicitation).toBe(false); + } + ); +}); From 3a1ec675fa42389431b0a58823b10db1ccad913b Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Tue, 24 Mar 2026 17:15:56 -0700 Subject: [PATCH 2/2] React to runtime changes --- nodejs/src/client.ts | 42 ---------------------------------------- nodejs/src/session.ts | 45 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 49 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 415a2a98..654ad103 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,8 +31,6 @@ import { getTraceContext } from "./telemetry.js"; import type { ConnectionState, CopilotClientOptions, - ElicitationRequest, - ElicitationResult, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, @@ -1551,18 +1549,6 @@ export class CopilotClient { await this.handleUserInputRequest(params) ); - this.connection.onRequest( - "elicitation.request", - async (params: { - sessionId: string; - requestId: string; - message: string; - requestedSchema?: unknown; - mode?: "form" | "url"; - elicitationSource?: string; - }): Promise => await this.handleElicitationRequest(params) - ); - this.connection.onRequest( "hooks.invoke", async (params: { @@ -1670,34 +1656,6 @@ export class CopilotClient { return result; } - private async handleElicitationRequest(params: { - sessionId: string; - requestId: string; - message: string; - requestedSchema?: unknown; - mode?: "form" | "url"; - elicitationSource?: string; - }): Promise { - if (!params || typeof params.sessionId !== "string" || typeof params.message !== "string") { - throw new Error("Invalid elicitation request payload"); - } - - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - - return await session._handleElicitationRequest( - { - message: params.message, - requestedSchema: params.requestedSchema as ElicitationRequest["requestedSchema"], - mode: params.mode, - elicitationSource: params.elicitationSource, - }, - params.sessionId - ); - } - private async handleHooksInvoke(params: { sessionId: string; hookType: string; diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 11dc8c19..0a08c626 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -417,6 +417,21 @@ export class CopilotSession { args: string; }; void this._executeCommandAndRespond(requestId, commandName, command, args); + } else if ((event as { type: string }).type === "elicitation.requested") { + // TODO: Remove type casts above once session-events codegen includes these event types + if (this.elicitationHandler) { + const data = (event as { data: Record }).data; + void this._handleElicitationRequest( + { + message: data.message as string, + requestedSchema: + data.requestedSchema as ElicitationRequest["requestedSchema"], + mode: data.mode as ElicitationRequest["mode"], + elicitationSource: data.elicitationSource as string | undefined, + }, + data.requestId as string + ); + } } else if ((event as { type: string }).type === "capabilities.changed") { const data = (event as { data: Partial }).data; this._capabilities = { ...this._capabilities, ...data }; @@ -598,17 +613,33 @@ export class CopilotSession { } /** - * Handles an elicitation.request RPC callback from the server. + * Handles an elicitation.requested broadcast event. + * Invokes the registered handler and responds via handlePendingElicitation RPC. * @internal */ - async _handleElicitationRequest( - request: ElicitationRequest, - sessionId: string - ): Promise { + async _handleElicitationRequest(request: ElicitationRequest, requestId: string): Promise { if (!this.elicitationHandler) { - throw new Error("Elicitation requested but no handler registered"); + return; + } + try { + const result = await this.elicitationHandler(request, { sessionId: this.sessionId }); + await this.connection.sendRequest("session.ui.handlePendingElicitation", { + sessionId: this.sessionId, + requestId, + result, + }); + } catch { + // Handler failed — attempt to cancel so the request doesn't hang + try { + await this.connection.sendRequest("session.ui.handlePendingElicitation", { + sessionId: this.sessionId, + requestId, + result: { action: "cancel" }, + }); + } catch { + // Best effort — another client may have already responded + } } - return await this.elicitationHandler(request, { sessionId }); } /**