Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<CopilotSession>`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,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);
}
Expand Down Expand Up @@ -685,6 +688,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,
Expand Down Expand Up @@ -766,6 +770,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);
}
Expand Down Expand Up @@ -807,6 +814,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,
Expand Down
2 changes: 2 additions & 0 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export type {
CopilotClientOptions,
CustomAgentConfig,
ElicitationFieldValue,
ElicitationHandler,
ElicitationParams,
ElicitationRequest,
ElicitationResult,
ElicitationSchema,
ElicitationSchemaField,
Expand Down
61 changes: 61 additions & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -77,6 +79,7 @@ export class CopilotSession {
private commandHandlers: Map<string, CommandHandler> = new Map();
private permissionHandler?: PermissionHandler;
private userInputHandler?: UserInputHandler;
private elicitationHandler?: ElicitationHandler;
private hooks?: SessionHooks;
private transformCallbacks?: Map<string, SectionTransformFn>;
private _rpc: ReturnType<typeof createSessionRpc> | null = null;
Expand Down Expand Up @@ -414,6 +417,24 @@ 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<string, unknown> }).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<SessionCapabilities> }).data;
this._capabilities = { ...this._capabilities, ...data };
}
}

Expand Down Expand Up @@ -581,6 +602,46 @@ 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.requested broadcast event.
* Invokes the registered handler and responds via handlePendingElicitation RPC.
* @internal
*/
async _handleElicitationRequest(request: ElicitationRequest, requestId: string): Promise<void> {
if (!this.elicitationHandler) {
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
}
}
}

/**
* Sets the host capabilities for this session.
*
Expand Down
32 changes: 32 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | ElicitationResult;

/**
* Options for the `input()` convenience method.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1167,6 +1198,7 @@ export type ResumeSessionConfig = Pick<
| "reasoningEffort"
| "onPermissionRequest"
| "onUserInputRequest"
| "onElicitationRequest"
| "hooks"
| "workingDirectory"
| "configDir"
Expand Down
46 changes: 46 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,5 +897,51 @@
})
).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({

Check failure on line 908 in nodejs/test/client.test.ts

View workflow job for this annotation

GitHub Actions / Node.js SDK Tests (ubuntu-latest)

'session' is assigned a value but never used. Allowed unused vars must match /^_/u

Check failure on line 908 in nodejs/test/client.test.ts

View workflow job for this annotation

GitHub Actions / Node.js SDK Tests (windows-latest)

'session' is assigned a value but never used. Allowed unused vars must match /^_/u

Check failure on line 908 in nodejs/test/client.test.ts

View workflow job for this annotation

GitHub Actions / Node.js SDK Tests (macos-latest)

'session' is assigned a value but never used. Allowed unused vars must match /^_/u
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({

Check failure on line 933 in nodejs/test/client.test.ts

View workflow job for this annotation

GitHub Actions / Node.js SDK Tests (ubuntu-latest)

'session' is assigned a value but never used. Allowed unused vars must match /^_/u

Check failure on line 933 in nodejs/test/client.test.ts

View workflow job for this annotation

GitHub Actions / Node.js SDK Tests (windows-latest)

'session' is assigned a value but never used. Allowed unused vars must match /^_/u

Check failure on line 933 in nodejs/test/client.test.ts

View workflow job for this annotation

GitHub Actions / Node.js SDK Tests (macos-latest)

'session' is assigned a value but never used. Allowed unused vars must match /^_/u
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();
});
});
});
Loading
Loading