From 9ef88ccb35dc7c8b01ecbc9f085ac85a0ae08769 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 23 Mar 2026 10:14:47 -0700 Subject: [PATCH 1/3] [Node] Add Commands and UI Elicitation Support to SDK --- nodejs/README.md | 82 ++++++++ nodejs/package-lock.json | 56 +++--- nodejs/package.json | 2 +- nodejs/src/client.ts | 18 +- nodejs/src/index.ts | 11 ++ nodejs/src/session.ts | 187 +++++++++++++++++++ nodejs/src/types.ts | 207 +++++++++++++++++++++ nodejs/test/client.test.ts | 247 +++++++++++++++++++++++++ nodejs/test/e2e/commands.test.ts | 57 ++++++ nodejs/test/e2e/ui_elicitation.test.ts | 21 +++ test/harness/package-lock.json | 56 +++--- test/harness/package.json | 2 +- 12 files changed, 886 insertions(+), 60 deletions(-) create mode 100644 nodejs/test/e2e/commands.test.ts create mode 100644 nodejs/test/e2e/ui_elicitation.test.ts diff --git a/nodejs/README.md b/nodejs/README.md index cc5d62416..c3503d4f1 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -279,6 +279,20 @@ Get all events/messages from this session. Disconnect the session and free resources. Session data on disk is preserved for later resumption. +##### `capabilities: SessionCapabilities` + +Host capabilities reported when the session was created or resumed. Use this to check feature support before calling capability-gated APIs. + +```typescript +if (session.capabilities.ui?.elicitation) { + const ok = await session.ui.confirm("Deploy?"); +} +``` + +##### `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. + ##### `destroy(): Promise` *(deprecated)* Deprecated — use `disconnect()` instead. @@ -294,6 +308,8 @@ Sessions emit various events during processing: - `assistant.message_delta` - Streaming response chunk - `tool.execution_start` - Tool execution started - `tool.execution_complete` - Tool execution completed +- `command.execute` - Command dispatch request (handled internally by the SDK) +- `commands.changed` - Command registration changed - And more... See `SessionEvent` type in the source for full details. @@ -455,6 +471,72 @@ defineTool("safe_lookup", { }) ``` +### Commands + +Register slash commands so that users of the CLI's TUI can invoke custom actions via `/commandName`. Each command has a `name`, optional `description`, and a `handler` called when the user executes it. + +```ts +const session = await client.createSession({ + onPermissionRequest: approveAll, + commands: [ + { + name: "deploy", + description: "Deploy the app to production", + handler: async ({ commandName, args }) => { + console.log(`Deploying with args: ${args}`); + // Do work here — any thrown error is reported back to the CLI + }, + }, + ], +}); +``` + +When the user types `/deploy staging` in the CLI, the SDK receives a `command.execute` event, routes it to your handler, and automatically responds to the CLI. If the handler throws, the error message is forwarded. + +Commands are sent to the CLI on both `createSession` and `resumeSession`, so you can update the command set when resuming. + +### 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. + +> **Capability check:** Elicitation is only available when the host advertises support. Always check `session.capabilities.ui?.elicitation` before calling UI methods. + +```ts +const session = await client.createSession({ onPermissionRequest: approveAll }); + +if (session.capabilities.ui?.elicitation) { + // Confirm dialog — returns boolean + const ok = await session.ui.confirm("Deploy to production?"); + + // Selection dialog — returns selected value or null + const env = await session.ui.select("Pick environment", ["production", "staging", "dev"]); + + // Text input — returns string or null + const name = await session.ui.input("Project name:", { + title: "Name", + minLength: 1, + maxLength: 50, + }); + + // Generic elicitation with full schema control + const result = await session.ui.elicitation({ + message: "Configure deployment", + requestedSchema: { + type: "object", + properties: { + region: { type: "string", enum: ["us-east", "eu-west"] }, + dryRun: { type: "boolean", default: true }, + }, + required: ["region"], + }, + }); + // result.action: "accept" | "decline" | "cancel" + // result.content: { region: "us-east", dryRun: true } (when accepted) +} +``` + +All UI methods throw if elicitation is not supported by the host. + ### System Message Customization Control the system prompt using `systemMessage` in session config: diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 52a84fc9d..d0d1398b2 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.10", + "@github/copilot": "^1.0.11-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.10.tgz", - "integrity": "sha512-RpHYMXYpyAgQLYQ3MB8ubV8zMn/zDatwaNmdxcC8ws7jqM+Ojy7Dz4KFKzyT0rCrWoUCAEBXsXoPbP0LY0FgLw==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.11-1.tgz", + "integrity": "sha512-W34C5TLJxE3SvB/TTt//LBNUbxNZV0tuobWUjBG7TnKQ4HHuJSzvQDE9dtxSfXlVyIzhoPgVYo0cOnN1cITfAA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.10", - "@github/copilot-darwin-x64": "1.0.10", - "@github/copilot-linux-arm64": "1.0.10", - "@github/copilot-linux-x64": "1.0.10", - "@github/copilot-win32-arm64": "1.0.10", - "@github/copilot-win32-x64": "1.0.10" + "@github/copilot-darwin-arm64": "1.0.11-1", + "@github/copilot-darwin-x64": "1.0.11-1", + "@github/copilot-linux-arm64": "1.0.11-1", + "@github/copilot-linux-x64": "1.0.11-1", + "@github/copilot-win32-arm64": "1.0.11-1", + "@github/copilot-win32-x64": "1.0.11-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.10.tgz", - "integrity": "sha512-MNlzwkTQ9iUgHQ+2Z25D0KgYZDEl4riEa1Z4/UCNpHXmmBiIY8xVRbXZTNMB69cnagjQ5Z8D2QM2BjI0kqeFPg==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.11-1.tgz", + "integrity": "sha512-VVL6qgV0MqWfi0Lh5xNuydgqq+QEWty8kXVq9gTHhu9RtVIxMjqF9Ay5IkiKTZf6lijTdMOdlRW6ds90dHQKtQ==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.10.tgz", - "integrity": "sha512-zAQBCbEue/n4xHBzE9T03iuupVXvLtu24MDMeXXtIC0d4O+/WV6j1zVJrp9Snwr0MBWYH+wUrV74peDDdd1VOQ==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.11-1.tgz", + "integrity": "sha512-nHatPin4ZRUmNnSyZ0Vir32M/yWF5fg0IYCT3HOxJCvDxAe60P86FBMWIW5oH4BFWqLB37Vs/XUc5WK08miaLw==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.10.tgz", - "integrity": "sha512-7mJ3uLe7ITyRi2feM1rMLQ5d0bmUGTUwV1ZxKZwSzWCYmuMn05pg4fhIUdxZZZMkLbOl3kG/1J7BxMCTdS2w7A==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.11-1.tgz", + "integrity": "sha512-Ybdb+gzJMKi8+poa+3XQGKPubgh6/LPJFkzhOumKdi/Jf1yOB3QmDXVltjuKbgaav4RZS+Gq8OvfdH4DL987SQ==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.10.tgz", - "integrity": "sha512-66NPaxroRScNCs6TZGX3h1RSKtzew0tcHBkj4J1AHkgYLjNHMdjjBwokGtKeMxzYOCAMBbmJkUDdNGkqsKIKUA==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.11-1.tgz", + "integrity": "sha512-dXwxh9FkheEeeKV8mSW1JGmjjAb7ntE7zoc6GXJJaS1L91QcrfkZag6gbG3fdc2X9hwNZMUCRbVX2meqQidrIg==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.10.tgz", - "integrity": "sha512-WC5M+M75sxLn4lvZ1wPA1Lrs/vXFisPXJPCKbKOMKqzwMLX/IbuybTV4dZDIyGEN591YmOdRIylUF0tVwO8Zmw==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.11-1.tgz", + "integrity": "sha512-YEcACVYSfn2mc+xR+OBSX8XM5HvXMuFIF3NixfswEFzqJBMhHAj9ECtsdAkgG2QEFL8vLkOdpcVwbXqvdu4jxA==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.10.tgz", - "integrity": "sha512-tUfIwyamd0zpm9DVTtbjIWF6j3zrA5A5IkkiuRgsy0HRJPQpeAV7ZYaHEZteHrynaULpl1Gn/Dq0IB4hYc4QtQ==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.11-1.tgz", + "integrity": "sha512-5YsCGeIDC62z7oQbWRjioBOX71JODYeYNif1PrJu2mUavCMuxHdt5/ZasLfX92HZpv+3zIrWTVnNUAaBVPKYlQ==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 7bde33b80..20525385d 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.10", + "@github/copilot": "^1.0.11-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9b8af3dd1..dc7103258 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -639,6 +639,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); @@ -674,6 +675,10 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + commands: config.commands?.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })), systemMessage: wireSystemMessage, availableTools: config.availableTools, excludedTools: config.excludedTools, @@ -693,11 +698,13 @@ export class CopilotClient { infiniteSessions: config.infiniteSessions, }); - const { workspacePath } = response as { + const { workspacePath, capabilities } = response as { sessionId: string; workspacePath?: string; + capabilities?: { ui?: { elicitation?: boolean } }; }; session["_workspacePath"] = workspacePath; + session.setCapabilities(capabilities); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -754,6 +761,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); @@ -792,6 +800,10 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + commands: config.commands?.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })), provider: config.provider, requestPermission: true, requestUserInput: !!config.onUserInputRequest, @@ -809,11 +821,13 @@ export class CopilotClient { disableResume: config.disableResume, }); - const { workspacePath } = response as { + const { workspacePath, capabilities } = response as { sessionId: string; workspacePath?: string; + capabilities?: { ui?: { elicitation?: boolean } }; }; session["_workspacePath"] = workspacePath; + session.setCapabilities(capabilities); } catch (e) { this.sessions.delete(sessionId); throw e; diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index f3788e168..c42935a26 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -12,13 +12,22 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; export { defineTool, approveAll, SYSTEM_PROMPT_SECTIONS } from "./types.js"; export type { + CommandContext, + CommandDefinition, + CommandHandler, ConnectionState, CopilotClientOptions, CustomAgentConfig, + ElicitationFieldValue, + ElicitationParams, + ElicitationResult, + ElicitationSchema, + ElicitationSchemaField, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, InfiniteSessionConfig, + InputOptions, MCPLocalServerConfig, MCPRemoteServerConfig, MCPServerConfig, @@ -34,6 +43,7 @@ export type { SectionOverride, SectionOverrideAction, SectionTransformFn, + SessionCapabilities, SessionConfig, SessionEvent, SessionEventHandler, @@ -45,6 +55,7 @@ export type { SessionContext, SessionListFilter, SessionMetadata, + SessionUiApi, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageCustomizeConfig, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 122f4ece8..a1f868282 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -12,17 +12,23 @@ import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { + CommandHandler, + ElicitationParams, + ElicitationResult, + InputOptions, MessageOptions, PermissionHandler, PermissionRequest, PermissionRequestResult, ReasoningEffort, SectionTransformFn, + SessionCapabilities, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, SessionHooks, + SessionUiApi, Tool, ToolHandler, TraceContextProvider, @@ -68,12 +74,14 @@ export class CopilotSession { private typedEventHandlers: Map void>> = new Map(); private toolHandlers: Map = new Map(); + private commandHandlers: Map = new Map(); private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; private hooks?: SessionHooks; private transformCallbacks?: Map; private _rpc: ReturnType | null = null; private traceContextProvider?: TraceContextProvider; + private _capabilities: SessionCapabilities = {}; /** * Creates a new CopilotSession instance. @@ -112,6 +120,35 @@ export class CopilotSession { return this._workspacePath; } + /** + * Host capabilities reported when the session was created or resumed. + * Use this to check feature support before calling capability-gated APIs. + */ + get capabilities(): SessionCapabilities { + return this._capabilities; + } + + /** + * Interactive UI methods for showing dialogs to the user. + * Only available when the CLI host supports elicitation + * (`session.capabilities.ui?.elicitation === true`). + * + * @example + * ```typescript + * if (session.capabilities.ui?.elicitation) { + * const ok = await session.ui.confirm("Deploy to production?"); + * } + * ``` + */ + get ui(): SessionUiApi { + return { + elicitation: (params: ElicitationParams) => this._elicitation(params), + confirm: (message: string) => this._confirm(message), + select: (message: string, options: string[]) => this._select(message, options), + input: (message: string, options?: InputOptions) => this._input(message, options), + }; + } + /** * Sends a message to this session and waits for the response. * @@ -369,6 +406,14 @@ export class CopilotSession { if (this.permissionHandler) { void this._executePermissionAndRespond(requestId, permissionRequest); } + } else if (event.type === "command.execute") { + const { requestId, commandName, command, args } = event.data as { + requestId: string; + command: string; + commandName: string; + args: string; + }; + void this._executeCommandAndRespond(requestId, commandName, command, args); } } @@ -449,6 +494,46 @@ export class CopilotSession { } } + /** + * Executes a command handler and sends the result back via RPC. + * @internal + */ + private async _executeCommandAndRespond( + requestId: string, + commandName: string, + command: string, + args: string + ): Promise { + const handler = this.commandHandlers.get(commandName); + if (!handler) { + try { + await this.rpc.commands.handlePendingCommand({ + requestId, + error: `Unknown command: ${commandName}`, + }); + } catch (rpcError) { + if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) { + throw rpcError; + } + } + return; + } + + try { + await handler({ sessionId: this.sessionId, command, commandName, args }); + await this.rpc.commands.handlePendingCommand({ requestId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + await this.rpc.commands.handlePendingCommand({ requestId, error: message }); + } catch (rpcError) { + if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) { + throw rpcError; + } + } + } + } + /** * Registers custom tool handlers for this session. * @@ -480,6 +565,108 @@ export class CopilotSession { return this.toolHandlers.get(name); } + /** + * Registers command handlers for this session. + * + * @param commands - An array of command definitions with handlers, or undefined to clear + * @internal This method is typically called internally when creating/resuming a session. + */ + registerCommands(commands?: { name: string; handler: CommandHandler }[]): void { + this.commandHandlers.clear(); + if (!commands) { + return; + } + for (const cmd of commands) { + this.commandHandlers.set(cmd.name, cmd.handler); + } + } + + /** + * Sets the host capabilities for this session. + * + * @param capabilities - The capabilities object from the create/resume response + * @internal This method is typically called internally when creating/resuming a session. + */ + setCapabilities(capabilities?: SessionCapabilities): void { + this._capabilities = capabilities ?? {}; + } + + private assertElicitation(): void { + if (!this._capabilities.ui?.elicitation) { + throw new Error( + "Elicitation is not supported by the host. " + + "Check session.capabilities.ui?.elicitation before calling UI methods." + ); + } + } + + private async _elicitation(params: ElicitationParams): Promise { + this.assertElicitation(); + return this.rpc.ui.elicitation({ + message: params.message, + requestedSchema: params.requestedSchema, + }); + } + + private async _confirm(message: string): Promise { + this.assertElicitation(); + const result = await this.rpc.ui.elicitation({ + message, + requestedSchema: { + type: "object", + properties: { + confirmed: { type: "boolean", default: true }, + }, + required: ["confirmed"], + }, + }); + return result.action === "accept" && (result.content?.confirmed as boolean) === true; + } + + private async _select(message: string, options: string[]): Promise { + this.assertElicitation(); + const result = await this.rpc.ui.elicitation({ + message, + requestedSchema: { + type: "object", + properties: { + selection: { type: "string", enum: options }, + }, + required: ["selection"], + }, + }); + if (result.action === "accept" && result.content?.selection != null) { + return result.content.selection as string; + } + return null; + } + + private async _input(message: string, options?: InputOptions): Promise { + this.assertElicitation(); + const field: Record = { type: "string" as const }; + if (options?.title) field.title = options.title; + if (options?.description) field.description = options.description; + if (options?.minLength != null) field.minLength = options.minLength; + if (options?.maxLength != null) field.maxLength = options.maxLength; + if (options?.format) field.format = options.format; + if (options?.default) field.default = options.default; + + const result = await this.rpc.ui.elicitation({ + message, + requestedSchema: { + type: "object", + properties: { + value: field as ElicitationParams["requestedSchema"]["properties"][string], + }, + required: ["value"], + }, + }); + if (result.action === "accept" && result.content?.value != null) { + return result.content.value as string; + } + return null; + } + /** * Registers a handler for permission requests. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 992dbdb9d..96694137d 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -261,6 +261,205 @@ export function defineTool( return { name, ...config }; } +// ============================================================================ +// Commands +// ============================================================================ + +/** + * Context passed to a command handler when a command is executed. + */ +export interface CommandContext { + /** Session ID where the command was invoked */ + sessionId: string; + /** The full command text (e.g. "/deploy production") */ + command: string; + /** Command name without leading / */ + commandName: string; + /** Raw argument string after the command name */ + args: string; +} + +/** + * Handler invoked when a registered command is executed by a user. + */ +export type CommandHandler = (context: CommandContext) => Promise | void; + +/** + * Definition of a slash command registered with the session. + * When the CLI is running with a TUI, registered commands appear as + * `/commandName` for the user to invoke. + */ +export interface CommandDefinition { + /** Command name (without leading /). */ + name: string; + /** Human-readable description shown in command completion UI. */ + description?: string; + /** Handler invoked when the command is executed. */ + handler: CommandHandler; +} + +// ============================================================================ +// UI Elicitation +// ============================================================================ + +/** + * Capabilities reported by the CLI host for this session. + */ +export interface SessionCapabilities { + ui?: { + /** Whether the host supports interactive elicitation dialogs. */ + elicitation?: boolean; + }; +} + +/** + * A single field in an elicitation schema — matches the MCP SDK's + * `PrimitiveSchemaDefinition` union. + */ +export type ElicitationSchemaField = + | { + type: "string"; + title?: string; + description?: string; + enum: string[]; + enumNames?: string[]; + default?: string; + } + | { + type: "string"; + title?: string; + description?: string; + oneOf: { const: string; title: string }[]; + default?: string; + } + | { + type: "array"; + title?: string; + description?: string; + minItems?: number; + maxItems?: number; + items: { type: "string"; enum: string[] }; + default?: string[]; + } + | { + type: "array"; + title?: string; + description?: string; + minItems?: number; + maxItems?: number; + items: { anyOf: { const: string; title: string }[] }; + default?: string[]; + } + | { + type: "boolean"; + title?: string; + description?: string; + default?: boolean; + } + | { + type: "string"; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: "email" | "uri" | "date" | "date-time"; + default?: string; + } + | { + type: "number" | "integer"; + title?: string; + description?: string; + minimum?: number; + maximum?: number; + default?: number; + }; + +/** + * Schema describing the form fields for an elicitation request. + */ +export interface ElicitationSchema { + type: "object"; + properties: Record; + required?: string[]; +} + +/** + * Primitive field value in an elicitation result. + * Matches MCP SDK's `ElicitResult.content` value type. + */ +export type ElicitationFieldValue = string | number | boolean | string[]; + +/** + * Result returned from an elicitation request. + */ +export interface ElicitationResult { + /** User action: "accept" (submitted), "decline" (rejected), or "cancel" (dismissed). */ + action: "accept" | "decline" | "cancel"; + /** Form values submitted by the user (present when action is "accept"). */ + content?: Record; +} + +/** + * Parameters for a raw elicitation request. + */ +export interface ElicitationParams { + /** Message describing what information is needed from the user. */ + message: string; + /** JSON Schema describing the form fields to present. */ + requestedSchema: ElicitationSchema; +} + +/** + * Options for the `input()` convenience method. + */ +export interface InputOptions { + /** Title label for the input field. */ + title?: string; + /** Descriptive text shown below the field. */ + description?: string; + /** Minimum character length. */ + minLength?: number; + /** Maximum character length. */ + maxLength?: number; + /** Semantic format hint. */ + format?: "email" | "uri" | "date" | "date-time"; + /** Default value pre-populated in the field. */ + default?: string; +} + +/** + * The `session.ui` API object providing interactive UI methods. + * Only usable when the CLI host supports elicitation. + */ +export interface SessionUiApi { + /** + * Shows a generic elicitation dialog with a custom schema. + * @throws Error if the host does not support elicitation. + */ + elicitation(params: ElicitationParams): Promise; + + /** + * Shows a confirmation dialog and returns the user's boolean answer. + * Returns `false` if the user declines or cancels. + * @throws Error if the host does not support elicitation. + */ + confirm(message: string): Promise; + + /** + * Shows a selection dialog with the given options. + * Returns the selected value, or `null` if the user declines/cancels. + * @throws Error if the host does not support elicitation. + */ + select(message: string, options: string[]): Promise; + + /** + * Shows a text input dialog. + * Returns the entered text, or `null` if the user declines/cancels. + * @throws Error if the host does not support elicitation. + */ + input(message: string, options?: InputOptions): Promise; +} + export interface ToolCallRequestPayload { sessionId: string; toolCallId: string; @@ -840,6 +1039,13 @@ export interface SessionConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any tools?: Tool[]; + /** + * Slash commands registered for this session. + * When the CLI has a TUI, each command appears as `/name` for the user to invoke. + * The handler is called when the user executes the command. + */ + commands?: CommandDefinition[]; + /** * System message configuration * Controls how the system prompt is constructed @@ -952,6 +1158,7 @@ export type ResumeSessionConfig = Pick< | "clientName" | "model" | "tools" + | "commands" | "systemMessage" | "availableTools" | "excludedTools" diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3d13d27ff..64f709e5c 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -650,4 +650,251 @@ describe("CopilotClient", () => { expect(params.tracestate).toBeUndefined(); }); }); + + describe("commands", () => { + it("forwards commands in session.create RPC", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + onPermissionRequest: approveAll, + commands: [ + { name: "deploy", description: "Deploy the app", handler: async () => {} }, + { name: "rollback", handler: async () => {} }, + ], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.commands).toEqual([ + { name: "deploy", description: "Deploy the app" }, + { name: "rollback", description: undefined }, + ]); + }); + + it("forwards commands in session.resume RPC", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + commands: [{ name: "deploy", description: "Deploy", handler: async () => {} }], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.commands).toEqual([{ name: "deploy", description: "Deploy" }]); + spy.mockRestore(); + }); + + it("routes command.execute event to the correct handler", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const handler = vi.fn(); + const session = await client.createSession({ + onPermissionRequest: approveAll, + commands: [{ name: "deploy", handler }], + }); + + // Mock the RPC response so handlePendingCommand doesn't fail + const rpcSpy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string) => { + if (method === "session.commands.handlePendingCommand") + return { success: true }; + throw new Error(`Unexpected method: ${method}`); + }); + + // Simulate a command.execute event + (session as any)._dispatchEvent({ + id: "evt-1", + timestamp: new Date().toISOString(), + parentId: null, + ephemeral: true, + type: "command.execute", + data: { + requestId: "req-1", + command: "/deploy production", + commandName: "deploy", + args: "production", + }, + }); + + // Wait for the async handler to complete + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: session.sessionId, + command: "/deploy production", + commandName: "deploy", + args: "production", + }) + ); + + // Verify handlePendingCommand was called with the requestId + expect(rpcSpy).toHaveBeenCalledWith( + "session.commands.handlePendingCommand", + expect.objectContaining({ requestId: "req-1" }) + ); + rpcSpy.mockRestore(); + }); + + it("sends error when command handler throws", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + commands: [ + { + name: "fail", + handler: () => { + throw new Error("deploy failed"); + }, + }, + ], + }); + + const rpcSpy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string) => { + if (method === "session.commands.handlePendingCommand") + return { success: true }; + throw new Error(`Unexpected method: ${method}`); + }); + + (session as any)._dispatchEvent({ + id: "evt-2", + timestamp: new Date().toISOString(), + parentId: null, + ephemeral: true, + type: "command.execute", + data: { + requestId: "req-2", + command: "/fail", + commandName: "fail", + args: "", + }, + }); + + await vi.waitFor(() => + expect(rpcSpy).toHaveBeenCalledWith( + "session.commands.handlePendingCommand", + expect.objectContaining({ requestId: "req-2", error: "deploy failed" }) + ) + ); + rpcSpy.mockRestore(); + }); + + it("sends error for unknown command", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + commands: [{ name: "deploy", handler: async () => {} }], + }); + + const rpcSpy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string) => { + if (method === "session.commands.handlePendingCommand") + return { success: true }; + throw new Error(`Unexpected method: ${method}`); + }); + + (session as any)._dispatchEvent({ + id: "evt-3", + timestamp: new Date().toISOString(), + parentId: null, + ephemeral: true, + type: "command.execute", + data: { + requestId: "req-3", + command: "/unknown", + commandName: "unknown", + args: "", + }, + }); + + await vi.waitFor(() => + expect(rpcSpy).toHaveBeenCalledWith( + "session.commands.handlePendingCommand", + expect.objectContaining({ + requestId: "req-3", + error: expect.stringContaining("Unknown command"), + }) + ) + ); + rpcSpy.mockRestore(); + }); + }); + + describe("ui elicitation", () => { + it("reads capabilities from session.create response", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + // Intercept session.create to inject capabilities + const origSendRequest = (client as any).connection!.sendRequest.bind( + (client as any).connection + ); + vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( + async (method: string, params: any) => { + if (method === "session.create") { + const result = await origSendRequest(method, params); + return { + ...result, + capabilities: { ui: { elicitation: true } }, + }; + } + return origSendRequest(method, params); + } + ); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + expect(session.capabilities).toEqual({ ui: { elicitation: true } }); + }); + + it("defaults capabilities to empty when not in response", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + expect(session.capabilities).toEqual({}); + }); + + it("elicitation throws when capability is missing", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + + await expect( + session.ui.elicitation({ + message: "Enter name", + requestedSchema: { + type: "object", + properties: { name: { type: "string", minLength: 1 } }, + required: ["name"], + }, + }) + ).rejects.toThrow(/not supported/); + }); + }); }); diff --git a/nodejs/test/e2e/commands.test.ts b/nodejs/test/e2e/commands.test.ts new file mode 100644 index 000000000..7217160cf --- /dev/null +++ b/nodejs/test/e2e/commands.test.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +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("Commands", 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("client receives commands.changed when another client joins with commands", { timeout: 20_000 }, async () => { + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + }); + + // Collect events after session creation + const events: SessionEvent[] = []; + session1.on((event) => events.push(event)); + + // Client2 joins with commands + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + commands: [ + { name: "deploy", description: "Deploy the app", handler: async () => {} }, + ], + disableResume: true, + }); + + // Wait for events to propagate + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const commandsChanged = events.filter((e) => e.type === "commands.changed"); + expect(commandsChanged).toHaveLength(1); + expect(commandsChanged[0].data.commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "deploy", description: "Deploy the app" }), + ]), + ); + + await session2.disconnect(); + }); +}); diff --git a/nodejs/test/e2e/ui_elicitation.test.ts b/nodejs/test/e2e/ui_elicitation.test.ts new file mode 100644 index 000000000..212f481fb --- /dev/null +++ b/nodejs/test/e2e/ui_elicitation.test.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("UI Elicitation", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("elicitation methods throw in headless mode", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + }); + + // The SDK spawns the CLI headless - no TUI means no elicitation support. + expect(session.capabilities.ui?.elicitation).toBeFalsy(); + await expect(session.ui.confirm("test")).rejects.toThrow(/not supported/); + }); +}); diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index a9503d7df..66616150f 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.10", + "@github/copilot": "^1.0.11-1", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "openai": "^6.17.0", @@ -462,27 +462,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.10.tgz", - "integrity": "sha512-RpHYMXYpyAgQLYQ3MB8ubV8zMn/zDatwaNmdxcC8ws7jqM+Ojy7Dz4KFKzyT0rCrWoUCAEBXsXoPbP0LY0FgLw==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.11-1.tgz", + "integrity": "sha512-W34C5TLJxE3SvB/TTt//LBNUbxNZV0tuobWUjBG7TnKQ4HHuJSzvQDE9dtxSfXlVyIzhoPgVYo0cOnN1cITfAA==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.10", - "@github/copilot-darwin-x64": "1.0.10", - "@github/copilot-linux-arm64": "1.0.10", - "@github/copilot-linux-x64": "1.0.10", - "@github/copilot-win32-arm64": "1.0.10", - "@github/copilot-win32-x64": "1.0.10" + "@github/copilot-darwin-arm64": "1.0.11-1", + "@github/copilot-darwin-x64": "1.0.11-1", + "@github/copilot-linux-arm64": "1.0.11-1", + "@github/copilot-linux-x64": "1.0.11-1", + "@github/copilot-win32-arm64": "1.0.11-1", + "@github/copilot-win32-x64": "1.0.11-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.10.tgz", - "integrity": "sha512-MNlzwkTQ9iUgHQ+2Z25D0KgYZDEl4riEa1Z4/UCNpHXmmBiIY8xVRbXZTNMB69cnagjQ5Z8D2QM2BjI0kqeFPg==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.11-1.tgz", + "integrity": "sha512-VVL6qgV0MqWfi0Lh5xNuydgqq+QEWty8kXVq9gTHhu9RtVIxMjqF9Ay5IkiKTZf6lijTdMOdlRW6ds90dHQKtQ==", "cpu": [ "arm64" ], @@ -497,9 +497,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.10.tgz", - "integrity": "sha512-zAQBCbEue/n4xHBzE9T03iuupVXvLtu24MDMeXXtIC0d4O+/WV6j1zVJrp9Snwr0MBWYH+wUrV74peDDdd1VOQ==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.11-1.tgz", + "integrity": "sha512-nHatPin4ZRUmNnSyZ0Vir32M/yWF5fg0IYCT3HOxJCvDxAe60P86FBMWIW5oH4BFWqLB37Vs/XUc5WK08miaLw==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.10.tgz", - "integrity": "sha512-7mJ3uLe7ITyRi2feM1rMLQ5d0bmUGTUwV1ZxKZwSzWCYmuMn05pg4fhIUdxZZZMkLbOl3kG/1J7BxMCTdS2w7A==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.11-1.tgz", + "integrity": "sha512-Ybdb+gzJMKi8+poa+3XQGKPubgh6/LPJFkzhOumKdi/Jf1yOB3QmDXVltjuKbgaav4RZS+Gq8OvfdH4DL987SQ==", "cpu": [ "arm64" ], @@ -531,9 +531,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.10.tgz", - "integrity": "sha512-66NPaxroRScNCs6TZGX3h1RSKtzew0tcHBkj4J1AHkgYLjNHMdjjBwokGtKeMxzYOCAMBbmJkUDdNGkqsKIKUA==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.11-1.tgz", + "integrity": "sha512-dXwxh9FkheEeeKV8mSW1JGmjjAb7ntE7zoc6GXJJaS1L91QcrfkZag6gbG3fdc2X9hwNZMUCRbVX2meqQidrIg==", "cpu": [ "x64" ], @@ -548,9 +548,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.10.tgz", - "integrity": "sha512-WC5M+M75sxLn4lvZ1wPA1Lrs/vXFisPXJPCKbKOMKqzwMLX/IbuybTV4dZDIyGEN591YmOdRIylUF0tVwO8Zmw==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.11-1.tgz", + "integrity": "sha512-YEcACVYSfn2mc+xR+OBSX8XM5HvXMuFIF3NixfswEFzqJBMhHAj9ECtsdAkgG2QEFL8vLkOdpcVwbXqvdu4jxA==", "cpu": [ "arm64" ], @@ -565,9 +565,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.10.tgz", - "integrity": "sha512-tUfIwyamd0zpm9DVTtbjIWF6j3zrA5A5IkkiuRgsy0HRJPQpeAV7ZYaHEZteHrynaULpl1Gn/Dq0IB4hYc4QtQ==", + "version": "1.0.11-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.11-1.tgz", + "integrity": "sha512-5YsCGeIDC62z7oQbWRjioBOX71JODYeYNif1PrJu2mUavCMuxHdt5/ZasLfX92HZpv+3zIrWTVnNUAaBVPKYlQ==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 3155d3ef3..99dcb464a 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.10", + "@github/copilot": "^1.0.11-1", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "openai": "^6.17.0", From 6c41687a60b216568e7cf6672ff6ba65c5e796c3 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 23 Mar 2026 10:36:34 -0700 Subject: [PATCH 2/3] CCR feedback and CI fixes --- nodejs/src/session.ts | 2 +- nodejs/test/client.test.ts | 5 +++-- nodejs/test/e2e/commands.test.ts | 20 +++++++++++--------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index a1f868282..7a0220f6f 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -649,7 +649,7 @@ export class CopilotSession { if (options?.minLength != null) field.minLength = options.minLength; if (options?.maxLength != null) field.maxLength = options.maxLength; if (options?.format) field.format = options.format; - if (options?.default) field.default = options.default; + if (options?.default != null) field.default = options.default; const result = await this.rpc.ui.elicitation({ message, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 64f709e5c..0612cc39e 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -869,13 +869,14 @@ describe("CopilotClient", () => { expect(session.capabilities).toEqual({ ui: { elicitation: true } }); }); - it("defaults capabilities to empty when not in response", async () => { + it("defaults capabilities when not injected", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); - expect(session.capabilities).toEqual({}); + // CLI returns actual capabilities (elicitation false in headless mode) + expect(session.capabilities.ui?.elicitation).toBe(false); }); it("elicitation throws when capability is missing", async () => { diff --git a/nodejs/test/e2e/commands.test.ts b/nodejs/test/e2e/commands.test.ts index 7217160cf..d081d74b1 100644 --- a/nodejs/test/e2e/commands.test.ts +++ b/nodejs/test/e2e/commands.test.ts @@ -28,9 +28,14 @@ describe("Commands", async () => { onPermissionRequest: approveAll, }); - // Collect events after session creation - const events: SessionEvent[] = []; - session1.on((event) => events.push(event)); + type CommandsChangedEvent = Extract; + + // Wait for the commands.changed event deterministically + const commandsChangedPromise = new Promise((resolve) => { + session1.on((event) => { + if (event.type === "commands.changed") resolve(event); + }); + }); // Client2 joins with commands const session2 = await client2.resumeSession(session1.sessionId, { @@ -41,12 +46,9 @@ describe("Commands", async () => { disableResume: true, }); - // Wait for events to propagate - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const commandsChanged = events.filter((e) => e.type === "commands.changed"); - expect(commandsChanged).toHaveLength(1); - expect(commandsChanged[0].data.commands).toEqual( + // Rely on default vitest timeout + const commandsChanged = await commandsChangedPromise; + expect(commandsChanged.data.commands).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "deploy", description: "Deploy the app" }), ]), From 3ebda618bec5b51f981d9918437d4aa09e257375 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 23 Mar 2026 10:38:05 -0700 Subject: [PATCH 3/3] Fix prettier --- nodejs/test/e2e/commands.test.ts | 64 +++++++++++++++++--------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/nodejs/test/e2e/commands.test.ts b/nodejs/test/e2e/commands.test.ts index d081d74b1..ea97f0ba0 100644 --- a/nodejs/test/e2e/commands.test.ts +++ b/nodejs/test/e2e/commands.test.ts @@ -23,37 +23,41 @@ describe("Commands", async () => { await client2.stop(); }); - it("client receives commands.changed when another client joins with commands", { timeout: 20_000 }, async () => { - const session1 = await client1.createSession({ - onPermissionRequest: approveAll, - }); + it( + "client receives commands.changed when another client joins with commands", + { timeout: 20_000 }, + async () => { + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + }); - type CommandsChangedEvent = Extract; + type CommandsChangedEvent = Extract; - // Wait for the commands.changed event deterministically - const commandsChangedPromise = new Promise((resolve) => { - session1.on((event) => { - if (event.type === "commands.changed") resolve(event); + // Wait for the commands.changed event deterministically + const commandsChangedPromise = new Promise((resolve) => { + session1.on((event) => { + if (event.type === "commands.changed") resolve(event); + }); }); - }); - - // Client2 joins with commands - const session2 = await client2.resumeSession(session1.sessionId, { - onPermissionRequest: approveAll, - commands: [ - { name: "deploy", description: "Deploy the app", handler: async () => {} }, - ], - disableResume: true, - }); - - // Rely on default vitest timeout - const commandsChanged = await commandsChangedPromise; - expect(commandsChanged.data.commands).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "deploy", description: "Deploy the app" }), - ]), - ); - - await session2.disconnect(); - }); + + // Client2 joins with commands + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + commands: [ + { name: "deploy", description: "Deploy the app", handler: async () => {} }, + ], + disableResume: true, + }); + + // Rely on default vitest timeout + const commandsChanged = await commandsChangedPromise; + expect(commandsChanged.data.commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "deploy", description: "Deploy the app" }), + ]) + ); + + await session2.disconnect(); + } + ); });