Skip to content

Commit 296c48e

Browse files
[Node] Add onElicitationRequest Callback for Elicitation Provider Support
1 parent e3638da commit 296c48e

7 files changed

Lines changed: 350 additions & 4 deletions

File tree

nodejs/README.md

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Create a new conversation session.
120120
- `provider?: ProviderConfig` - Custom API provider configuration (BYOK - Bring Your Own Key). See [Custom Providers](#custom-providers) section.
121121
- `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.
122122
- `onUserInputRequest?: UserInputHandler` - Handler for user input requests from the agent. Enables the `ask_user` tool. See [User Input Requests](#user-input-requests) section.
123+
- `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.
123124
- `hooks?: SessionHooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section.
124125

125126
##### `resumeSession(sessionId: string, config?: ResumeSessionConfig): Promise<CopilotSession>`
@@ -293,6 +294,8 @@ if (session.capabilities.ui?.elicitation) {
293294
}
294295
```
295296

297+
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.
298+
296299
##### `ui: SessionUiApi`
297300

298301
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.
@@ -505,9 +508,9 @@ Commands are sent to the CLI on both `createSession` and `resumeSession`, so you
505508

506509
### UI Elicitation
507510

508-
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.
511+
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.
509512

510-
> **Capability check:** Elicitation is only available when the host advertises support. Always check `session.capabilities.ui?.elicitation` before calling UI methods.
513+
> **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.
511514
512515
```ts
513516
const session = await client.createSession({ onPermissionRequest: approveAll });
@@ -899,6 +902,41 @@ const session = await client.createSession({
899902
});
900903
```
901904

905+
## Elicitation Requests
906+
907+
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.
908+
909+
```typescript
910+
const session = await client.createSession({
911+
model: "gpt-5",
912+
onPermissionRequest: approveAll,
913+
onElicitationRequest: async (request, invocation) => {
914+
// request.message - Description of what information is needed
915+
// request.requestedSchema - JSON Schema describing the form fields
916+
// request.mode - "form" (structured input) or "url" (browser redirect)
917+
// request.elicitationSource - Origin of the request (e.g. MCP server name)
918+
919+
console.log(`Elicitation from ${request.elicitationSource}: ${request.message}`);
920+
921+
// Present UI to the user and collect their response...
922+
return {
923+
action: "accept", // "accept", "decline", or "cancel"
924+
content: { region: "us-east", dryRun: true },
925+
};
926+
},
927+
});
928+
929+
// The session now reports elicitation capability
930+
console.log(session.capabilities.ui?.elicitation); // true
931+
```
932+
933+
When `onElicitationRequest` is provided, the SDK sends `requestElicitation: true` during session create/resume, which enables `session.capabilities.ui.elicitation` on the session.
934+
935+
In multi-client scenarios:
936+
- 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.
937+
- Similarly, if the last elicitation provider disconnects, all clients receive a `capabilities.changed` event indicating elicitation is no longer available.
938+
- The server fans out elicitation requests to **all** connected clients that registered a handler — the first response wins.
939+
902940
## Session Hooks
903941

904942
Hook into session lifecycle events by providing handlers in the `hooks` configuration:

nodejs/src/client.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { getTraceContext } from "./telemetry.js";
3131
import type {
3232
ConnectionState,
3333
CopilotClientOptions,
34+
ElicitationRequest,
35+
ElicitationResult,
3436
ForegroundSessionInfo,
3537
GetAuthStatusResponse,
3638
GetStatusResponse,
@@ -647,6 +649,9 @@ export class CopilotClient {
647649
if (config.onUserInputRequest) {
648650
session.registerUserInputHandler(config.onUserInputRequest);
649651
}
652+
if (config.onElicitationRequest) {
653+
session.registerElicitationHandler(config.onElicitationRequest);
654+
}
650655
if (config.hooks) {
651656
session.registerHooks(config.hooks);
652657
}
@@ -688,6 +693,7 @@ export class CopilotClient {
688693
provider: config.provider,
689694
requestPermission: true,
690695
requestUserInput: !!config.onUserInputRequest,
696+
requestElicitation: !!config.onElicitationRequest,
691697
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
692698
workingDirectory: config.workingDirectory,
693699
streaming: config.streaming,
@@ -769,6 +775,9 @@ export class CopilotClient {
769775
if (config.onUserInputRequest) {
770776
session.registerUserInputHandler(config.onUserInputRequest);
771777
}
778+
if (config.onElicitationRequest) {
779+
session.registerElicitationHandler(config.onElicitationRequest);
780+
}
772781
if (config.hooks) {
773782
session.registerHooks(config.hooks);
774783
}
@@ -810,6 +819,7 @@ export class CopilotClient {
810819
provider: config.provider,
811820
requestPermission: true,
812821
requestUserInput: !!config.onUserInputRequest,
822+
requestElicitation: !!config.onElicitationRequest,
813823
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
814824
workingDirectory: config.workingDirectory,
815825
configDir: config.configDir,
@@ -1597,6 +1607,18 @@ export class CopilotClient {
15971607
await this.handleUserInputRequest(params)
15981608
);
15991609

1610+
this.connection.onRequest(
1611+
"elicitation.request",
1612+
async (params: {
1613+
sessionId: string;
1614+
requestId: string;
1615+
message: string;
1616+
requestedSchema?: unknown;
1617+
mode?: "form" | "url";
1618+
elicitationSource?: string;
1619+
}): Promise<ElicitationResult> => await this.handleElicitationRequest(params)
1620+
);
1621+
16001622
this.connection.onRequest(
16011623
"hooks.invoke",
16021624
async (params: {
@@ -1704,6 +1726,34 @@ export class CopilotClient {
17041726
return result;
17051727
}
17061728

1729+
private async handleElicitationRequest(params: {
1730+
sessionId: string;
1731+
requestId: string;
1732+
message: string;
1733+
requestedSchema?: unknown;
1734+
mode?: "form" | "url";
1735+
elicitationSource?: string;
1736+
}): Promise<ElicitationResult> {
1737+
if (!params || typeof params.sessionId !== "string" || typeof params.message !== "string") {
1738+
throw new Error("Invalid elicitation request payload");
1739+
}
1740+
1741+
const session = this.sessions.get(params.sessionId);
1742+
if (!session) {
1743+
throw new Error(`Session not found: ${params.sessionId}`);
1744+
}
1745+
1746+
return await session._handleElicitationRequest(
1747+
{
1748+
message: params.message,
1749+
requestedSchema: params.requestedSchema as ElicitationRequest["requestedSchema"],
1750+
mode: params.mode,
1751+
elicitationSource: params.elicitationSource,
1752+
},
1753+
params.sessionId
1754+
);
1755+
}
1756+
17071757
private async handleHooksInvoke(params: {
17081758
sessionId: string;
17091759
hookType: string;

nodejs/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export type {
1919
CopilotClientOptions,
2020
CustomAgentConfig,
2121
ElicitationFieldValue,
22+
ElicitationHandler,
2223
ElicitationParams,
24+
ElicitationRequest,
2325
ElicitationResult,
2426
ElicitationSchema,
2527
ElicitationSchemaField,

nodejs/src/session.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import { createSessionRpc } from "./generated/rpc.js";
1313
import { getTraceContext } from "./telemetry.js";
1414
import type {
1515
CommandHandler,
16+
ElicitationHandler,
1617
ElicitationParams,
1718
ElicitationResult,
19+
ElicitationRequest,
1820
InputOptions,
1921
MessageOptions,
2022
PermissionHandler,
@@ -77,6 +79,7 @@ export class CopilotSession {
7779
private commandHandlers: Map<string, CommandHandler> = new Map();
7880
private permissionHandler?: PermissionHandler;
7981
private userInputHandler?: UserInputHandler;
82+
private elicitationHandler?: ElicitationHandler;
8083
private hooks?: SessionHooks;
8184
private transformCallbacks?: Map<string, SectionTransformFn>;
8285
private _rpc: ReturnType<typeof createSessionRpc> | null = null;
@@ -414,6 +417,9 @@ export class CopilotSession {
414417
args: string;
415418
};
416419
void this._executeCommandAndRespond(requestId, commandName, command, args);
420+
} else if ((event as { type: string }).type === "capabilities.changed") {
421+
const data = (event as { data: Partial<SessionCapabilities> }).data;
422+
this._capabilities = { ...this._capabilities, ...data };
417423
}
418424
}
419425

@@ -581,6 +587,30 @@ export class CopilotSession {
581587
}
582588
}
583589

590+
/**
591+
* Registers the elicitation handler for this session.
592+
*
593+
* @param handler - The handler to invoke when the server dispatches an elicitation request
594+
* @internal This method is typically called internally when creating/resuming a session.
595+
*/
596+
registerElicitationHandler(handler?: ElicitationHandler): void {
597+
this.elicitationHandler = handler;
598+
}
599+
600+
/**
601+
* Handles an elicitation.request RPC callback from the server.
602+
* @internal
603+
*/
604+
async _handleElicitationRequest(
605+
request: ElicitationRequest,
606+
sessionId: string
607+
): Promise<ElicitationResult> {
608+
if (!this.elicitationHandler) {
609+
throw new Error("Elicitation requested but no handler registered");
610+
}
611+
return await this.elicitationHandler(request, { sessionId });
612+
}
613+
584614
/**
585615
* Sets the host capabilities for this session.
586616
*

nodejs/src/types.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,30 @@ export interface ElicitationParams {
409409
requestedSchema: ElicitationSchema;
410410
}
411411

412+
/**
413+
* Request payload passed to an elicitation handler callback.
414+
* Extends ElicitationParams with optional metadata fields.
415+
*/
416+
export interface ElicitationRequest {
417+
/** Message describing what information is needed from the user. */
418+
message: string;
419+
/** JSON Schema describing the form fields to present. */
420+
requestedSchema?: ElicitationSchema;
421+
/** Elicitation mode: "form" for structured input, "url" for browser redirect. */
422+
mode?: "form" | "url";
423+
/** The source that initiated the request (e.g. MCP server name). */
424+
elicitationSource?: string;
425+
}
426+
427+
/**
428+
* Handler invoked when the server dispatches an elicitation request to this client.
429+
* Return an {@link ElicitationResult} with the user's response.
430+
*/
431+
export type ElicitationHandler = (
432+
request: ElicitationRequest,
433+
invocation: { sessionId: string }
434+
) => Promise<ElicitationResult> | ElicitationResult;
435+
412436
/**
413437
* Options for the `input()` convenience method.
414438
*/
@@ -1082,6 +1106,13 @@ export interface SessionConfig {
10821106
*/
10831107
onUserInputRequest?: UserInputHandler;
10841108

1109+
/**
1110+
* Handler for elicitation requests from the agent.
1111+
* When provided, the server calls back to this client for form-based UI dialogs.
1112+
* Also enables the `elicitation` capability on the session.
1113+
*/
1114+
onElicitationRequest?: ElicitationHandler;
1115+
10851116
/**
10861117
* Hook handlers for intercepting session lifecycle events.
10871118
* When provided, enables hooks callback allowing custom logic at various points.
@@ -1167,6 +1198,7 @@ export type ResumeSessionConfig = Pick<
11671198
| "reasoningEffort"
11681199
| "onPermissionRequest"
11691200
| "onUserInputRequest"
1201+
| "onElicitationRequest"
11701202
| "hooks"
11711203
| "workingDirectory"
11721204
| "configDir"

nodejs/test/client.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,5 +897,51 @@ describe("CopilotClient", () => {
897897
})
898898
).rejects.toThrow(/not supported/);
899899
});
900+
901+
it("sends requestElicitation flag when onElicitationRequest is provided", async () => {
902+
const client = new CopilotClient();
903+
await client.start();
904+
onTestFinished(() => client.forceStop());
905+
906+
const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest");
907+
908+
const session = await client.createSession({
909+
onPermissionRequest: approveAll,
910+
onElicitationRequest: async () => ({
911+
action: "accept" as const,
912+
content: {},
913+
}),
914+
});
915+
916+
const createCall = rpcSpy.mock.calls.find((c) => c[0] === "session.create");
917+
expect(createCall).toBeDefined();
918+
expect(createCall![1]).toEqual(
919+
expect.objectContaining({
920+
requestElicitation: true,
921+
})
922+
);
923+
rpcSpy.mockRestore();
924+
});
925+
926+
it("does not send requestElicitation when no handler provided", async () => {
927+
const client = new CopilotClient();
928+
await client.start();
929+
onTestFinished(() => client.forceStop());
930+
931+
const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest");
932+
933+
const session = await client.createSession({
934+
onPermissionRequest: approveAll,
935+
});
936+
937+
const createCall = rpcSpy.mock.calls.find((c) => c[0] === "session.create");
938+
expect(createCall).toBeDefined();
939+
expect(createCall![1]).toEqual(
940+
expect.objectContaining({
941+
requestElicitation: false,
942+
})
943+
);
944+
rpcSpy.mockRestore();
945+
});
900946
});
901947
});

0 commit comments

Comments
 (0)