Skip to content

Commit 4438bb9

Browse files
[Node] Add onElicitationRequest Callback for Elicitation Provider Support
1 parent 4088739 commit 4438bb9

File tree

7 files changed

+350
-4
lines changed

7 files changed

+350
-4
lines changed

nodejs/README.md

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

122123
##### `resumeSession(sessionId: string, config?: ResumeSessionConfig): Promise<CopilotSession>`
@@ -289,6 +290,8 @@ if (session.capabilities.ui?.elicitation) {
289290
}
290291
```
291292

293+
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.
294+
292295
##### `ui: SessionUiApi`
293296

294297
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
497500

498501
### UI Elicitation
499502

500-
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.
503+
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.
501504

502-
> **Capability check:** Elicitation is only available when the host advertises support. Always check `session.capabilities.ui?.elicitation` before calling UI methods.
505+
> **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.
503506
504507
```ts
505508
const session = await client.createSession({ onPermissionRequest: approveAll });
@@ -885,6 +888,41 @@ const session = await client.createSession({
885888
});
886889
```
887890

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

890928
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,
@@ -644,6 +646,9 @@ export class CopilotClient {
644646
if (config.onUserInputRequest) {
645647
session.registerUserInputHandler(config.onUserInputRequest);
646648
}
649+
if (config.onElicitationRequest) {
650+
session.registerElicitationHandler(config.onElicitationRequest);
651+
}
647652
if (config.hooks) {
648653
session.registerHooks(config.hooks);
649654
}
@@ -685,6 +690,7 @@ export class CopilotClient {
685690
provider: config.provider,
686691
requestPermission: true,
687692
requestUserInput: !!config.onUserInputRequest,
693+
requestElicitation: !!config.onElicitationRequest,
688694
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
689695
workingDirectory: config.workingDirectory,
690696
streaming: config.streaming,
@@ -766,6 +772,9 @@ export class CopilotClient {
766772
if (config.onUserInputRequest) {
767773
session.registerUserInputHandler(config.onUserInputRequest);
768774
}
775+
if (config.onElicitationRequest) {
776+
session.registerElicitationHandler(config.onElicitationRequest);
777+
}
769778
if (config.hooks) {
770779
session.registerHooks(config.hooks);
771780
}
@@ -807,6 +816,7 @@ export class CopilotClient {
807816
provider: config.provider,
808817
requestPermission: true,
809818
requestUserInput: !!config.onUserInputRequest,
819+
requestElicitation: !!config.onElicitationRequest,
810820
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
811821
workingDirectory: config.workingDirectory,
812822
configDir: config.configDir,
@@ -1541,6 +1551,18 @@ export class CopilotClient {
15411551
await this.handleUserInputRequest(params)
15421552
);
15431553

1554+
this.connection.onRequest(
1555+
"elicitation.request",
1556+
async (params: {
1557+
sessionId: string;
1558+
requestId: string;
1559+
message: string;
1560+
requestedSchema?: unknown;
1561+
mode?: "form" | "url";
1562+
elicitationSource?: string;
1563+
}): Promise<ElicitationResult> => await this.handleElicitationRequest(params)
1564+
);
1565+
15441566
this.connection.onRequest(
15451567
"hooks.invoke",
15461568
async (params: {
@@ -1648,6 +1670,34 @@ export class CopilotClient {
16481670
return result;
16491671
}
16501672

1673+
private async handleElicitationRequest(params: {
1674+
sessionId: string;
1675+
requestId: string;
1676+
message: string;
1677+
requestedSchema?: unknown;
1678+
mode?: "form" | "url";
1679+
elicitationSource?: string;
1680+
}): Promise<ElicitationResult> {
1681+
if (!params || typeof params.sessionId !== "string" || typeof params.message !== "string") {
1682+
throw new Error("Invalid elicitation request payload");
1683+
}
1684+
1685+
const session = this.sessions.get(params.sessionId);
1686+
if (!session) {
1687+
throw new Error(`Session not found: ${params.sessionId}`);
1688+
}
1689+
1690+
return await session._handleElicitationRequest(
1691+
{
1692+
message: params.message,
1693+
requestedSchema: params.requestedSchema as ElicitationRequest["requestedSchema"],
1694+
mode: params.mode,
1695+
elicitationSource: params.elicitationSource,
1696+
},
1697+
params.sessionId
1698+
);
1699+
}
1700+
16511701
private async handleHooksInvoke(params: {
16521702
sessionId: string;
16531703
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)