From 6b62308f14eb2e26df3bf91e660227a00865e51a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:27:43 +0100 Subject: [PATCH 01/27] Phase A: property/method renames on SessionConfig/ResumeSessionConfig Mirrors C# PR #1343 Phase 4a renames: - onExitPlanMode -> onExitPlanModeRequest - onAutoModeSwitch -> onAutoModeSwitchRequest - createSessionFsHandler -> createSessionFsProvider - ResumeSessionConfig.disableResume -> suppressResumeEvent - ProviderConfig.maxInputTokens -> maxPromptTokens (drops the wire shim) - CopilotSession.getMessages() -> getEvents() - InputOptions -> UiInputOptions Wire RPC name 'session.getMessages' is unchanged (runtime contract). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/troubleshooting/compatibility.md | 4 +- nodejs/README.md | 2 +- nodejs/src/client.ts | 50 +++++++------------ nodejs/src/extension.ts | 2 +- nodejs/src/index.ts | 2 +- nodejs/src/session.ts | 10 ++-- nodejs/src/types.ts | 20 ++++---- nodejs/test/client.test.ts | 16 +++--- nodejs/test/e2e/commands.e2e.test.ts | 2 +- nodejs/test/e2e/error_resilience.e2e.test.ts | 2 +- nodejs/test/e2e/event_fidelity.e2e.test.ts | 2 +- nodejs/test/e2e/harness/sdkTestHelper.ts | 2 +- nodejs/test/e2e/mode_handlers.e2e.test.ts | 4 +- .../test/e2e/pending_work_resume.e2e.test.ts | 4 +- .../e2e/rpc_event_side_effects.e2e.test.ts | 6 +-- nodejs/test/e2e/rpc_session_state.e2e.test.ts | 14 +++--- .../test/e2e/rpc_shell_and_fleet.e2e.test.ts | 2 +- nodejs/test/e2e/session.e2e.test.ts | 26 +++++----- nodejs/test/e2e/session_fs.e2e.test.ts | 20 ++++---- nodejs/test/e2e/session_fs_sqlite.e2e.test.ts | 6 +-- nodejs/test/e2e/session_lifecycle.e2e.test.ts | 2 +- .../test/e2e/streaming_fidelity.e2e.test.ts | 2 +- nodejs/test/e2e/ui_elicitation.e2e.test.ts | 4 +- nodejs/test/extension.test.ts | 6 +-- 24 files changed, 99 insertions(+), 111 deletions(-) diff --git a/docs/troubleshooting/compatibility.md b/docs/troubleshooting/compatibility.md index 8e65128ce..0e7eb4768 100644 --- a/docs/troubleshooting/compatibility.md +++ b/docs/troubleshooting/compatibility.md @@ -29,7 +29,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b | Queueing (enqueue mode) | `send({ mode: "enqueue" })` | Buffer for sequential processing (default) | | File attachments | `send({ attachments: [{ type: "file", path }] })` | Images auto-encoded and resized | | Directory attachments | `send({ attachments: [{ type: "directory", path }] })` | Attach directory context | -| Get history | `getMessages()` | All session events | +| Get history | `getEvents()` | All session events | | Abort | `abort()` | Cancel in-flight request | | **Tools** | | | | Register custom tools | `registerTools()` | Full JSON Schema support | @@ -178,7 +178,7 @@ The `--share` option is not available via SDK. Workarounds: const events: SessionEvent[] = []; session.on((event) => events.push(event)); // ... after conversation ... - const messages = await session.getMessages(); + const messages = await session.getEvents(); // Format as markdown yourself ``` diff --git a/nodejs/README.md b/nodejs/README.md index 5d0458ad9..8888ae251 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -277,7 +277,7 @@ unsubscribe(); Abort the currently processing message in this session. -##### `getMessages(): Promise` +##### `getEvents(): Promise` Get all events/messages from this session. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8a2fe32b4..4c58aace0 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -46,7 +46,6 @@ import type { GetAuthStatusResponse, GetStatusResponse, ModelInfo, - ProviderConfig, ResumeSessionConfig, SectionTransformFn, SessionConfig, @@ -69,17 +68,6 @@ import type { } from "./types.js"; import { defaultJoinSessionPermissionHandler } from "./types.js"; -/** - * Convert a {@link ProviderConfig} to its JSON-RPC wire shape, remapping - * camelCase SDK property names to the wire keys expected by the runtime - * (e.g. `maxInputTokens` → `maxPromptTokens`). - */ -function toWireProviderConfig(provider: ProviderConfig): Record { - const { maxInputTokens, ...rest } = provider; - if (maxInputTokens === undefined) return rest; - return { ...rest, maxPromptTokens: maxInputTokens }; -} - /** * Minimum protocol version this SDK can communicate with. * Servers reporting a version below this are rejected. @@ -452,17 +440,17 @@ export class CopilotClient { private setupSessionFs( session: CopilotSession, - config: { createSessionFsHandler?: (session: CopilotSession) => SessionFsProvider } + config: { createSessionFsProvider?: (session: CopilotSession) => SessionFsProvider } ): void { if (!this.sessionFsConfig) { return; } - if (!config.createSessionFsHandler) { + if (!config.createSessionFsProvider) { throw new Error( - "createSessionFsHandler is required in session config when sessionFs is enabled in client options." + "createSessionFsProvider is required in session config when sessionFs is enabled in client options." ); } - const provider = config.createSessionFsHandler(session); + const provider = config.createSessionFsProvider(session); if (this.sessionFsConfig.capabilities?.sqlite && !provider.sqlite) { throw new Error( "SessionFsConfig declares capabilities.sqlite but the provider does not implement sqlite." @@ -772,11 +760,11 @@ export class CopilotClient { if (config.onElicitationRequest) { session.registerElicitationHandler(config.onElicitationRequest); } - if (config.onExitPlanMode) { - session.registerExitPlanModeHandler(config.onExitPlanMode); + if (config.onExitPlanModeRequest) { + session.registerExitPlanModeHandler(config.onExitPlanModeRequest); } - if (config.onAutoModeSwitch) { - session.registerAutoModeSwitchHandler(config.onAutoModeSwitch); + if (config.onAutoModeSwitchRequest) { + session.registerAutoModeSwitchHandler(config.onAutoModeSwitchRequest); } if (config.hooks) { session.registerHooks(config.hooks); @@ -817,14 +805,14 @@ export class CopilotClient { systemMessage: wireSystemMessage, availableTools: config.availableTools, excludedTools: config.excludedTools, - provider: config.provider ? toWireProviderConfig(config.provider) : undefined, + provider: config.provider, enableSessionTelemetry: config.enableSessionTelemetry, modelCapabilities: config.modelCapabilities, requestPermission: true, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, - requestExitPlanMode: !!config.onExitPlanMode, - requestAutoModeSwitch: !!config.onAutoModeSwitch, + requestExitPlanMode: !!config.onExitPlanModeRequest, + requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, streaming: config.streaming, @@ -910,11 +898,11 @@ export class CopilotClient { if (config.onElicitationRequest) { session.registerElicitationHandler(config.onElicitationRequest); } - if (config.onExitPlanMode) { - session.registerExitPlanModeHandler(config.onExitPlanMode); + if (config.onExitPlanModeRequest) { + session.registerExitPlanModeHandler(config.onExitPlanModeRequest); } - if (config.onAutoModeSwitch) { - session.registerAutoModeSwitchHandler(config.onAutoModeSwitch); + if (config.onAutoModeSwitchRequest) { + session.registerAutoModeSwitchHandler(config.onAutoModeSwitchRequest); } if (config.hooks) { session.registerHooks(config.hooks); @@ -956,14 +944,14 @@ export class CopilotClient { name: cmd.name, description: cmd.description, })), - provider: config.provider ? toWireProviderConfig(config.provider) : undefined, + provider: config.provider, modelCapabilities: config.modelCapabilities, requestPermission: config.onPermissionRequest !== defaultJoinSessionPermissionHandler, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, - requestExitPlanMode: !!config.onExitPlanMode, - requestAutoModeSwitch: !!config.onAutoModeSwitch, + requestExitPlanMode: !!config.onExitPlanModeRequest, + requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, configDir: config.configDir, @@ -979,7 +967,7 @@ export class CopilotClient { instructionDirectories: config.instructionDirectories, disabledSkills: config.disabledSkills, infiniteSessions: config.infiniteSessions, - disableResume: config.disableResume, + suppressResumeEvent: config.suppressResumeEvent, continuePendingWork: config.continuePendingWork, gitHubToken: config.gitHubToken, remoteSession: config.remoteSession, diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index bd35c0997..914a3bf60 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -39,6 +39,6 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise 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), + input: (message: string, options?: UiInputOptions) => this._input(message, options), }; } @@ -770,7 +770,7 @@ export class CopilotSession { return null; } - private async _input(message: string, options?: InputOptions): Promise { + private async _input(message: string, options?: UiInputOptions): Promise { this.assertElicitation(); const field: Record = { type: "string" as const }; if (options?.title) field.title = options.title; @@ -983,7 +983,7 @@ export class CopilotSession { * * @example * ```typescript - * const events = await session.getMessages(); + * const events = await session.getEvents(); * for (const event of events) { * if (event.type === "assistant.message") { * console.log("Assistant:", event.data.content); @@ -991,7 +991,7 @@ export class CopilotSession { * } * ``` */ - async getMessages(): Promise { + async getEvents(): Promise { const response = await this.connection.sendRequest("session.getMessages", { sessionId: this.sessionId, }); diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 00cb177a6..4ffe8e219 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -613,7 +613,7 @@ export type ElicitationHandler = ( /** * Options for the `input()` convenience method. */ -export interface InputOptions { +export interface UiInputOptions { /** Title label for the input field. */ title?: string; /** Descriptive text shown below the field. */ @@ -658,7 +658,7 @@ export interface SessionUiApi { * 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; + input(message: string, options?: UiInputOptions): Promise; } export interface ToolCallRequestPayload { @@ -1413,13 +1413,13 @@ export interface SessionConfig { * Handler for exit-plan-mode requests from the agent. * When provided, enables `exitPlanMode.request` callbacks. */ - onExitPlanMode?: ExitPlanModeHandler; + onExitPlanModeRequest?: ExitPlanModeHandler; /** * Handler for auto-mode-switch requests from the agent. * When provided, enables `autoModeSwitch.request` callbacks. */ - onAutoModeSwitch?: AutoModeSwitchHandler; + onAutoModeSwitchRequest?: AutoModeSwitchHandler; /** * Hook handlers for intercepting session lifecycle events. @@ -1542,7 +1542,7 @@ export interface SessionConfig { * Supplies a handler for session filesystem operations. This takes effect * only if {@link CopilotClientOptions.sessionFs} is configured. */ - createSessionFsHandler?: (session: CopilotSession) => SessionFsProvider; + createSessionFsProvider?: (session: CopilotSession) => SessionFsProvider; } /** @@ -1566,8 +1566,8 @@ export type ResumeSessionConfig = Pick< | "onPermissionRequest" | "onUserInputRequest" | "onElicitationRequest" - | "onExitPlanMode" - | "onAutoModeSwitch" + | "onExitPlanModeRequest" + | "onAutoModeSwitchRequest" | "hooks" | "workingDirectory" | "configDir" @@ -1583,14 +1583,14 @@ export type ResumeSessionConfig = Pick< | "gitHubToken" | "remoteSession" | "onEvent" - | "createSessionFsHandler" + | "createSessionFsProvider" > & { /** * When true, skips emitting the session.resume event. * Useful for reconnecting to a session without triggering resume-related side effects. * @default false */ - disableResume?: boolean; + suppressResumeEvent?: boolean; /** * When true, the runtime continues any tool calls or permission prompts that were * still pending when the session was last suspended. When false (the default), the @@ -1673,7 +1673,7 @@ export interface ProviderConfig { * prompt (system message, history, tool definitions, user message) would * exceed this limit. */ - maxInputTokens?: number; + maxPromptTokens?: number; /** * Overrides the resolved model's default max output tokens. When hit, the diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index a92f54253..de4d7cbcc 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -276,7 +276,7 @@ describe("CopilotClient", () => { headers: { Authorization: "Bearer provider-token" }, modelId: "gpt-4o", wireModel: "my-finetune-v3", - maxInputTokens: 100_000, + maxPromptTokens: 100_000, maxOutputTokens: 4096, }, }); @@ -315,7 +315,7 @@ describe("CopilotClient", () => { headers: { Authorization: "Bearer resume-token" }, modelId: "gpt-4o", wireModel: "my-finetune-v3", - maxInputTokens: 100_000, + maxPromptTokens: 100_000, maxOutputTokens: 4096, }, }); @@ -488,8 +488,8 @@ describe("CopilotClient", () => { await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, - onExitPlanMode: () => ({ approved: true }), - onAutoModeSwitch: () => "yes", + onExitPlanModeRequest: () => ({ approved: true }), + onAutoModeSwitchRequest: () => "yes", }); expect(spy).toHaveBeenCalledWith( @@ -1456,8 +1456,8 @@ describe("CopilotClient", () => { await client.createSession({ onPermissionRequest: approveAll, - onExitPlanMode: () => ({ approved: true }), - onAutoModeSwitch: () => "yes_always", + onExitPlanModeRequest: () => ({ approved: true }), + onAutoModeSwitchRequest: () => "yes_always", }); const createCallWithHandlers = rpcSpy.mock.calls.find((c) => c[0] === "session.create"); @@ -1489,7 +1489,7 @@ describe("CopilotClient", () => { const session = await client.createSession({ onPermissionRequest: approveAll, - onExitPlanMode: (request, invocation) => { + onExitPlanModeRequest: (request, invocation) => { expect(invocation.sessionId).toBeDefined(); expect(request.summary).toBe("Review the plan"); expect(request.planContent).toBe("Plan body"); @@ -1501,7 +1501,7 @@ describe("CopilotClient", () => { feedback: "Looks good", }; }, - onAutoModeSwitch: (request, invocation) => { + onAutoModeSwitchRequest: (request, invocation) => { expect(invocation.sessionId).toBeDefined(); expect(request.errorCode).toBe("user_weekly_rate_limited"); expect(request.retryAfterSeconds).toBe(3600); diff --git a/nodejs/test/e2e/commands.e2e.test.ts b/nodejs/test/e2e/commands.e2e.test.ts index 5ab6a9bbe..452d51f32 100644 --- a/nodejs/test/e2e/commands.e2e.test.ts +++ b/nodejs/test/e2e/commands.e2e.test.ts @@ -50,7 +50,7 @@ describe("Commands", async () => { commands: [ { name: "deploy", description: "Deploy the app", handler: async () => {} }, ], - disableResume: true, + suppressResumeEvent: true, }); // Rely on default vitest timeout diff --git a/nodejs/test/e2e/error_resilience.e2e.test.ts b/nodejs/test/e2e/error_resilience.e2e.test.ts index 183ea1188..188aae0c7 100644 --- a/nodejs/test/e2e/error_resilience.e2e.test.ts +++ b/nodejs/test/e2e/error_resilience.e2e.test.ts @@ -20,7 +20,7 @@ describe("Error Resilience", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); await session.disconnect(); - await expect(session.getMessages()).rejects.toThrow(); + await expect(session.getEvents()).rejects.toThrow(); }); it("should handle double abort without error", async () => { diff --git a/nodejs/test/e2e/event_fidelity.e2e.test.ts b/nodejs/test/e2e/event_fidelity.e2e.test.ts index 2161fa877..95b554bcd 100644 --- a/nodejs/test/e2e/event_fidelity.e2e.test.ts +++ b/nodejs/test/e2e/event_fidelity.e2e.test.ts @@ -205,7 +205,7 @@ describe("Event Fidelity", async () => { prompt: "Read the file 'order.txt' and tell me what the number is.", }); - const messages = await session.getMessages(); + const messages = await session.getEvents(); const types = messages.map((m) => m.type); const sessionStartIdx = types.indexOf("session.start"); diff --git a/nodejs/test/e2e/harness/sdkTestHelper.ts b/nodejs/test/e2e/harness/sdkTestHelper.ts index 183e216f2..18c893f88 100644 --- a/nodejs/test/e2e/harness/sdkTestHelper.ts +++ b/nodejs/test/e2e/harness/sdkTestHelper.ts @@ -26,7 +26,7 @@ function getExistingFinalResponse( alreadyIdle: boolean = false ): Promise { return new Promise(async (resolve, reject) => { - const messages = await session.getMessages(); + const messages = await session.getEvents(); const finalUserMessageIndex = messages.findLastIndex((m) => m.type === "user.message"); const currentTurnMessages = finalUserMessageIndex < 0 ? messages : messages.slice(finalUserMessageIndex); diff --git a/nodejs/test/e2e/mode_handlers.e2e.test.ts b/nodejs/test/e2e/mode_handlers.e2e.test.ts index 702a2d649..c7eb5eae7 100644 --- a/nodejs/test/e2e/mode_handlers.e2e.test.ts +++ b/nodejs/test/e2e/mode_handlers.e2e.test.ts @@ -73,7 +73,7 @@ describe("Mode handlers", async () => { session = await client.createSession({ gitHubToken: MODE_HANDLER_TOKEN, onPermissionRequest: approveAll, - onExitPlanMode: async (request, invocation): Promise => { + onExitPlanModeRequest: async (request, invocation): Promise => { exitPlanModeRequests.push(request); expect(invocation.sessionId).toBe(session?.sessionId); @@ -133,7 +133,7 @@ describe("Mode handlers", async () => { session = await client.createSession({ gitHubToken: MODE_HANDLER_TOKEN, onPermissionRequest: approveAll, - onAutoModeSwitch: (request, invocation) => { + onAutoModeSwitchRequest: (request, invocation) => { autoModeSwitchRequests.push(request); expect(invocation.sessionId).toBe(session?.sessionId); return "yes"; diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index eec241cd3..3769665c9 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -512,7 +512,7 @@ describe("Pending work resume", async () => { }); // Verify resume event has continuePendingWork: false and sessionWasActive: true - const messages = await session2.getMessages(); + const messages = await session2.getEvents(); const resumeEvent = messages.find((m) => m.type === "session.resume"); expect(resumeEvent).toBeDefined(); expect(resumeEvent!.data.continuePendingWork).toBe(false); @@ -577,7 +577,7 @@ describe("Pending work resume", async () => { }); // Verify resume event has continuePendingWork: true and sessionWasActive: false - const messages = await resumedSession.getMessages(); + const messages = await resumedSession.getEvents(); const resumeEvent = messages.find((m) => m.type === "session.resume"); expect(resumeEvent).toBeDefined(); expect(resumeEvent!.data.continuePendingWork).toBe(true); diff --git a/nodejs/test/e2e/rpc_event_side_effects.e2e.test.ts b/nodejs/test/e2e/rpc_event_side_effects.e2e.test.ts index 16432c7af..8d46c913a 100644 --- a/nodejs/test/e2e/rpc_event_side_effects.e2e.test.ts +++ b/nodejs/test/e2e/rpc_event_side_effects.e2e.test.ts @@ -153,7 +153,7 @@ describe("Session RPC event side effects", async () => { try { await session.sendAndWait({ prompt: "Say SNAPSHOT_REWIND_TARGET exactly." }); - const messages = await session.getMessages(); + const messages = await session.getEvents(); const userEvent = messages.find((event) => event.type === "user.message"); expect(userEvent).toBeDefined(); const targetEventId = userEvent!.id; @@ -173,7 +173,7 @@ describe("Session RPC event side effects", async () => { expect(rewindEvent.data.eventsRemoved).toBe(truncateResult.eventsRemoved); expect(rewindEvent.data.upToEventId.toLowerCase()).toBe(targetEventId.toLowerCase()); - const messagesAfter = await session.getMessages(); + const messagesAfter = await session.getEvents(); expect(messagesAfter.some((event) => event.id === targetEventId)).toBe(false); } finally { await session.disconnect(); @@ -185,7 +185,7 @@ describe("Session RPC event side effects", async () => { try { await session.sendAndWait({ prompt: "Say SNAPSHOT_REWIND_TARGET exactly." }); - const messages = await session.getMessages(); + const messages = await session.getEvents(); const userEvent = messages.find((event) => event.type === "user.message"); expect(userEvent).toBeDefined(); diff --git a/nodejs/test/e2e/rpc_session_state.e2e.test.ts b/nodejs/test/e2e/rpc_session_state.e2e.test.ts index 6af08e42a..2fc6ce046 100644 --- a/nodejs/test/e2e/rpc_session_state.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state.e2e.test.ts @@ -151,7 +151,7 @@ describe("Session-scoped RPC", async () => { const initialAnswer = await session.sendAndWait({ prompt: sourcePrompt }); expect(initialAnswer?.data.content ?? "").toContain("FORK_SOURCE_ALPHA"); - const sourceConversation = getConversationMessages(await session.getMessages()); + const sourceConversation = getConversationMessages(await session.getEvents()); expect( sourceConversation.some((m) => m.role === "user" && m.content === sourcePrompt) ).toBe(true); @@ -168,16 +168,16 @@ describe("Session-scoped RPC", async () => { const forkedSession = await client.resumeSession(fork.sessionId, { onPermissionRequest: approveAll, }); - const forkedConversation = getConversationMessages(await forkedSession.getMessages()); + const forkedConversation = getConversationMessages(await forkedSession.getEvents()); expect(forkedConversation.slice(0, sourceConversation.length)).toEqual(sourceConversation); const forkAnswer = await forkedSession.sendAndWait({ prompt: forkPrompt }); expect(forkAnswer?.data.content ?? "").toContain("FORK_CHILD_BETA"); - const sourceAfterFork = getConversationMessages(await session.getMessages()); + const sourceAfterFork = getConversationMessages(await session.getEvents()); expect(sourceAfterFork.some((m) => m.content === forkPrompt)).toBe(false); - const forkAfterPrompt = getConversationMessages(await forkedSession.getMessages()); + const forkAfterPrompt = getConversationMessages(await forkedSession.getEvents()); expect(forkAfterPrompt.some((m) => m.role === "user" && m.content === forkPrompt)).toBe( true ); @@ -212,7 +212,7 @@ describe("Session-scoped RPC", async () => { onPermissionRequest: approveAll, }); try { - expect(getConversationMessages(await forkedSession.getMessages())).toEqual([]); + expect(getConversationMessages(await forkedSession.getEvents())).toEqual([]); } finally { await forkedSession.disconnect(); } @@ -230,7 +230,7 @@ describe("Session-scoped RPC", async () => { await session.sendAndWait({ prompt: firstPrompt }); await session.sendAndWait({ prompt: secondPrompt }); - const sourceEvents = await session.getMessages(); + const sourceEvents = await session.getEvents(); const secondUserEvent = sourceEvents.find( (event) => event.type === "user.message" && event.data.content === secondPrompt ); @@ -248,7 +248,7 @@ describe("Session-scoped RPC", async () => { onPermissionRequest: approveAll, }); try { - const forkedEvents = await forkedSession.getMessages(); + const forkedEvents = await forkedSession.getEvents(); expect(forkedEvents.some((event) => event.id === boundaryEventId)).toBe(false); const forkedConversation = getConversationMessages(forkedEvents); diff --git a/nodejs/test/e2e/rpc_shell_and_fleet.e2e.test.ts b/nodejs/test/e2e/rpc_shell_and_fleet.e2e.test.ts index ce9bed143..6915c7033 100644 --- a/nodejs/test/e2e/rpc_shell_and_fleet.e2e.test.ts +++ b/nodejs/test/e2e/rpc_shell_and_fleet.e2e.test.ts @@ -50,7 +50,7 @@ describe("Shell and fleet RPC", async () => { // session message list is the simplest way to wait for a satisfying state. const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const messages = await session.getMessages(); + const messages = await session.getEvents(); if (predicate(messages)) { return messages; } diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index ca9d2d9d4..19118d3a4 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -21,7 +21,7 @@ describe("Sessions", async () => { }); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); - const allEvents = await session.getMessages(); + const allEvents = await session.getEvents(); const sessionStartEvents = allEvents.filter((e) => e.type === "session.start"); expect(sessionStartEvents).toMatchObject([ { @@ -31,7 +31,7 @@ describe("Sessions", async () => { ]); await session.disconnect(); - await expect(() => session.getMessages()).rejects.toThrow(/Session not found/); + await expect(() => session.getEvents()).rejects.toThrow(/Session not found/); }); // TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle @@ -41,7 +41,7 @@ describe("Sessions", async () => { expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); // Verify it has a start event (confirms session is active) - const messages = await session.getMessages(); + const messages = await session.getEvents(); expect(messages.length).toBeGreaterThan(0); // List sessions and find the one we just created @@ -242,7 +242,7 @@ describe("Sessions", async () => { // All are connected for (const s of [s1, s2, s3]) { - expect(await s.getMessages()).toMatchObject([ + expect(await s.getEvents()).toMatchObject([ { type: "session.start", data: { sessionId: s.sessionId }, @@ -253,7 +253,7 @@ describe("Sessions", async () => { // All can be disconnected await Promise.all([s1.disconnect(), s2.disconnect(), s3.disconnect()]); for (const s of [s1, s2, s3]) { - await expect(() => s.getMessages()).rejects.toThrow(/Session not found/); + await expect(() => s.getEvents()).rejects.toThrow(/Session not found/); } }); @@ -267,7 +267,7 @@ describe("Sessions", async () => { // Resume using the same client const session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll }); expect(session2.sessionId).toBe(sessionId); - const messages = await session2.getMessages(); + const messages = await session2.getEvents(); const assistantMessages = messages.filter((m) => m.type === "assistant.message"); expect(assistantMessages[assistantMessages.length - 1].data.content).toContain("2"); @@ -302,7 +302,7 @@ describe("Sessions", async () => { const answer2 = await getFinalAssistantMessage(session2, { alreadyIdle: true }); expect(answer2?.data.content).toContain("2"); - const messages = await session2.getMessages(); + const messages = await session2.getEvents(); expect(messages).toContainEqual(expect.objectContaining({ type: "user.message" })); expect(messages).toContainEqual(expect.objectContaining({ type: "session.resume" })); @@ -384,7 +384,7 @@ describe("Sessions", async () => { await nextSessionIdle; // The session should still be alive and usable after abort - const messages = await session.getMessages(); + const messages = await session.getEvents(); expect(messages.length).toBeGreaterThan(0); expect(messages.some((m) => m.type === "abort")).toBe(true); @@ -575,7 +575,7 @@ describe("Sessions", async () => { ], }); - const messages = await session.getMessages(); + const messages = await session.getEvents(); const userMessage = messages.filter((m) => m.type === "user.message").at(-1); expect(userMessage).toBeDefined(); const attachments = (userMessage as unknown as { data: { attachments?: unknown[] } }).data @@ -614,7 +614,7 @@ describe("Sessions", async () => { ], }); - const messages = await session.getMessages(); + const messages = await session.getEvents(); const userMessage = messages.filter((m) => m.type === "user.message").at(-1); expect(userMessage).toBeDefined(); const attachments = (userMessage as unknown as { data: { attachments?: unknown[] } }).data @@ -651,7 +651,7 @@ describe("Sessions", async () => { ], }); - const messages = await session.getMessages(); + const messages = await session.getEvents(); const userMessage = messages.filter((m) => m.type === "user.message").at(-1); expect(userMessage).toBeDefined(); const attachments = (userMessage as unknown as { data: { attachments?: unknown[] } }).data @@ -721,7 +721,7 @@ describe("Sessions", async () => { ], }); - const messages = await session.getMessages(); + const messages = await session.getEvents(); const userMessage = messages.filter((m) => m.type === "user.message").at(-1); expect(userMessage).toBeDefined(); const attachments = (userMessage as unknown as { data: { attachments?: unknown[] } }).data @@ -755,7 +755,7 @@ describe("Sessions", async () => { mode: "plan" as unknown as NonNullable[0]["mode"]>, }); - const messages = await session.getMessages(); + const messages = await session.getEvents(); const userMessage = messages.filter((m) => m.type === "user.message").at(-1) as | { data: { content: string; agentMode?: string | null } } | undefined; diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index 16bb22db7..f22ea14d9 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -34,7 +34,7 @@ describe("Session Fs", async () => { // Single provider for the describe block — session IDs are unique per test, // so no cross-contamination between tests. const provider = new MemoryProvider(); - const createSessionFsHandler = (session: CopilotSession) => + const createSessionFsProvider = (session: CopilotSession) => createTestSessionFsHandler(session, provider); // Helpers to build session-namespaced paths for direct provider assertions @@ -51,7 +51,7 @@ describe("Session Fs", async () => { async () => { const session = await client.createSession({ onPermissionRequest: approveAll, - createSessionFsHandler, + createSessionFsProvider, }); const errors: SessionEvent[] = []; @@ -79,7 +79,7 @@ describe("Session Fs", async () => { it("should load session data from fs provider on resume", async () => { const session1 = await client.createSession({ onPermissionRequest: approveAll, - createSessionFsHandler, + createSessionFsProvider, }); const sessionId = session1.sessionId; @@ -92,7 +92,7 @@ describe("Session Fs", async () => { const session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll, - createSessionFsHandler, + createSessionFsProvider, }); // Send another message to verify the session is functional after resume @@ -109,7 +109,7 @@ describe("Session Fs", async () => { env, }); onTestFinished(() => client.forceStop()); - await client.createSession({ onPermissionRequest: approveAll, createSessionFsHandler }); + await client.createSession({ onPermissionRequest: approveAll, createSessionFsProvider }); const { actualPort: port } = client as unknown as { actualPort: number }; @@ -131,7 +131,7 @@ describe("Session Fs", async () => { const suppliedFileContent = "x".repeat(100_000); const session = await client.createSession({ onPermissionRequest: approveAll, - createSessionFsHandler, + createSessionFsProvider, tools: [ defineTool("get_big_string", { description: "Returns a large string", @@ -145,7 +145,7 @@ describe("Session Fs", async () => { }); // The tool result should reference a temp file under the session state path - const messages = await session.getMessages(); + const messages = await session.getEvents(); const toolResult = findToolCallResult(messages, "get_big_string"); expect(toolResult).toContain(`${sessionStatePath}/temp/`); const filename = toolResult?.match( @@ -162,7 +162,7 @@ describe("Session Fs", async () => { it("should write workspace metadata via sessionFs", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, - createSessionFsHandler, + createSessionFsProvider, }); const msg = await session.sendAndWait({ prompt: "What is 7 * 8?" }); @@ -184,7 +184,7 @@ describe("Session Fs", async () => { it("should persist plan.md via sessionFs", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, - createSessionFsHandler, + createSessionFsProvider, }); // Write a plan via the session RPC @@ -202,7 +202,7 @@ describe("Session Fs", async () => { it("should succeed with compaction while using sessionFs", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, - createSessionFsHandler, + createSessionFsProvider, }); let compactionEvent: SessionCompactionCompleteEvent | undefined; diff --git a/nodejs/test/e2e/session_fs_sqlite.e2e.test.ts b/nodejs/test/e2e/session_fs_sqlite.e2e.test.ts index cde6ee8cb..cea67c145 100644 --- a/nodejs/test/e2e/session_fs_sqlite.e2e.test.ts +++ b/nodejs/test/e2e/session_fs_sqlite.e2e.test.ts @@ -45,7 +45,7 @@ describe("Session Fs SQLite", async () => { * re-creates the handler (e.g., on reconnect). */ const sessionDbs = new Map(); - const createSessionFsHandler = (session: CopilotSession) => + const createSessionFsProvider = (session: CopilotSession) => createTestSessionFsHandlerWithSqlite(session, provider, sqliteCalls, sessionDbs); // Helpers to build session-namespaced paths for direct provider assertions @@ -62,7 +62,7 @@ describe("Session Fs SQLite", async () => { async () => { const session = await client.createSession({ onPermissionRequest: approveAll, - createSessionFsHandler, + createSessionFsProvider, }); // Ask the agent to create a table and insert data using the SQL tool @@ -94,7 +94,7 @@ describe("Session Fs SQLite", async () => { async () => { const session = await client.createSession({ onPermissionRequest: approveAll, - createSessionFsHandler, + createSessionFsProvider, }); const events: SessionEvent[] = []; diff --git a/nodejs/test/e2e/session_lifecycle.e2e.test.ts b/nodejs/test/e2e/session_lifecycle.e2e.test.ts index 8b8c9f524..fae878273 100644 --- a/nodejs/test/e2e/session_lifecycle.e2e.test.ts +++ b/nodejs/test/e2e/session_lifecycle.e2e.test.ts @@ -82,7 +82,7 @@ describe("Session Lifecycle", async () => { prompt: "What is 2+2? Reply with just the number.", }); - const messages = await session.getMessages(); + const messages = await session.getEvents(); expect(messages.length).toBeGreaterThan(0); // Should have at least session.start, user.message, assistant.message, session.idle diff --git a/nodejs/test/e2e/streaming_fidelity.e2e.test.ts b/nodejs/test/e2e/streaming_fidelity.e2e.test.ts index 88cbdf879..d9745fdf5 100644 --- a/nodejs/test/e2e/streaming_fidelity.e2e.test.ts +++ b/nodejs/test/e2e/streaming_fidelity.e2e.test.ts @@ -168,7 +168,7 @@ describe("Streaming Fidelity", async () => { expect(lastAssistant.data.content).toContain("255"); // Verify the session was created with reasoning effort via getMessages - const messages = await session.getMessages(); + const messages = await session.getEvents(); const startEvent = messages.find((m) => m.type === "session.start"); expect(startEvent).toBeDefined(); expect(startEvent!.data.reasoningEffort).toBe("high"); diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index e30dbdacd..e17228d5d 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -95,7 +95,7 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { const session2 = await client2.resumeSession(session1.sessionId, { onPermissionRequest: approveAll, onElicitationRequest: async () => ({ action: "accept", content: {} }), - disableResume: true, + suppressResumeEvent: true, }); const capEvent = await capChangedPromise; @@ -147,7 +147,7 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { await client3.resumeSession(session1.sessionId, { onPermissionRequest: approveAll, onElicitationRequest: async () => ({ action: "accept", content: {} }), - disableResume: true, + suppressResumeEvent: true, }); await capEnabledPromise; diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index 1e1f11c88..a522d23d5 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -31,7 +31,7 @@ describe("joinSession", () => { config.onPermissionRequest!({ kind: "write" }, { sessionId: "session-123" }) ); expect(result).toEqual({ kind: "no-result" }); - expect(config.disableResume).toBe(true); + expect(config.suppressResumeEvent).toBe(true); }); it("preserves an explicit onPermissionRequest handler", async () => { @@ -40,10 +40,10 @@ describe("joinSession", () => { .spyOn(CopilotClient.prototype, "resumeSession") .mockResolvedValue({} as any); - await joinSession({ onPermissionRequest: approveAll, disableResume: false }); + await joinSession({ onPermissionRequest: approveAll, suppressResumeEvent: false }); const [, config] = resumeSession.mock.calls[0]!; expect(config.onPermissionRequest).toBe(approveAll); - expect(config.disableResume).toBe(false); + expect(config.suppressResumeEvent).toBe(false); }); }); From ff6fed5181d69e7cec24dc0eb2065d073735166b Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:32:49 +0100 Subject: [PATCH 02/27] Phase C: CopilotClientOptions / MCP / streaming shape changes - Remove autoStart and autoRestart from CopilotClientOptions. The client now always starts on first createSession/resumeSession; users can still call client.start() explicitly for eager startup. - Make MCPServerConfigBase.tools optional (undefined = all, [] = none). - Fix streaming JSDoc block comment that wasn't attached due to single-star. Mirrors C# PR #1343 Phase 4c. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 2 +- nodejs/src/client.ts | 23 ++++++---------------- nodejs/src/types.ts | 19 +++++------------- nodejs/test/cjs-compat.test.ts | 4 ++-- nodejs/test/client.test.ts | 4 ++-- nodejs/test/e2e/client_options.e2e.test.ts | 4 +--- 6 files changed, 17 insertions(+), 39 deletions(-) diff --git a/nodejs/README.md b/nodejs/README.md index 8888ae251..807d51fa6 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -415,7 +415,7 @@ Note: `assistant.message` and `assistant.reasoning` (final events) are always se ### Manual Server Control ```typescript -const client = new CopilotClient({ autoStart: false }); +const client = new CopilotClient({ }); // Start manually await client.start(); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 4c58aace0..b2fb564f0 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -379,8 +379,6 @@ export class CopilotClient { isChildProcess: options.isChildProcess ?? false, cliUrl: options.cliUrl, logLevel: options.logLevel || "debug", - autoStart: options.autoStart ?? true, - autoRestart: false, env: effectiveEnv, gitHubToken: options.gitHubToken, @@ -465,14 +463,14 @@ export class CopilotClient { * If connecting to an external server (via cliUrl), only establishes the connection. * Otherwise, spawns the CLI server process and then connects. * - * This method is called automatically when creating a session if `autoStart` is true (default). + * This method is called automatically the first time you create or resume a session. * * @returns A promise that resolves when the connection is established * @throws Error if the server fails to start or the connection fails * * @example * ```typescript - * const client = new CopilotClient({ autoStart: false }); + * const client = new CopilotClient(); * await client.start(); * // Now ready to create sessions * ``` @@ -707,12 +705,11 @@ export class CopilotClient { * Creates a new conversation session with the Copilot CLI. * * Sessions maintain conversation state, handle events, and manage tool execution. - * If the client is not connected and `autoStart` is enabled, this will automatically - * start the connection. + * If the client is not connected, this method automatically starts the connection. * * @param config - Optional configuration for the session * @returns A promise that resolves with the created session - * @throws Error if the client is not connected and autoStart is disabled + * @throws Error if the client fails to start * * @example * ```typescript @@ -734,11 +731,7 @@ export class CopilotClient { */ async createSession(config: SessionConfig): Promise { if (!this.connection) { - if (this.options.autoStart) { - await this.start(); - } else { - throw new Error("Client not connected. Call start() first."); - } + await this.start(); } const sessionId = config.sessionId ?? randomUUID(); @@ -874,11 +867,7 @@ export class CopilotClient { */ async resumeSession(sessionId: string, config: ResumeSessionConfig): Promise { if (!this.connection) { - if (this.options.autoStart) { - await this.start(); - } else { - throw new Error("Client not connected. Call start() first."); - } + await this.start(); } // Create and register the session before issuing the RPC so that diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 4ffe8e219..fbb031674 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -119,17 +119,6 @@ export interface CopilotClientOptions { */ logLevel?: "none" | "error" | "warning" | "info" | "debug" | "all"; - /** - * Auto-start the CLI server on first use - * @default true - */ - autoStart?: boolean; - - /** - * @deprecated This option has no effect and will be removed in a future release. - */ - autoRestart?: boolean; - /** * Environment variables to pass to the CLI process. If not set, inherits process.env. */ @@ -1145,9 +1134,11 @@ export interface SessionHooks { */ interface MCPServerConfigBase { /** - * List of tools to include from this server. [] means none. "*" means all. + * List of tools to include from this server. + * `undefined` (the default) or `"*"` means include all tools. + * `[]` means include none. */ - tools: string[]; + tools?: string[]; /** * Indicates the server type: "stdio" for local/subprocess servers, "http"/"sse" for remote servers. * If not specified, defaults to "stdio". @@ -1433,7 +1424,7 @@ export interface SessionConfig { */ workingDirectory?: string; - /* + /** * Enable streaming of assistant message and reasoning chunks. * When true, ephemeral assistant.message_delta and assistant.reasoning_delta * events are sent as the response is generated. Clients should accumulate diff --git a/nodejs/test/cjs-compat.test.ts b/nodejs/test/cjs-compat.test.ts index f57403725..31f96898a 100644 --- a/nodejs/test/cjs-compat.test.ts +++ b/nodejs/test/cjs-compat.test.ts @@ -43,7 +43,7 @@ describe("Dual ESM/CJS build (#528)", () => { it("CJS build resolves bundled CLI path", () => { const script = ` const sdk = require(${JSON.stringify(join(distDir, "cjs/index.js"))}); - const client = new sdk.CopilotClient({ autoStart: false }); + const client = new sdk.CopilotClient({ }); console.log('CJS CLI resolved: OK'); `; const output = execFileSync(process.execPath, ["--eval", script], { @@ -59,7 +59,7 @@ describe("Dual ESM/CJS build (#528)", () => { const script = ` import { pathToFileURL } from 'node:url'; const sdk = await import(pathToFileURL(${JSON.stringify(esmPath)}).href); - const client = new sdk.CopilotClient({ autoStart: false }); + const client = new sdk.CopilotClient({ }); console.log('ESM CLI resolved: OK'); `; const output = execFileSync(process.execPath, ["--input-type=module", "--eval", script], { diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index de4d7cbcc..bc4567104 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -8,13 +8,13 @@ import { defaultJoinSessionPermissionHandler } from "../src/types.js"; describe("CopilotClient", () => { it("allows createSession without onPermissionRequest", async () => { - const client = new CopilotClient({ autoStart: false }); + const client = new CopilotClient({ }); await expect(client.createSession({})).rejects.toThrow(/Client not connected/); }); it("allows resumeSession without onPermissionRequest", async () => { - const client = new CopilotClient({ autoStart: false }); + const client = new CopilotClient({ }); await expect(client.resumeSession("session-1", {})).rejects.toThrow(/Client not connected/); }); diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index d67b6a243..dbd0e5859 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -144,8 +144,7 @@ describe("Client options", async () => { const client = new CopilotClient({ cwd: workDir, env, - cliPath: process.env.COPILOT_CLI_PATH, - autoStart: false, + cliPath: process.env.COPILOT_CLI_PATH }); onTestFinished(async () => { try { @@ -247,7 +246,6 @@ describe("Client options", async () => { const client = new CopilotClient({ cwd: workDir, env: { ...env, COPILOT_HOME: copilotHomeFromEnv }, - autoStart: false, cliPath, cliArgs: ["--capture-file", capturePath], copilotHome: copilotHomeFromOption, From 325c9d0d1183ff18ab2988ee453ca752390ae5af Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:34:23 +0100 Subject: [PATCH 03/27] Phase D: lifecycle event polymorphic union + Date timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split SessionLifecycleEvent into a discriminated union of SessionCreatedEvent / SessionDeletedEvent / SessionUpdatedEvent / SessionForegroundEvent / SessionBackgroundEvent. - Promote the metadata payload into a named SessionLifecycleEventMetadata interface; metadata is required on non-delete variants and absent on session.deleted. - Convert metadata.startTime and metadata.modifiedTime from string to Date, matching SessionMetadata. Parse on receipt in client.handleSessionLifecycleNotification. - Export the new variant types from index.ts. Mirrors C# PR #1343 Phase 4f + review §2.3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 21 +++++++++++- nodejs/src/index.ts | 6 ++++ nodejs/src/types.ts | 77 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index b2fb564f0..2240996a6 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1881,7 +1881,26 @@ export class CopilotClient { return; } - const event = notification as SessionLifecycleEvent; + const raw = notification as { + type: SessionLifecycleEventType; + sessionId: string; + metadata?: { startTime?: string; modifiedTime?: string; summary?: string }; + }; + + let metadata: SessionLifecycleEvent["metadata"]; + if (raw.metadata && raw.metadata.startTime && raw.metadata.modifiedTime) { + metadata = { + startTime: new Date(raw.metadata.startTime), + modifiedTime: new Date(raw.metadata.modifiedTime), + summary: raw.metadata.summary, + }; + } + + const event = { + type: raw.type, + sessionId: raw.sessionId, + metadata, + } as SessionLifecycleEvent; // Dispatch to typed handlers for this specific event type const typedHandlers = this.typedLifecycleHandlers.get(event.type); diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index a07fd36f2..fbcefbc23 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -83,8 +83,14 @@ export type { SessionEventPayload, SessionEventType, SessionLifecycleEvent, + SessionLifecycleEventMetadata, SessionLifecycleEventType, SessionLifecycleHandler, + SessionCreatedEvent, + SessionDeletedEvent, + SessionUpdatedEvent, + SessionForegroundEvent, + SessionBackgroundEvent, SessionContext, SessionListFilter, SessionMetadata, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index fbb031674..f043ab091 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1926,7 +1926,7 @@ export interface ModelInfo { // ============================================================================ /** - * Types of session lifecycle events + * Types of session lifecycle events. */ export type SessionLifecycleEventType = | "session.created" @@ -1936,32 +1936,77 @@ export type SessionLifecycleEventType = | "session.background"; /** - * Session lifecycle event notification - * Sent when sessions are created, deleted, updated, or change foreground/background state + * Metadata payload for session lifecycle events. Not present on + * `session.deleted` events. */ -export interface SessionLifecycleEvent { - /** Type of lifecycle event */ - type: SessionLifecycleEventType; - /** ID of the session this event relates to */ +export interface SessionLifecycleEventMetadata { + /** Time the session was created. */ + startTime: Date; + /** Time the session was last modified. */ + modifiedTime: Date; + /** Human-readable summary of the session, if available. */ + summary?: string; +} + +/** Base shape shared by every lifecycle event variant. */ +interface SessionLifecycleEventBase { + /** ID of the session this event relates to. */ sessionId: string; - /** Session metadata (not included for deleted sessions) */ - metadata?: { - startTime: string; - modifiedTime: string; - summary?: string; - }; + /** Session metadata (not included for `session.deleted`). */ + metadata?: SessionLifecycleEventMetadata; +} + +/** Emitted when a new session is created. */ +export interface SessionCreatedEvent extends SessionLifecycleEventBase { + type: "session.created"; + metadata: SessionLifecycleEventMetadata; +} + +/** Emitted when a session is deleted. The metadata field is omitted. */ +export interface SessionDeletedEvent extends SessionLifecycleEventBase { + type: "session.deleted"; + metadata?: undefined; } +/** Emitted when a session's metadata is updated. */ +export interface SessionUpdatedEvent extends SessionLifecycleEventBase { + type: "session.updated"; + metadata: SessionLifecycleEventMetadata; +} + +/** Emitted when a session is brought to the foreground (TUI+server mode). */ +export interface SessionForegroundEvent extends SessionLifecycleEventBase { + type: "session.foreground"; + metadata: SessionLifecycleEventMetadata; +} + +/** Emitted when a session is moved to the background (TUI+server mode). */ +export interface SessionBackgroundEvent extends SessionLifecycleEventBase { + type: "session.background"; + metadata: SessionLifecycleEventMetadata; +} + +/** + * Discriminated union of all session lifecycle events emitted in TUI+server mode. + * Switch on `type` to access the variant-specific metadata. + */ +export type SessionLifecycleEvent = + | SessionCreatedEvent + | SessionDeletedEvent + | SessionUpdatedEvent + | SessionForegroundEvent + | SessionBackgroundEvent; + /** - * Handler for session lifecycle events + * Handler for session lifecycle events. */ export type SessionLifecycleHandler = (event: SessionLifecycleEvent) => void; /** - * Typed handler for specific session lifecycle event types + * Typed handler for specific session lifecycle event types. */ export type TypedSessionLifecycleHandler = ( - event: SessionLifecycleEvent & { type: K } + event: Extract ) => void; /** From 328afa29d907e5ed227dd17e64df1909f3a62dc0 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:35:26 +0100 Subject: [PATCH 04/27] Phase E: hook input timestamps as Date - Change BaseHookInput.timestamp from number (Unix ms) to Date. - Parse incoming numeric timestamps into Date in handleHooksInvoke. - Update hooks_extended.e2e.test.ts assertion accordingly. Mirrors C# PR #1343 Phase 4g. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 19 ++++++++++++++++++- nodejs/src/types.ts | 3 ++- nodejs/test/e2e/hooks_extended.e2e.test.ts | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 2240996a6..6411dead5 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -86,6 +86,23 @@ function isZodSchema(value: unknown): value is { toJSONSchema(): Record), timestamp: new Date(t) }; + } + return input; +} + /** * Convert tool parameters to JSON schema format for sending to CLI */ @@ -2014,7 +2031,7 @@ export class CopilotClient { throw new Error(`Session not found: ${params.sessionId}`); } - const output = await session._handleHooksInvoke(params.hookType, params.input); + const output = await session._handleHooksInvoke(params.hookType, normalizeHookInput(params.input)); return { output }; } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index f043ab091..b3d704fc8 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -932,7 +932,8 @@ export interface BaseHookInput { /** The runtime session ID of the session that triggered the hook. * For sub-agent hooks this differs from `invocation.sessionId`. */ sessionId: string; - timestamp: number; + /** Time at which the hook event was emitted by the runtime. */ + timestamp: Date; cwd: string; } diff --git a/nodejs/test/e2e/hooks_extended.e2e.test.ts b/nodejs/test/e2e/hooks_extended.e2e.test.ts index f4c812eaa..85b51a528 100644 --- a/nodejs/test/e2e/hooks_extended.e2e.test.ts +++ b/nodejs/test/e2e/hooks_extended.e2e.test.ts @@ -102,7 +102,7 @@ describe("Extended session hooks", async () => { onErrorOccurred: async (input, invocation) => { errorInputs.push(input); expect(invocation.sessionId).toBe(session.sessionId); - expect(input.timestamp).toBeGreaterThan(0); + expect(input.timestamp).toBeInstanceOf(Date); expect(input.cwd).toBeDefined(); expect(input.error).toBeDefined(); expect(["model_call", "tool_execution", "system", "user_input"]).toContain( From f9f0083910663953a16d7848ea8c2315765dc499 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:36:41 +0100 Subject: [PATCH 05/27] Phase F: PermissionRequestResult.feedback + use generated PermissionRequest union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional feedback?: string field to PermissionRequestResult so consumers can return free-form text forwarded to the model with the decision. - Delete the hand-written narrow PermissionRequest interface in types.ts and re-export the generated discriminated union from session-events.ts instead. Handlers can now type-safely access per-kind fields (e.g. shell .commands, write .fileName / .diff, mcp .toolName / .args). Mirrors C# PR #1343 Phase 4g + review §2.9. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/types.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index b3d704fc8..be65c2629 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -793,16 +793,25 @@ export type SystemMessageConfig = | SystemMessageCustomizeConfig; /** - * Permission request types from the server + * Permission request types from the server. This is the generated + * discriminated union from the runtime schema — switch on `kind` to + * access the variant-specific fields (e.g. shell `commands`, write + * `fileName`/`diff`, mcp `toolName`/`args`). */ -export interface PermissionRequest { - kind: "shell" | "write" | "mcp" | "read" | "url" | "custom-tool" | "memory" | "hook"; - toolCallId?: string; -} +export type { PermissionRequest } from "./generated/session-events.js"; +import type { PermissionRequest } from "./generated/session-events.js"; import type { PermissionDecisionRequest } from "./generated/rpc.js"; -export type PermissionRequestResult = PermissionDecisionRequest["result"] | { kind: "no-result" }; +/** + * Permission decision result returned from a {@link PermissionHandler}. + * The discriminated `kind` field selects the decision; `feedback` is + * optional free-form text forwarded to the model along with the decision. + */ +export type PermissionRequestResult = ( + | PermissionDecisionRequest["result"] + | { kind: "no-result" } +) & { feedback?: string }; export type PermissionHandler = ( request: PermissionRequest, From dea6d5501ffc2a726ac658b3bb590f2cf9f4224e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:37:47 +0100 Subject: [PATCH 06/27] Phase G: extract SessionConfigBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fragile Pick definition for ResumeSessionConfig with a shared SessionConfigBase interface. SessionConfig and ResumeSessionConfig now both extend it: - SessionConfig adds sessionId? and cloud?. - ResumeSessionConfig adds suppressResumeEvent? and continuePendingWork?. SessionConfigBase is exported from index.ts for consumers that want to build shared helpers over both shapes. Mirrors C# PR #1343 Phase 5 + review §2.2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/index.ts | 1 + nodejs/src/types.ts | 80 +++++++++++++++------------------------------ 2 files changed, 28 insertions(+), 53 deletions(-) diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index fbcefbc23..5ffc25a3a 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -78,6 +78,7 @@ export type { SectionTransformFn, SessionCapabilities, SessionConfig, + SessionConfigBase, SessionEvent, SessionEventHandler, SessionEventPayload, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index be65c2629..8dd9e6922 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1295,13 +1295,12 @@ export interface InfiniteSessionConfig { */ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; -export interface SessionConfig { - /** - * Optional custom session ID - * If not provided, server will generate one - */ - sessionId?: string; - +/** + * Shared configuration fields used by both {@link SessionConfig} (for + * creating a new session) and {@link ResumeSessionConfig} (for resuming + * an existing one). + */ +export interface SessionConfigBase { /** * Client name to identify the application using the SDK. * Included in the User-Agent header for API requests. @@ -1522,12 +1521,6 @@ export interface SessionConfig { */ remoteSession?: RemoteSessionMode; - /** - * Creates a remote session in the cloud instead of a local session. - * The optional repository is associated with the cloud session. - */ - cloud?: CloudSessionOptions; - /** * Optional event handler that is registered on the session before the * session.create RPC is issued. This guarantees that early events emitted @@ -1547,45 +1540,26 @@ export interface SessionConfig { } /** - * Configuration for resuming a session - */ -export type ResumeSessionConfig = Pick< - SessionConfig, - | "clientName" - | "model" - | "tools" - | "commands" - | "systemMessage" - | "availableTools" - | "excludedTools" - | "provider" - | "enableSessionTelemetry" - | "modelCapabilities" - | "streaming" - | "includeSubAgentStreamingEvents" - | "reasoningEffort" - | "onPermissionRequest" - | "onUserInputRequest" - | "onElicitationRequest" - | "onExitPlanModeRequest" - | "onAutoModeSwitchRequest" - | "hooks" - | "workingDirectory" - | "configDir" - | "enableConfigDiscovery" - | "mcpServers" - | "customAgents" - | "defaultAgent" - | "agent" - | "skillDirectories" - | "instructionDirectories" - | "disabledSkills" - | "infiniteSessions" - | "gitHubToken" - | "remoteSession" - | "onEvent" - | "createSessionFsProvider" -> & { + * Configuration for creating a new session via {@link CopilotClient.createSession}. + */ +export interface SessionConfig extends SessionConfigBase { + /** + * Optional custom session ID. If not provided, the server generates one. + */ + sessionId?: string; + + /** + * Creates a remote session in the cloud instead of a local session. + * The optional repository is associated with the cloud session. + */ + cloud?: CloudSessionOptions; +} + +/** + * Configuration for resuming an existing session via + * {@link CopilotClient.resumeSession}. + */ +export interface ResumeSessionConfig extends SessionConfigBase { /** * When true, skips emitting the session.resume event. * Useful for reconnecting to a session without triggering resume-related side effects. @@ -1604,7 +1578,7 @@ export type ResumeSessionConfig = Pick< * @default false */ continuePendingWork?: boolean; -}; +} /** * Configuration for a custom API provider. From 49da9117b4403ce9e57078861582d872916622d6 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:42:27 +0100 Subject: [PATCH 07/27] Phase H: defineTool({ name, ... }) single-arg form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change defineTool from defineTool(name, config) to defineTool({ name, ...config }) so the call shape matches the Tool interface. name remains mandatory and is enforced by the Tool type. Updates all samples, docs, tests, and the CHANGELOG snippet. Review §1.3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 ++--- docs/features/custom-agents.md | 2 +- docs/getting-started.md | 4 +-- nodejs/README.md | 6 ++--- nodejs/examples/basic-example.ts | 2 +- nodejs/samples/manual-tool-resume.ts | 2 +- nodejs/src/types.ts | 25 ++++++++++--------- nodejs/test/e2e/abort.e2e.test.ts | 2 +- nodejs/test/e2e/hooks_extended.e2e.test.ts | 2 +- nodejs/test/e2e/mcp_and_agents.e2e.test.ts | 4 +-- nodejs/test/e2e/multi-client.e2e.test.ts | 10 ++++---- .../test/e2e/pending_work_resume.e2e.test.ts | 12 ++++----- nodejs/test/e2e/permissions.e2e.test.ts | 4 +-- nodejs/test/e2e/session.e2e.test.ts | 2 +- nodejs/test/e2e/session_fs.e2e.test.ts | 2 +- nodejs/test/e2e/suspend.e2e.test.ts | 4 +-- nodejs/test/e2e/tool_results.e2e.test.ts | 12 ++++----- nodejs/test/e2e/tools.e2e.test.ts | 22 ++++++++-------- .../custom-agents/typescript/src/index.ts | 2 +- .../tool-overrides/typescript/src/index.ts | 2 +- .../typescript/src/index.ts | 6 ++--- 21 files changed, 67 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 369c599be..0c5c45a29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -358,11 +358,11 @@ The SDK now uses protocol version 3, where the runtime broadcasts `external_tool ```ts // Two clients each register different tools; the agent can use both const session1 = await client1.createSession({ - tools: [defineTool("search", { handler: doSearch })], + tools: [defineTool({ name: "search", handler: doSearch })], onPermissionRequest: approveAll, }); const session2 = await client2.resumeSession(session1.id, { - tools: [defineTool("analyze", { handler: doAnalyze })], + tools: [defineTool({ name: "analyze", handler: doAnalyze })], onPermissionRequest: approveAll, }); ``` @@ -411,7 +411,7 @@ Applications can now override built-in tools such as `grep`, `edit_file`, or `re import { defineTool } from "@github/copilot-sdk"; const session = await client.createSession({ - tools: [defineTool("grep", { + tools: [defineTool({ name: "grep", overridesBuiltInTool: true, handler: async (params) => `CUSTOM_GREP_RESULT: ${params.query}`, })], diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index 3d93f7589..300c9c429 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -777,7 +777,7 @@ This is useful when: import { CopilotClient, defineTool, approveAll } from "@github/copilot-sdk"; import { z } from "zod"; -const heavyContextTool = defineTool("analyze-codebase", { +const heavyContextTool = defineTool({ name: "analyze-codebase", description: "Performs deep analysis of the codebase, generating extensive context", parameters: z.object({ query: z.string() }), handler: async ({ query }) => { diff --git a/docs/getting-started.md b/docs/getting-started.md index 3a43fad7c..97f18a482 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -891,7 +891,7 @@ Update `index.ts`: import { CopilotClient, defineTool } from "@github/copilot-sdk"; // Define a tool that Copilot can call -const getWeather = defineTool("get_weather", { +const getWeather = defineTool({ name: "get_weather", description: "Get the current weather for a city", parameters: { type: "object", @@ -1296,7 +1296,7 @@ Let's put it all together into a useful interactive assistant: import { CopilotClient, defineTool } from "@github/copilot-sdk"; import * as readline from "readline"; -const getWeather = defineTool("get_weather", { +const getWeather = defineTool({ name: "get_weather", description: "Get the current weather for a city", parameters: { type: "object", diff --git a/nodejs/README.md b/nodejs/README.md index 807d51fa6..8c6616328 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -437,7 +437,7 @@ import { CopilotClient, defineTool } from "@github/copilot-sdk"; const session = await client.createSession({ model: "gpt-5", tools: [ - defineTool("lookup_issue", { + defineTool({ name: "lookup_issue", description: "Fetch issue details from our tracker", parameters: z.object({ id: z.string().describe("Issue identifier"), @@ -458,7 +458,7 @@ When Copilot invokes `lookup_issue`, the client automatically runs your handler If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `overridesBuiltInTool: true`. This flag signals that you intend to replace the built-in tool with your custom implementation. ```ts -defineTool("edit_file", { +defineTool({ name: "edit_file", description: "Custom file editor with project-specific validation", parameters: z.object({ path: z.string(), content: z.string() }), overridesBuiltInTool: true, @@ -473,7 +473,7 @@ defineTool("edit_file", { Set `skipPermission: true` on a tool definition to allow it to execute without triggering a permission prompt: ```ts -defineTool("safe_lookup", { +defineTool({ name: "safe_lookup", description: "A read-only lookup that needs no confirmation", parameters: z.object({ id: z.string() }), skipPermission: true, diff --git a/nodejs/examples/basic-example.ts b/nodejs/examples/basic-example.ts index c20a85af0..2756e2515 100644 --- a/nodejs/examples/basic-example.ts +++ b/nodejs/examples/basic-example.ts @@ -12,7 +12,7 @@ const facts: Record = { node: "Node.js lets you run JavaScript outside the browser using the V8 engine.", }; -const lookupFactTool = defineTool("lookup_fact", { +const lookupFactTool = defineTool({ name: "lookup_fact", description: "Returns a fun fact about a given topic.", parameters: z.object({ topic: z.string().describe("Topic to look up (e.g. 'javascript', 'node')"), diff --git a/nodejs/samples/manual-tool-resume.ts b/nodejs/samples/manual-tool-resume.ts index 32951dddc..279ea7276 100644 --- a/nodejs/samples/manual-tool-resume.ts +++ b/nodejs/samples/manual-tool-resume.ts @@ -29,7 +29,7 @@ async function pause() { await new Promise((resolve) => setTimeout(resolve, 1000)); } -const tool = defineTool("manual_resume_status", { +const tool = defineTool({ name: "manual_resume_status", description: "Looks up a status value. The SDK consumer supplies the result manually.", parameters: z.object({ id: z.string().describe("Identifier to look up"), diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 8dd9e6922..38342b072 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -410,18 +410,19 @@ export interface Tool { /** * Helper to define a tool with Zod schema and get type inference for the handler. * Without this helper, TypeScript cannot infer handler argument types from Zod schemas. - */ -export function defineTool( - name: string, - config: { - description?: string; - parameters?: ZodSchema | Record; - handler?: ToolHandler; - overridesBuiltInTool?: boolean; - skipPermission?: boolean; - } -): Tool { - return { name, ...config }; + * + * @example + * ```typescript + * const weatherTool = defineTool({ + * name: "get_weather", + * description: "Get weather for a location", + * parameters: z.object({ location: z.string() }), + * handler: ({ location }) => fetchWeather(location), + * }); + * ``` + */ +export function defineTool(tool: Tool): Tool { + return tool; } // ============================================================================ diff --git a/nodejs/test/e2e/abort.e2e.test.ts b/nodejs/test/e2e/abort.e2e.test.ts index 87d91fc5e..7bd97cc05 100644 --- a/nodejs/test/e2e/abort.e2e.test.ts +++ b/nodejs/test/e2e/abort.e2e.test.ts @@ -81,7 +81,7 @@ describe("Abort", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("slow_analysis", { + defineTool({ name: "slow_analysis", description: "A slow analysis tool that blocks until released", parameters: z.object({ value: z.string().describe("Value to analyze"), diff --git a/nodejs/test/e2e/hooks_extended.e2e.test.ts b/nodejs/test/e2e/hooks_extended.e2e.test.ts index 85b51a528..4b296b336 100644 --- a/nodejs/test/e2e/hooks_extended.e2e.test.ts +++ b/nodejs/test/e2e/hooks_extended.e2e.test.ts @@ -235,7 +235,7 @@ describe("Extended session hooks", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("echo_value", { + defineTool({ name: "echo_value", description: "Echoes the supplied value", parameters: z.object({ value: z.string() }), handler: ({ value }) => value, diff --git a/nodejs/test/e2e/mcp_and_agents.e2e.test.ts b/nodejs/test/e2e/mcp_and_agents.e2e.test.ts index 93a8df7a4..a475bba2d 100644 --- a/nodejs/test/e2e/mcp_and_agents.e2e.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.e2e.test.ts @@ -319,7 +319,7 @@ describe("MCP Servers and Custom Agents", async () => { describe("Default Agent Tool Exclusion", () => { it("should hide excluded tools from default agent", async () => { - const secretTool = defineTool("secret_tool", { + const secretTool = defineTool({ name: "secret_tool", description: "A secret tool hidden from the default agent", parameters: z.object({ input: z.string().describe("Input to process"), @@ -358,7 +358,7 @@ describe("MCP Servers and Custom Agents", async () => { const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 3+3?" }); - const secretTool = defineTool("secret_tool", { + const secretTool = defineTool({ name: "secret_tool", description: "A secret tool hidden from the default agent", parameters: z.object({ input: z.string().describe("Input to process"), diff --git a/nodejs/test/e2e/multi-client.e2e.test.ts b/nodejs/test/e2e/multi-client.e2e.test.ts index 4a6c5a0d4..4399d2d7c 100644 --- a/nodejs/test/e2e/multi-client.e2e.test.ts +++ b/nodejs/test/e2e/multi-client.e2e.test.ts @@ -63,7 +63,7 @@ describe("Multi-client broadcast", async () => { } it("both clients see tool request and completion events", async () => { - const tool = defineTool("magic_number", { + const tool = defineTool({ name: "magic_number", description: "Returns a magic number", parameters: z.object({ seed: z.string().describe("A seed value"), @@ -255,7 +255,7 @@ describe("Multi-client broadcast", async () => { "two clients register different tools and agent uses both", { timeout: 90_000 }, async () => { - const toolA = defineTool("city_lookup", { + const toolA = defineTool({ name: "city_lookup", description: "Returns a city name for a given country code", parameters: z.object({ countryCode: z.string().describe("A two-letter country code"), @@ -263,7 +263,7 @@ describe("Multi-client broadcast", async () => { handler: ({ countryCode }) => `CITY_FOR_${countryCode}`, }); - const toolB = defineTool("currency_lookup", { + const toolB = defineTool({ name: "currency_lookup", description: "Returns a currency for a given country code", parameters: z.object({ countryCode: z.string().describe("A two-letter country code"), @@ -299,13 +299,13 @@ describe("Multi-client broadcast", async () => { ); it("disconnecting client removes its tools", { timeout: 90_000 }, async () => { - const toolA = defineTool("stable_tool", { + const toolA = defineTool({ name: "stable_tool", description: "A tool that persists across disconnects", parameters: z.object({ input: z.string() }), handler: ({ input }) => `STABLE_${input}`, }); - const toolB = defineTool("ephemeral_tool", { + const toolB = defineTool({ name: "ephemeral_tool", description: "A tool that will disappear when its client disconnects", parameters: z.object({ input: z.string() }), handler: ({ input }) => `EPHEMERAL_${input}`, diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index 3769665c9..91412b803 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -178,7 +178,7 @@ describe("Pending work resume", async () => { const suspendedClient = createConnectingClient(cliUrl); const session1 = await suspendedClient.createSession({ tools: [ - defineTool("resume_permission_tool", { + defineTool({ name: "resume_permission_tool", description: "Transforms a value after permission is granted", parameters: z.object({ value: z.string() }), handler: ({ value }) => `ORIGINAL_SHOULD_NOT_RUN_${value}`, @@ -213,7 +213,7 @@ describe("Pending work resume", async () => { continuePendingWork: true, onPermissionRequest: () => ({ kind: "no-result" }), tools: [ - defineTool("resume_permission_tool", { + defineTool({ name: "resume_permission_tool", description: "Transforms a value after permission is granted", parameters: z.object({ value: z.string() }), handler: ({ value }) => { @@ -263,7 +263,7 @@ describe("Pending work resume", async () => { const suspendedClient = createConnectingClient(cliUrl); const session1 = await suspendedClient.createSession({ tools: [ - defineTool("resume_external_tool", { + defineTool({ name: "resume_external_tool", description: "Looks up a value after resumption", parameters: z.object({ value: z.string() }), handler: async ({ value }) => { @@ -341,7 +341,7 @@ describe("Pending work resume", async () => { const suspendedClient = createConnectingClient(cliUrl); const session1 = await suspendedClient.createSession({ tools: [ - defineTool("pending_lookup_a", { + defineTool({ name: "pending_lookup_a", description: "Looks up the first value after resumption", parameters: z.object({ value: z.string() }), handler: async ({ value }) => { @@ -349,7 +349,7 @@ describe("Pending work resume", async () => { return await releaseOriginalToolA.promise; }, }), - defineTool("pending_lookup_b", { + defineTool({ name: "pending_lookup_b", description: "Looks up the second value after resumption", parameters: z.object({ value: z.string() }), handler: async ({ value }) => { @@ -470,7 +470,7 @@ describe("Pending work resume", async () => { const suspendedClient = createConnectingClient(cliUrl); const session1 = await suspendedClient.createSession({ tools: [ - defineTool("resume_external_tool", { + defineTool({ name: "resume_external_tool", description: "Looks up a value after resumption", parameters: z.object({ value: z.string() }), handler: async ({ value }) => { diff --git a/nodejs/test/e2e/permissions.e2e.test.ts b/nodejs/test/e2e/permissions.e2e.test.ts index dcb8033b2..0592ceb91 100644 --- a/nodejs/test/e2e/permissions.e2e.test.ts +++ b/nodejs/test/e2e/permissions.e2e.test.ts @@ -333,7 +333,7 @@ describe("Permission callbacks", async () => { const session = await client.createSession({ tools: [ - defineTool("first_permission_tool", { + defineTool({ name: "first_permission_tool", description: "First concurrent permission test tool", parameters: z.object({}), handler: async (): Promise => { @@ -345,7 +345,7 @@ describe("Permission callbacks", async () => { }; }, }), - defineTool("second_permission_tool", { + defineTool({ name: "second_permission_tool", description: "Second concurrent permission test tool", parameters: z.object({}), handler: async (): Promise => { diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index 19118d3a4..d2ea30c2e 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -196,7 +196,7 @@ describe("Sessions", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("secret_tool", { + defineTool({ name: "secret_tool", description: "A secret tool hidden from the default agent", parameters: { type: "object", diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index f22ea14d9..b641751e9 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -133,7 +133,7 @@ describe("Session Fs", async () => { onPermissionRequest: approveAll, createSessionFsProvider, tools: [ - defineTool("get_big_string", { + defineTool({ name: "get_big_string", description: "Returns a large string", handler: async () => suppliedFileContent, }), diff --git a/nodejs/test/e2e/suspend.e2e.test.ts b/nodejs/test/e2e/suspend.e2e.test.ts index 3ca4c4e3f..ca7a1600e 100644 --- a/nodejs/test/e2e/suspend.e2e.test.ts +++ b/nodejs/test/e2e/suspend.e2e.test.ts @@ -144,7 +144,7 @@ describe("Suspend RPC", async () => { const session = await client.createSession({ tools: [ - defineTool("suspend_cancel_permission_tool", { + defineTool({ name: "suspend_cancel_permission_tool", description: "Transforms a value (should not run when suspend cancels permission)", parameters: z.object({ @@ -195,7 +195,7 @@ describe("Suspend RPC", async () => { const session = await client.createSession({ tools: [ - defineTool("suspend_reject_external_tool", { + defineTool({ name: "suspend_reject_external_tool", description: "Looks up a value externally", parameters: z.object({ value: z.string().describe("Value to look up"), diff --git a/nodejs/test/e2e/tool_results.e2e.test.ts b/nodejs/test/e2e/tool_results.e2e.test.ts index 6e8729c42..0daa5b502 100644 --- a/nodejs/test/e2e/tool_results.e2e.test.ts +++ b/nodejs/test/e2e/tool_results.e2e.test.ts @@ -30,7 +30,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("get_weather", { + defineTool({ name: "get_weather", description: "Gets weather for a city", parameters: z.object({ city: z.string(), @@ -57,7 +57,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("check_status", { + defineTool({ name: "check_status", description: "Checks the status of a service", handler: (): ToolResultObject => ({ textResultForLlm: "Service unavailable", @@ -82,7 +82,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("calculate", { + defineTool({ name: "calculate", description: "Calculates a math expression", parameters: z.object({ operation: z.enum(["add", "subtract", "multiply"]), @@ -118,7 +118,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("analyze_code", { + defineTool({ name: "analyze_code", description: "Analyzes code for issues", parameters: z.object({ file: z.string(), @@ -173,7 +173,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("deploy_service", { + defineTool({ name: "deploy_service", description: "Deploys a service", parameters: z.object({}), handler: (): ToolResultObject => { @@ -217,7 +217,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("access_secret", { + defineTool({ name: "access_secret", description: "A tool that returns a denied result", parameters: z.object({}), handler: (): ToolResultObject => ({ diff --git a/nodejs/test/e2e/tools.e2e.test.ts b/nodejs/test/e2e/tools.e2e.test.ts index 09a041468..a3a41c1d2 100644 --- a/nodejs/test/e2e/tools.e2e.test.ts +++ b/nodejs/test/e2e/tools.e2e.test.ts @@ -29,7 +29,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("encrypt_string", { + defineTool({ name: "encrypt_string", description: "Encrypts a string", parameters: z.object({ input: z.string().describe("String to encrypt"), @@ -49,7 +49,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("get_user_location", { + defineTool({ name: "get_user_location", description: "Gets the user's location", handler: () => { throw new Error("Melbourne"); @@ -90,7 +90,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("db_query", { + defineTool({ name: "db_query", description: "Performs a database query", parameters: z.object({ query: z.object({ @@ -134,7 +134,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ tools: [ - defineTool("encrypt_string", { + defineTool({ name: "encrypt_string", description: "Encrypts a string", parameters: z.object({ input: z.string().describe("String to encrypt"), @@ -167,7 +167,7 @@ describe("Custom tools", async () => { return { kind: "no-result" }; }, tools: [ - defineTool("safe_lookup", { + defineTool({ name: "safe_lookup", description: "A safe lookup that skips permission", parameters: z.object({ id: z.string().describe("ID to look up"), @@ -189,7 +189,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("grep", { + defineTool({ name: "grep", description: "A custom grep implementation that overrides the built-in", parameters: z.object({ query: z.string().describe("Search query"), @@ -211,7 +211,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ tools: [ - defineTool("encrypt_string", { + defineTool({ name: "encrypt_string", description: "Encrypts a string", parameters: z.object({ input: z.string().describe("String to encrypt"), @@ -242,7 +242,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("lookup_city", { + defineTool({ name: "lookup_city", description: "Looks up city information", parameters: z.object({ city: z.string() }), handler: ({ city }) => { @@ -250,7 +250,7 @@ describe("Custom tools", async () => { return `CITY_${city.toUpperCase()}`; }, }), - defineTool("lookup_country", { + defineTool({ name: "lookup_country", description: "Looks up country information", parameters: z.object({ country: z.string() }), handler: ({ country }) => { @@ -280,7 +280,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool("allowed_tool", { + defineTool({ name: "allowed_tool", description: "A tool that is allowed", parameters: z.object({ input: z.string() }), handler: ({ input }) => { @@ -288,7 +288,7 @@ describe("Custom tools", async () => { return `ALLOWED_${input.toUpperCase()}`; }, }), - defineTool("excluded_tool", { + defineTool({ name: "excluded_tool", description: "A tool that should be excluded", parameters: z.object({}), handler: () => { diff --git a/test/scenarios/tools/custom-agents/typescript/src/index.ts b/test/scenarios/tools/custom-agents/typescript/src/index.ts index ffb0bd827..a68632b72 100644 --- a/test/scenarios/tools/custom-agents/typescript/src/index.ts +++ b/test/scenarios/tools/custom-agents/typescript/src/index.ts @@ -1,7 +1,7 @@ import { CopilotClient, defineTool } from "@github/copilot-sdk"; import { z } from "zod"; -const analyzeCodebase = defineTool("analyze-codebase", { +const analyzeCodebase = defineTool({ name: "analyze-codebase", description: "Performs deep analysis of the codebase, generating extensive context", parameters: z.object({ query: z.string().describe("The analysis query") }), handler: async ({ query }) => { diff --git a/test/scenarios/tools/tool-overrides/typescript/src/index.ts b/test/scenarios/tools/tool-overrides/typescript/src/index.ts index 0472115d5..6f068d4b5 100644 --- a/test/scenarios/tools/tool-overrides/typescript/src/index.ts +++ b/test/scenarios/tools/tool-overrides/typescript/src/index.ts @@ -12,7 +12,7 @@ async function main() { model: "claude-haiku-4.5", onPermissionRequest: approveAll, tools: [ - defineTool("grep", { + defineTool({ name: "grep", description: "A custom grep implementation that overrides the built-in", parameters: z.object({ query: z.string().describe("Search query"), diff --git a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts index fa146da83..09de93040 100644 --- a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts +++ b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts @@ -4,7 +4,7 @@ import { z } from "zod"; // In-memory virtual filesystem const virtualFs = new Map(); -const createFile = defineTool("create_file", { +const createFile = defineTool({ name: "create_file", description: "Create or overwrite a file at the given path with the provided content", parameters: z.object({ path: z.string().describe("File path"), @@ -16,7 +16,7 @@ const createFile = defineTool("create_file", { }, }); -const readFile = defineTool("read_file", { +const readFile = defineTool({ name: "read_file", description: "Read the contents of a file at the given path", parameters: z.object({ path: z.string().describe("File path"), @@ -28,7 +28,7 @@ const readFile = defineTool("read_file", { }, }); -const listFiles = defineTool("list_files", { +const listFiles = defineTool({ name: "list_files", description: "List all files in the virtual filesystem", parameters: z.object({}), handler: async () => { From 355a0f1f0e631f476f964186d0e806e79f0df91e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:43:59 +0100 Subject: [PATCH 08/27] Revert "Phase H: defineTool({ name, ... }) single-arg form" This reverts commit 49da9117b4403ce9e57078861582d872916622d6. --- CHANGELOG.md | 6 ++--- docs/features/custom-agents.md | 2 +- docs/getting-started.md | 4 +-- nodejs/README.md | 6 ++--- nodejs/examples/basic-example.ts | 2 +- nodejs/samples/manual-tool-resume.ts | 2 +- nodejs/src/types.ts | 25 +++++++++---------- nodejs/test/e2e/abort.e2e.test.ts | 2 +- nodejs/test/e2e/hooks_extended.e2e.test.ts | 2 +- nodejs/test/e2e/mcp_and_agents.e2e.test.ts | 4 +-- nodejs/test/e2e/multi-client.e2e.test.ts | 10 ++++---- .../test/e2e/pending_work_resume.e2e.test.ts | 12 ++++----- nodejs/test/e2e/permissions.e2e.test.ts | 4 +-- nodejs/test/e2e/session.e2e.test.ts | 2 +- nodejs/test/e2e/session_fs.e2e.test.ts | 2 +- nodejs/test/e2e/suspend.e2e.test.ts | 4 +-- nodejs/test/e2e/tool_results.e2e.test.ts | 12 ++++----- nodejs/test/e2e/tools.e2e.test.ts | 22 ++++++++-------- .../custom-agents/typescript/src/index.ts | 2 +- .../tool-overrides/typescript/src/index.ts | 2 +- .../typescript/src/index.ts | 6 ++--- 21 files changed, 66 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5c45a29..369c599be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -358,11 +358,11 @@ The SDK now uses protocol version 3, where the runtime broadcasts `external_tool ```ts // Two clients each register different tools; the agent can use both const session1 = await client1.createSession({ - tools: [defineTool({ name: "search", handler: doSearch })], + tools: [defineTool("search", { handler: doSearch })], onPermissionRequest: approveAll, }); const session2 = await client2.resumeSession(session1.id, { - tools: [defineTool({ name: "analyze", handler: doAnalyze })], + tools: [defineTool("analyze", { handler: doAnalyze })], onPermissionRequest: approveAll, }); ``` @@ -411,7 +411,7 @@ Applications can now override built-in tools such as `grep`, `edit_file`, or `re import { defineTool } from "@github/copilot-sdk"; const session = await client.createSession({ - tools: [defineTool({ name: "grep", + tools: [defineTool("grep", { overridesBuiltInTool: true, handler: async (params) => `CUSTOM_GREP_RESULT: ${params.query}`, })], diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index 300c9c429..3d93f7589 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -777,7 +777,7 @@ This is useful when: import { CopilotClient, defineTool, approveAll } from "@github/copilot-sdk"; import { z } from "zod"; -const heavyContextTool = defineTool({ name: "analyze-codebase", +const heavyContextTool = defineTool("analyze-codebase", { description: "Performs deep analysis of the codebase, generating extensive context", parameters: z.object({ query: z.string() }), handler: async ({ query }) => { diff --git a/docs/getting-started.md b/docs/getting-started.md index 97f18a482..3a43fad7c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -891,7 +891,7 @@ Update `index.ts`: import { CopilotClient, defineTool } from "@github/copilot-sdk"; // Define a tool that Copilot can call -const getWeather = defineTool({ name: "get_weather", +const getWeather = defineTool("get_weather", { description: "Get the current weather for a city", parameters: { type: "object", @@ -1296,7 +1296,7 @@ Let's put it all together into a useful interactive assistant: import { CopilotClient, defineTool } from "@github/copilot-sdk"; import * as readline from "readline"; -const getWeather = defineTool({ name: "get_weather", +const getWeather = defineTool("get_weather", { description: "Get the current weather for a city", parameters: { type: "object", diff --git a/nodejs/README.md b/nodejs/README.md index 8c6616328..807d51fa6 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -437,7 +437,7 @@ import { CopilotClient, defineTool } from "@github/copilot-sdk"; const session = await client.createSession({ model: "gpt-5", tools: [ - defineTool({ name: "lookup_issue", + defineTool("lookup_issue", { description: "Fetch issue details from our tracker", parameters: z.object({ id: z.string().describe("Issue identifier"), @@ -458,7 +458,7 @@ When Copilot invokes `lookup_issue`, the client automatically runs your handler If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `overridesBuiltInTool: true`. This flag signals that you intend to replace the built-in tool with your custom implementation. ```ts -defineTool({ name: "edit_file", +defineTool("edit_file", { description: "Custom file editor with project-specific validation", parameters: z.object({ path: z.string(), content: z.string() }), overridesBuiltInTool: true, @@ -473,7 +473,7 @@ defineTool({ name: "edit_file", Set `skipPermission: true` on a tool definition to allow it to execute without triggering a permission prompt: ```ts -defineTool({ name: "safe_lookup", +defineTool("safe_lookup", { description: "A read-only lookup that needs no confirmation", parameters: z.object({ id: z.string() }), skipPermission: true, diff --git a/nodejs/examples/basic-example.ts b/nodejs/examples/basic-example.ts index 2756e2515..c20a85af0 100644 --- a/nodejs/examples/basic-example.ts +++ b/nodejs/examples/basic-example.ts @@ -12,7 +12,7 @@ const facts: Record = { node: "Node.js lets you run JavaScript outside the browser using the V8 engine.", }; -const lookupFactTool = defineTool({ name: "lookup_fact", +const lookupFactTool = defineTool("lookup_fact", { description: "Returns a fun fact about a given topic.", parameters: z.object({ topic: z.string().describe("Topic to look up (e.g. 'javascript', 'node')"), diff --git a/nodejs/samples/manual-tool-resume.ts b/nodejs/samples/manual-tool-resume.ts index 279ea7276..32951dddc 100644 --- a/nodejs/samples/manual-tool-resume.ts +++ b/nodejs/samples/manual-tool-resume.ts @@ -29,7 +29,7 @@ async function pause() { await new Promise((resolve) => setTimeout(resolve, 1000)); } -const tool = defineTool({ name: "manual_resume_status", +const tool = defineTool("manual_resume_status", { description: "Looks up a status value. The SDK consumer supplies the result manually.", parameters: z.object({ id: z.string().describe("Identifier to look up"), diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 38342b072..8dd9e6922 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -410,19 +410,18 @@ export interface Tool { /** * Helper to define a tool with Zod schema and get type inference for the handler. * Without this helper, TypeScript cannot infer handler argument types from Zod schemas. - * - * @example - * ```typescript - * const weatherTool = defineTool({ - * name: "get_weather", - * description: "Get weather for a location", - * parameters: z.object({ location: z.string() }), - * handler: ({ location }) => fetchWeather(location), - * }); - * ``` - */ -export function defineTool(tool: Tool): Tool { - return tool; + */ +export function defineTool( + name: string, + config: { + description?: string; + parameters?: ZodSchema | Record; + handler?: ToolHandler; + overridesBuiltInTool?: boolean; + skipPermission?: boolean; + } +): Tool { + return { name, ...config }; } // ============================================================================ diff --git a/nodejs/test/e2e/abort.e2e.test.ts b/nodejs/test/e2e/abort.e2e.test.ts index 7bd97cc05..87d91fc5e 100644 --- a/nodejs/test/e2e/abort.e2e.test.ts +++ b/nodejs/test/e2e/abort.e2e.test.ts @@ -81,7 +81,7 @@ describe("Abort", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "slow_analysis", + defineTool("slow_analysis", { description: "A slow analysis tool that blocks until released", parameters: z.object({ value: z.string().describe("Value to analyze"), diff --git a/nodejs/test/e2e/hooks_extended.e2e.test.ts b/nodejs/test/e2e/hooks_extended.e2e.test.ts index 4b296b336..85b51a528 100644 --- a/nodejs/test/e2e/hooks_extended.e2e.test.ts +++ b/nodejs/test/e2e/hooks_extended.e2e.test.ts @@ -235,7 +235,7 @@ describe("Extended session hooks", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "echo_value", + defineTool("echo_value", { description: "Echoes the supplied value", parameters: z.object({ value: z.string() }), handler: ({ value }) => value, diff --git a/nodejs/test/e2e/mcp_and_agents.e2e.test.ts b/nodejs/test/e2e/mcp_and_agents.e2e.test.ts index a475bba2d..93a8df7a4 100644 --- a/nodejs/test/e2e/mcp_and_agents.e2e.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.e2e.test.ts @@ -319,7 +319,7 @@ describe("MCP Servers and Custom Agents", async () => { describe("Default Agent Tool Exclusion", () => { it("should hide excluded tools from default agent", async () => { - const secretTool = defineTool({ name: "secret_tool", + const secretTool = defineTool("secret_tool", { description: "A secret tool hidden from the default agent", parameters: z.object({ input: z.string().describe("Input to process"), @@ -358,7 +358,7 @@ describe("MCP Servers and Custom Agents", async () => { const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 3+3?" }); - const secretTool = defineTool({ name: "secret_tool", + const secretTool = defineTool("secret_tool", { description: "A secret tool hidden from the default agent", parameters: z.object({ input: z.string().describe("Input to process"), diff --git a/nodejs/test/e2e/multi-client.e2e.test.ts b/nodejs/test/e2e/multi-client.e2e.test.ts index 4399d2d7c..4a6c5a0d4 100644 --- a/nodejs/test/e2e/multi-client.e2e.test.ts +++ b/nodejs/test/e2e/multi-client.e2e.test.ts @@ -63,7 +63,7 @@ describe("Multi-client broadcast", async () => { } it("both clients see tool request and completion events", async () => { - const tool = defineTool({ name: "magic_number", + const tool = defineTool("magic_number", { description: "Returns a magic number", parameters: z.object({ seed: z.string().describe("A seed value"), @@ -255,7 +255,7 @@ describe("Multi-client broadcast", async () => { "two clients register different tools and agent uses both", { timeout: 90_000 }, async () => { - const toolA = defineTool({ name: "city_lookup", + const toolA = defineTool("city_lookup", { description: "Returns a city name for a given country code", parameters: z.object({ countryCode: z.string().describe("A two-letter country code"), @@ -263,7 +263,7 @@ describe("Multi-client broadcast", async () => { handler: ({ countryCode }) => `CITY_FOR_${countryCode}`, }); - const toolB = defineTool({ name: "currency_lookup", + const toolB = defineTool("currency_lookup", { description: "Returns a currency for a given country code", parameters: z.object({ countryCode: z.string().describe("A two-letter country code"), @@ -299,13 +299,13 @@ describe("Multi-client broadcast", async () => { ); it("disconnecting client removes its tools", { timeout: 90_000 }, async () => { - const toolA = defineTool({ name: "stable_tool", + const toolA = defineTool("stable_tool", { description: "A tool that persists across disconnects", parameters: z.object({ input: z.string() }), handler: ({ input }) => `STABLE_${input}`, }); - const toolB = defineTool({ name: "ephemeral_tool", + const toolB = defineTool("ephemeral_tool", { description: "A tool that will disappear when its client disconnects", parameters: z.object({ input: z.string() }), handler: ({ input }) => `EPHEMERAL_${input}`, diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index 91412b803..3769665c9 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -178,7 +178,7 @@ describe("Pending work resume", async () => { const suspendedClient = createConnectingClient(cliUrl); const session1 = await suspendedClient.createSession({ tools: [ - defineTool({ name: "resume_permission_tool", + defineTool("resume_permission_tool", { description: "Transforms a value after permission is granted", parameters: z.object({ value: z.string() }), handler: ({ value }) => `ORIGINAL_SHOULD_NOT_RUN_${value}`, @@ -213,7 +213,7 @@ describe("Pending work resume", async () => { continuePendingWork: true, onPermissionRequest: () => ({ kind: "no-result" }), tools: [ - defineTool({ name: "resume_permission_tool", + defineTool("resume_permission_tool", { description: "Transforms a value after permission is granted", parameters: z.object({ value: z.string() }), handler: ({ value }) => { @@ -263,7 +263,7 @@ describe("Pending work resume", async () => { const suspendedClient = createConnectingClient(cliUrl); const session1 = await suspendedClient.createSession({ tools: [ - defineTool({ name: "resume_external_tool", + defineTool("resume_external_tool", { description: "Looks up a value after resumption", parameters: z.object({ value: z.string() }), handler: async ({ value }) => { @@ -341,7 +341,7 @@ describe("Pending work resume", async () => { const suspendedClient = createConnectingClient(cliUrl); const session1 = await suspendedClient.createSession({ tools: [ - defineTool({ name: "pending_lookup_a", + defineTool("pending_lookup_a", { description: "Looks up the first value after resumption", parameters: z.object({ value: z.string() }), handler: async ({ value }) => { @@ -349,7 +349,7 @@ describe("Pending work resume", async () => { return await releaseOriginalToolA.promise; }, }), - defineTool({ name: "pending_lookup_b", + defineTool("pending_lookup_b", { description: "Looks up the second value after resumption", parameters: z.object({ value: z.string() }), handler: async ({ value }) => { @@ -470,7 +470,7 @@ describe("Pending work resume", async () => { const suspendedClient = createConnectingClient(cliUrl); const session1 = await suspendedClient.createSession({ tools: [ - defineTool({ name: "resume_external_tool", + defineTool("resume_external_tool", { description: "Looks up a value after resumption", parameters: z.object({ value: z.string() }), handler: async ({ value }) => { diff --git a/nodejs/test/e2e/permissions.e2e.test.ts b/nodejs/test/e2e/permissions.e2e.test.ts index 0592ceb91..dcb8033b2 100644 --- a/nodejs/test/e2e/permissions.e2e.test.ts +++ b/nodejs/test/e2e/permissions.e2e.test.ts @@ -333,7 +333,7 @@ describe("Permission callbacks", async () => { const session = await client.createSession({ tools: [ - defineTool({ name: "first_permission_tool", + defineTool("first_permission_tool", { description: "First concurrent permission test tool", parameters: z.object({}), handler: async (): Promise => { @@ -345,7 +345,7 @@ describe("Permission callbacks", async () => { }; }, }), - defineTool({ name: "second_permission_tool", + defineTool("second_permission_tool", { description: "Second concurrent permission test tool", parameters: z.object({}), handler: async (): Promise => { diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index d2ea30c2e..19118d3a4 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -196,7 +196,7 @@ describe("Sessions", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "secret_tool", + defineTool("secret_tool", { description: "A secret tool hidden from the default agent", parameters: { type: "object", diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index b641751e9..f22ea14d9 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -133,7 +133,7 @@ describe("Session Fs", async () => { onPermissionRequest: approveAll, createSessionFsProvider, tools: [ - defineTool({ name: "get_big_string", + defineTool("get_big_string", { description: "Returns a large string", handler: async () => suppliedFileContent, }), diff --git a/nodejs/test/e2e/suspend.e2e.test.ts b/nodejs/test/e2e/suspend.e2e.test.ts index ca7a1600e..3ca4c4e3f 100644 --- a/nodejs/test/e2e/suspend.e2e.test.ts +++ b/nodejs/test/e2e/suspend.e2e.test.ts @@ -144,7 +144,7 @@ describe("Suspend RPC", async () => { const session = await client.createSession({ tools: [ - defineTool({ name: "suspend_cancel_permission_tool", + defineTool("suspend_cancel_permission_tool", { description: "Transforms a value (should not run when suspend cancels permission)", parameters: z.object({ @@ -195,7 +195,7 @@ describe("Suspend RPC", async () => { const session = await client.createSession({ tools: [ - defineTool({ name: "suspend_reject_external_tool", + defineTool("suspend_reject_external_tool", { description: "Looks up a value externally", parameters: z.object({ value: z.string().describe("Value to look up"), diff --git a/nodejs/test/e2e/tool_results.e2e.test.ts b/nodejs/test/e2e/tool_results.e2e.test.ts index 0daa5b502..6e8729c42 100644 --- a/nodejs/test/e2e/tool_results.e2e.test.ts +++ b/nodejs/test/e2e/tool_results.e2e.test.ts @@ -30,7 +30,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "get_weather", + defineTool("get_weather", { description: "Gets weather for a city", parameters: z.object({ city: z.string(), @@ -57,7 +57,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "check_status", + defineTool("check_status", { description: "Checks the status of a service", handler: (): ToolResultObject => ({ textResultForLlm: "Service unavailable", @@ -82,7 +82,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "calculate", + defineTool("calculate", { description: "Calculates a math expression", parameters: z.object({ operation: z.enum(["add", "subtract", "multiply"]), @@ -118,7 +118,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "analyze_code", + defineTool("analyze_code", { description: "Analyzes code for issues", parameters: z.object({ file: z.string(), @@ -173,7 +173,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "deploy_service", + defineTool("deploy_service", { description: "Deploys a service", parameters: z.object({}), handler: (): ToolResultObject => { @@ -217,7 +217,7 @@ describe("Tool Results", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "access_secret", + defineTool("access_secret", { description: "A tool that returns a denied result", parameters: z.object({}), handler: (): ToolResultObject => ({ diff --git a/nodejs/test/e2e/tools.e2e.test.ts b/nodejs/test/e2e/tools.e2e.test.ts index a3a41c1d2..09a041468 100644 --- a/nodejs/test/e2e/tools.e2e.test.ts +++ b/nodejs/test/e2e/tools.e2e.test.ts @@ -29,7 +29,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "encrypt_string", + defineTool("encrypt_string", { description: "Encrypts a string", parameters: z.object({ input: z.string().describe("String to encrypt"), @@ -49,7 +49,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "get_user_location", + defineTool("get_user_location", { description: "Gets the user's location", handler: () => { throw new Error("Melbourne"); @@ -90,7 +90,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "db_query", + defineTool("db_query", { description: "Performs a database query", parameters: z.object({ query: z.object({ @@ -134,7 +134,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ tools: [ - defineTool({ name: "encrypt_string", + defineTool("encrypt_string", { description: "Encrypts a string", parameters: z.object({ input: z.string().describe("String to encrypt"), @@ -167,7 +167,7 @@ describe("Custom tools", async () => { return { kind: "no-result" }; }, tools: [ - defineTool({ name: "safe_lookup", + defineTool("safe_lookup", { description: "A safe lookup that skips permission", parameters: z.object({ id: z.string().describe("ID to look up"), @@ -189,7 +189,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "grep", + defineTool("grep", { description: "A custom grep implementation that overrides the built-in", parameters: z.object({ query: z.string().describe("Search query"), @@ -211,7 +211,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ tools: [ - defineTool({ name: "encrypt_string", + defineTool("encrypt_string", { description: "Encrypts a string", parameters: z.object({ input: z.string().describe("String to encrypt"), @@ -242,7 +242,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "lookup_city", + defineTool("lookup_city", { description: "Looks up city information", parameters: z.object({ city: z.string() }), handler: ({ city }) => { @@ -250,7 +250,7 @@ describe("Custom tools", async () => { return `CITY_${city.toUpperCase()}`; }, }), - defineTool({ name: "lookup_country", + defineTool("lookup_country", { description: "Looks up country information", parameters: z.object({ country: z.string() }), handler: ({ country }) => { @@ -280,7 +280,7 @@ describe("Custom tools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, tools: [ - defineTool({ name: "allowed_tool", + defineTool("allowed_tool", { description: "A tool that is allowed", parameters: z.object({ input: z.string() }), handler: ({ input }) => { @@ -288,7 +288,7 @@ describe("Custom tools", async () => { return `ALLOWED_${input.toUpperCase()}`; }, }), - defineTool({ name: "excluded_tool", + defineTool("excluded_tool", { description: "A tool that should be excluded", parameters: z.object({}), handler: () => { diff --git a/test/scenarios/tools/custom-agents/typescript/src/index.ts b/test/scenarios/tools/custom-agents/typescript/src/index.ts index a68632b72..ffb0bd827 100644 --- a/test/scenarios/tools/custom-agents/typescript/src/index.ts +++ b/test/scenarios/tools/custom-agents/typescript/src/index.ts @@ -1,7 +1,7 @@ import { CopilotClient, defineTool } from "@github/copilot-sdk"; import { z } from "zod"; -const analyzeCodebase = defineTool({ name: "analyze-codebase", +const analyzeCodebase = defineTool("analyze-codebase", { description: "Performs deep analysis of the codebase, generating extensive context", parameters: z.object({ query: z.string().describe("The analysis query") }), handler: async ({ query }) => { diff --git a/test/scenarios/tools/tool-overrides/typescript/src/index.ts b/test/scenarios/tools/tool-overrides/typescript/src/index.ts index 6f068d4b5..0472115d5 100644 --- a/test/scenarios/tools/tool-overrides/typescript/src/index.ts +++ b/test/scenarios/tools/tool-overrides/typescript/src/index.ts @@ -12,7 +12,7 @@ async function main() { model: "claude-haiku-4.5", onPermissionRequest: approveAll, tools: [ - defineTool({ name: "grep", + defineTool("grep", { description: "A custom grep implementation that overrides the built-in", parameters: z.object({ query: z.string().describe("Search query"), diff --git a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts index 09de93040..fa146da83 100644 --- a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts +++ b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts @@ -4,7 +4,7 @@ import { z } from "zod"; // In-memory virtual filesystem const virtualFs = new Map(); -const createFile = defineTool({ name: "create_file", +const createFile = defineTool("create_file", { description: "Create or overwrite a file at the given path with the provided content", parameters: z.object({ path: z.string().describe("File path"), @@ -16,7 +16,7 @@ const createFile = defineTool({ name: "create_file", }, }); -const readFile = defineTool({ name: "read_file", +const readFile = defineTool("read_file", { description: "Read the contents of a file at the given path", parameters: z.object({ path: z.string().describe("File path"), @@ -28,7 +28,7 @@ const readFile = defineTool({ name: "read_file", }, }); -const listFiles = defineTool({ name: "list_files", +const listFiles = defineTool("list_files", { description: "List all files in the virtual filesystem", parameters: z.object({}), handler: async () => { From b3c36e8053a05ff03db8df37ebf7febcdeb7aa04 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:55:03 +0100 Subject: [PATCH 09/27] Phase I: RuntimeConnection discriminated config Replaces the flat connection-related fields on CopilotClientOptions (cliPath, cliArgs, port, useStdio, cliUrl, tcpConnectionToken, isChildProcess) with a single discriminated 'connection?: RuntimeConnection' field. Construct values via factory functions: RuntimeConnection.forStdio({ path?, args? }) // default RuntimeConnection.forTcp({ port?, connectionToken?, path?, args? }) RuntimeConnection.forUri(url, { connectionToken? }) The mutually-exclusive combinations that used to be runtime errors are now caught at compile time by the discriminated union. The previous isChildProcess flag (only ever used by joinSession() in extension.ts) is dropped from the public API surface; extension.ts now uses an @internal _internalConnection hook to enter the parent-process stdio mode. Other renames in this phase: - CopilotClientOptions.copilotHome -> baseDirectory. - Internal CopilotClient.actualPort field -> runtimePort. All TS test files, scenario fixtures, samples, README, and docs updated to the new shape. Mirrors C# PR #1343 Phase 9. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 23 +- nodejs/src/client.ts | 208 +++++++++--------- nodejs/src/extension.ts | 2 +- nodejs/src/index.ts | 4 + nodejs/src/types.ts | 174 ++++++++++----- nodejs/test/client.test.ts | 86 +++----- nodejs/test/e2e/client.e2e.test.ts | 17 +- nodejs/test/e2e/client_options.e2e.test.ts | 41 ++-- nodejs/test/e2e/commands.e2e.test.ts | 7 +- nodejs/test/e2e/connection_token.test.ts | 16 +- nodejs/test/e2e/harness/sdkTestContext.ts | 10 +- nodejs/test/e2e/multi-client.e2e.test.ts | 9 +- .../test/e2e/pending_work_resume.e2e.test.ts | 12 +- nodejs/test/e2e/per_session_auth.e2e.test.ts | 4 +- nodejs/test/e2e/rpc.e2e.test.ts | 6 +- .../test/e2e/rpc_mcp_and_skills.e2e.test.ts | 4 +- nodejs/test/e2e/rpc_mcp_config.e2e.test.ts | 2 +- nodejs/test/e2e/rpc_server.e2e.test.ts | 4 +- nodejs/test/e2e/session_fs.e2e.test.ts | 8 +- nodejs/test/e2e/suspend.e2e.test.ts | 12 +- nodejs/test/e2e/ui_elicitation.e2e.test.ts | 12 +- .../byok-anthropic/typescript/src/index.ts | 2 +- .../auth/byok-azure/typescript/src/index.ts | 2 +- .../auth/byok-ollama/typescript/src/index.ts | 2 +- .../auth/byok-openai/typescript/src/index.ts | 2 +- .../auth/gh-app/typescript/src/index.ts | 2 +- .../fully-bundled/typescript/src/index.ts | 2 +- .../callbacks/hooks/typescript/src/index.ts | 2 +- .../permissions/typescript/src/index.ts | 4 +- .../user-input/typescript/src/index.ts | 2 +- .../modes/default/typescript/src/index.ts | 2 +- .../modes/minimal/typescript/src/index.ts | 2 +- .../attachments/typescript/src/index.ts | 2 +- .../reasoning-effort/typescript/src/index.ts | 2 +- .../system-message/typescript/src/index.ts | 2 +- .../typescript/src/index.ts | 2 +- .../infinite-sessions/typescript/src/index.ts | 2 +- .../session-resume/typescript/src/index.ts | 2 +- .../streaming/typescript/src/index.ts | 2 +- .../custom-agents/typescript/src/index.ts | 2 +- .../tools/mcp-servers/typescript/src/index.ts | 2 +- .../tools/no-tools/typescript/src/index.ts | 2 +- .../tools/skills/typescript/src/index.ts | 2 +- .../tool-filtering/typescript/src/index.ts | 2 +- .../tool-overrides/typescript/src/index.ts | 2 +- .../typescript/src/index.ts | 4 +- .../reconnect/typescript/src/index.ts | 4 +- .../transport/stdio/typescript/src/index.ts | 2 +- .../transport/tcp/typescript/src/index.ts | 4 +- 49 files changed, 381 insertions(+), 344 deletions(-) diff --git a/nodejs/README.md b/nodejs/README.md index 807d51fa6..808530e04 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -79,18 +79,17 @@ new CopilotClient(options?: CopilotClientOptions) **Options:** -- `cliPath?: string` - Path to CLI executable (default: uses COPILOT_CLI_PATH env var or bundled instance) -- `cliArgs?: string[]` - Extra arguments prepended before SDK-managed flags (e.g. `["./dist-cli/index.js"]` when using `node`) -- `cliUrl?: string` - URL of existing CLI server to connect to (e.g., `"localhost:8080"`, `"http://127.0.0.1:9000"`, or just `"8080"`). When provided, the client will not spawn a CLI process. -- `port?: number` - Server port (default: 0 for random) -- `useStdio?: boolean` - Use stdio transport instead of TCP (default: true) -- `logLevel?: string` - Log level (default: "info") -- `autoStart?: boolean` - Auto-start server (default: true) +- `connection?: RuntimeConnection` - How to connect to the Copilot runtime. Construct via the factory functions on `RuntimeConnection`: + - `RuntimeConnection.forStdio({ path?, args? })` (default) — spawn the runtime and communicate over its stdin/stdout. + - `RuntimeConnection.forTcp({ port?, connectionToken?, path?, args? })` — spawn the runtime as a TCP server. + - `RuntimeConnection.forUri(url, { connectionToken? })` — connect to an already-running runtime (mutually exclusive with `gitHubToken`/`useLoggedInUser`). +- `cwd?: string` - Working directory for the runtime process (default: current process cwd). +- `baseDirectory?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When not set, the runtime defaults to `~/.copilot`. Ignored when connecting via `RuntimeConnection.forUri`. +- `logLevel?: string` - Log level (default: "info"). - `gitHubToken?: string` - GitHub token for authentication. When provided, takes priority over other auth methods. -- `useLoggedInUser?: boolean` - Whether to use logged-in user for authentication (default: true, but false when `gitHubToken` is provided). Cannot be used with `cliUrl`. -- `copilotHome?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned CLI process. When not set, the CLI defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when using `cliUrl`. -- `telemetry?: TelemetryConfig` - OpenTelemetry configuration for the CLI process. Providing this object enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below. -- `onGetTraceContext?: TraceContextProvider` - Advanced: callback for linking your application's own OpenTelemetry spans into the same distributed trace as the CLI's spans. Not needed for normal telemetry collection. See [Telemetry](#telemetry) below. +- `useLoggedInUser?: boolean` - Whether to use logged-in user for authentication (default: true, but false when `gitHubToken` is provided). Cannot be used with `RuntimeConnection.forUri`. +- `telemetry?: TelemetryConfig` - OpenTelemetry configuration for the runtime process. Providing this object enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below. +- `onGetTraceContext?: TraceContextProvider` - Advanced: callback for linking your application's own OpenTelemetry spans into the same distributed trace as the runtime's spans. Not needed for normal telemetry collection. See [Telemetry](#telemetry) below. #### Methods @@ -1026,7 +1025,7 @@ try { ## Requirements - Node.js >= 18.0.0 -- GitHub Copilot CLI installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI installed and in PATH (or provide a custom `connection`) ## License diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6411dead5..a4686149f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -45,6 +45,7 @@ import type { ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, + InternalRuntimeConnection, ModelInfo, ResumeSessionConfig, SectionTransformFn, @@ -209,7 +210,7 @@ function getBundledCliPath(): string { * const client = new CopilotClient(); * * // Or connect to an existing server - * const client = new CopilotClient({ cliUrl: "localhost:3000" }); + * const client = new CopilotClient({ connection: RuntimeConnection.forUri("localhost:3000") }); * * // Create a session * const session = await client.createSession({ onPermissionRequest: approveAll, model: "gpt-4" }); @@ -232,32 +233,26 @@ export class CopilotClient { private cliProcess: ChildProcess | null = null; private connection: MessageConnection | null = null; private socket: Socket | null = null; - private actualPort: number | null = null; + private runtimePort: number | null = null; private actualHost: string = "localhost"; private state: ConnectionState = "disconnected"; private sessions: Map = new Map(); private stderrBuffer: string = ""; // Captures CLI stderr for error messages - private options: Required< - Omit< - CopilotClientOptions, - | "cliPath" - | "cliUrl" - | "gitHubToken" - | "useLoggedInUser" - | "onListModels" - | "telemetry" - | "onGetTraceContext" - | "sessionFs" - | "tcpConnectionToken" - | "copilotHome" - > - > & { - cliPath?: string; - cliUrl?: string; + /** Resolved connection mode chosen in the constructor. */ + private connectionConfig: InternalRuntimeConnection; + /** Resolved path to the runtime executable (only used for child-process kinds). */ + private resolvedCliPath: string | undefined; + /** Resolved environment passed to the spawned runtime. */ + private resolvedEnv: Record; + private options: { + cwd: string; + logLevel: string; gitHubToken?: string; - useLoggedInUser?: boolean; + useLoggedInUser: boolean; telemetry?: TelemetryConfig; - copilotHome?: string; + baseDirectory?: string; + sessionIdleTimeoutSeconds: number; + remote: boolean; }; private isExternalServer: boolean = false; private forceStopping: boolean = false; @@ -311,73 +306,72 @@ export class CopilotClient { * Creates a new CopilotClient instance. * * @param options - Configuration options for the client - * @throws Error if mutually exclusive options are provided (e.g., cliUrl with useStdio or cliPath) * * @example * ```typescript - * // Default options - spawns CLI server using stdio + * // Default: spawns the bundled runtime over stdio * const client = new CopilotClient(); * - * // Connect to an existing server - * const client = new CopilotClient({ cliUrl: "localhost:3000" }); + * // Connect to an existing runtime + * const client = new CopilotClient({ + * connection: RuntimeConnection.forUri("localhost:3000"), + * }); + * + * // Spawn the runtime over TCP on a chosen port + * const client = new CopilotClient({ + * connection: RuntimeConnection.forTcp({ port: 9001 }), + * }); * - * // Custom CLI path with specific log level + * // Use a custom runtime binary * const client = new CopilotClient({ - * cliPath: "/usr/local/bin/copilot", - * logLevel: "debug" + * connection: RuntimeConnection.forStdio({ path: "/usr/local/bin/copilot" }), + * logLevel: "debug", * }); * ``` */ constructor(options: CopilotClientOptions = {}) { - // Validate mutually exclusive options - if (options.cliUrl && (options.useStdio === true || options.cliPath)) { - throw new Error("cliUrl is mutually exclusive with useStdio and cliPath"); - } + // Resolve the connection mode. `_internalConnection` is set by + // `joinSession()` to opt into the parent-process stdio path; consumers + // should always go through the public `connection` field. + const conn: InternalRuntimeConnection = + options._internalConnection ?? options.connection ?? { kind: "stdio" }; - if (options.isChildProcess && (options.cliUrl || options.useStdio === false)) { - throw new Error( - "isChildProcess must be used in conjunction with useStdio and not with cliUrl" - ); - } - - // Validate auth options with external server - if (options.cliUrl && (options.gitHubToken || options.useLoggedInUser !== undefined)) { + if ( + conn.kind === "uri" && + (options.gitHubToken !== undefined || options.useLoggedInUser !== undefined) + ) { throw new Error( - "gitHubToken and useLoggedInUser cannot be used with cliUrl (external server manages its own auth)" + "gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri (external server manages its own auth)" ); } - - if (options.tcpConnectionToken !== undefined) { - if ( - typeof options.tcpConnectionToken !== "string" || - options.tcpConnectionToken.length === 0 - ) { - throw new Error("tcpConnectionToken must be a non-empty string"); - } - if (options.useStdio === true) { - throw new Error("tcpConnectionToken cannot be used with useStdio: true"); + if (conn.kind === "tcp" && conn.connectionToken !== undefined) { + if (typeof conn.connectionToken !== "string" || conn.connectionToken.length === 0) { + throw new Error("connectionToken must be a non-empty string"); } } - const willUseStdio = options.cliUrl ? false : (options.useStdio ?? true); - const sdkSpawnsCli = !willUseStdio && !options.cliUrl && !options.isChildProcess; - this.effectiveConnectionToken = - options.tcpConnectionToken ?? (sdkSpawnsCli ? randomUUID() : undefined); + this.connectionConfig = conn; if (options.sessionFs) { this.validateSessionFsConfig(options.sessionFs); } - // Parse cliUrl if provided - if (options.cliUrl) { - const { host, port } = this.parseCliUrl(options.cliUrl); + // Pre-parse the URI host/port and mark as external if applicable. + if (conn.kind === "uri") { + const { host, port } = this.parseCliUrl(conn.url); this.actualHost = host; - this.actualPort = port; + this.runtimePort = port; + this.isExternalServer = true; + } else if (conn.kind === "parent-process") { this.isExternalServer = true; } - if (options.isChildProcess) { - this.isExternalServer = true; + // Effective TCP connection token: explicit, else auto-generated when we + // spawn our own runtime over TCP, else undefined. + if (conn.kind === "tcp") { + this.effectiveConnectionToken = conn.connectionToken ?? randomUUID(); + } else if (conn.kind === "uri") { + this.effectiveConnectionToken = conn.connectionToken; } this.onListModels = options.onListModels; @@ -385,29 +379,32 @@ export class CopilotClient { this.sessionFsConfig = options.sessionFs ?? null; const effectiveEnv = options.env ?? process.env; + this.resolvedEnv = effectiveEnv; + this.resolvedCliPath = + conn.kind === "stdio" || conn.kind === "tcp" + ? (conn.path ?? effectiveEnv.COPILOT_CLI_PATH ?? getBundledCliPath()) + : undefined; + + // Collect extra CLI args from the connection variant (if any). + const connArgs: readonly string[] = + conn.kind === "stdio" || conn.kind === "tcp" ? (conn.args ?? []) : []; + this.connectionExtraArgs = [...connArgs]; + this.options = { - cliPath: options.cliUrl - ? undefined - : options.cliPath || effectiveEnv.COPILOT_CLI_PATH || getBundledCliPath(), - cliArgs: options.cliArgs ?? [], cwd: options.cwd ?? process.cwd(), - port: options.port || 0, - useStdio: options.cliUrl ? false : (options.useStdio ?? true), // Default to stdio unless cliUrl is provided - isChildProcess: options.isChildProcess ?? false, - cliUrl: options.cliUrl, logLevel: options.logLevel || "debug", - - env: effectiveEnv, gitHubToken: options.gitHubToken, - // Default useLoggedInUser to false when gitHubToken is provided, otherwise true + // Default useLoggedInUser to false when gitHubToken is provided, otherwise true. useLoggedInUser: options.useLoggedInUser ?? (options.gitHubToken ? false : true), telemetry: options.telemetry, - copilotHome: options.copilotHome, + baseDirectory: options.baseDirectory, sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0, remote: options.remote ?? false, }; } + private connectionExtraArgs: string[] = []; + /** * Parse CLI URL into host and port * Supports formats: "host:port", "http://host:port", "https://host:port", or just "port" @@ -636,7 +633,7 @@ export class CopilotClient { } this.state = "disconnected"; - this.actualPort = null; + this.runtimePort = null; this.stderrBuffer = ""; this.processExitPromise = null; @@ -713,7 +710,7 @@ export class CopilotClient { } this.state = "disconnected"; - this.actualPort = null; + this.runtimePort = null; this.stderrBuffer = ""; this.processExitPromise = null; } @@ -1480,18 +1477,21 @@ export class CopilotClient { this.stderrBuffer = ""; const args = [ - ...this.options.cliArgs, + ...this.connectionExtraArgs, "--headless", "--no-auto-update", "--log-level", this.options.logLevel, ]; - // Choose transport mode - if (this.options.useStdio) { + // Choose transport mode based on the resolved connection config. + if (this.connectionConfig.kind === "stdio") { args.push("--stdio"); - } else if (this.options.port > 0) { - args.push("--port", this.options.port.toString()); + } else if (this.connectionConfig.kind === "tcp") { + const requestedPort = this.connectionConfig.port ?? 0; + if (requestedPort > 0) { + args.push("--port", requestedPort.toString()); + } } // Add auth-related flags @@ -1517,7 +1517,7 @@ export class CopilotClient { } // Suppress debug/trace output that might pollute stdout - const envWithoutNodeDebug = { ...this.options.env }; + const envWithoutNodeDebug = { ...this.resolvedEnv }; delete envWithoutNodeDebug.NODE_DEBUG; // Set auth token in environment if provided @@ -1529,11 +1529,11 @@ export class CopilotClient { envWithoutNodeDebug.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken; } - if (this.options.copilotHome) { - envWithoutNodeDebug.COPILOT_HOME = this.options.copilotHome; + if (this.options.baseDirectory) { + envWithoutNodeDebug.COPILOT_HOME = this.options.baseDirectory; } - if (!this.options.cliPath) { + if (!this.resolvedCliPath) { throw new Error( "Path to Copilot CLI is required. Please provide it via the cliPath option, or use cliUrl to rely on a remote CLI." ); @@ -1558,28 +1558,28 @@ export class CopilotClient { } // Verify CLI exists before attempting to spawn - if (!existsSync(this.options.cliPath)) { + if (!existsSync(this.resolvedCliPath)) { throw new Error( - `Copilot CLI not found at ${this.options.cliPath}. Ensure @github/copilot is installed.` + `Copilot CLI not found at ${this.resolvedCliPath}. Ensure @github/copilot is installed.` ); } - const stdioConfig: ["pipe", "pipe", "pipe"] | ["ignore", "pipe", "pipe"] = this.options - .useStdio - ? ["pipe", "pipe", "pipe"] - : ["ignore", "pipe", "pipe"]; + const stdioConfig: ["pipe", "pipe", "pipe"] | ["ignore", "pipe", "pipe"] = + this.connectionConfig.kind === "stdio" + ? ["pipe", "pipe", "pipe"] + : ["ignore", "pipe", "pipe"]; // For .js files, spawn node explicitly; for executables, spawn directly - const isJsFile = this.options.cliPath.endsWith(".js"); + const isJsFile = this.resolvedCliPath.endsWith(".js"); if (isJsFile) { - this.cliProcess = spawn(getNodeExecPath(), [this.options.cliPath, ...args], { + this.cliProcess = spawn(getNodeExecPath(), [this.resolvedCliPath, ...args], { stdio: stdioConfig, cwd: this.options.cwd, env: envWithoutNodeDebug, windowsHide: true, }); } else { - this.cliProcess = spawn(this.options.cliPath, args, { + this.cliProcess = spawn(this.resolvedCliPath, args, { stdio: stdioConfig, cwd: this.options.cwd, env: envWithoutNodeDebug, @@ -1591,7 +1591,7 @@ export class CopilotClient { let resolved = false; // For stdio mode, we're ready immediately after spawn - if (this.options.useStdio) { + if (this.connectionConfig.kind === "stdio") { resolved = true; resolve(); } else { @@ -1600,7 +1600,7 @@ export class CopilotClient { stdout += data.toString(); const match = stdout.match(/listening on port (\d+)/i); if (match && !resolved) { - this.actualPort = parseInt(match[1], 10); + this.runtimePort = parseInt(match[1], 10); resolved = true; resolve(); } @@ -1688,12 +1688,14 @@ export class CopilotClient { * Connect to the CLI server (via socket or stdio) */ private async connectToServer(): Promise { - if (this.options.isChildProcess) { - return this.connectToParentProcessViaStdio(); - } else if (this.options.useStdio) { - return this.connectToChildProcessViaStdio(); - } else { - return this.connectViaTcp(); + switch (this.connectionConfig.kind) { + case "parent-process": + return this.connectToParentProcessViaStdio(); + case "stdio": + return this.connectToChildProcessViaStdio(); + case "tcp": + case "uri": + return this.connectViaTcp(); } } @@ -1744,7 +1746,7 @@ export class CopilotClient { * Connect to the CLI server via TCP socket */ private async connectViaTcp(): Promise { - if (!this.actualPort) { + if (!this.runtimePort) { throw new Error("Server port not available"); } @@ -1756,7 +1758,7 @@ export class CopilotClient { reject(new Error("Timeout connecting to CLI server")); }, 10000); - this.socket.connect(this.actualPort!, this.actualHost, () => { + this.socket.connect(this.runtimePort!, this.actualHost, () => { clearTimeout(connectionTimeout); // Create JSON-RPC connection this.connection = createMessageConnection( diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 914a3bf60..617052546 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -35,7 +35,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise; /** * GitHub token to use for authentication. - * When provided, the token is passed to the CLI server via environment variable. + * When provided, the token is passed to the runtime via environment variable. * This takes priority over other authentication methods. */ gitHubToken?: string; /** * Whether to use the logged-in user for authentication. - * When true, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. + * When true, the runtime will attempt to use stored OAuth tokens or gh CLI auth. * When false, only explicit tokens (gitHubToken or environment variables) are used. * @default true (but defaults to false when gitHubToken is provided) */ @@ -142,15 +214,15 @@ export interface CopilotClientOptions { /** * Custom handler for listing available models. * When provided, client.listModels() calls this handler instead of - * querying the CLI server. Useful in BYOK mode to return models + * querying the runtime. Useful in BYOK mode to return models * available from your custom provider. */ onListModels?: () => Promise | ModelInfo[]; /** - * OpenTelemetry configuration for the CLI process. + * OpenTelemetry configuration for the runtime process. * When provided, the corresponding OTel environment variables are set - * on the spawned CLI server. + * on the spawned runtime. */ telemetry?: TelemetryConfig; @@ -192,29 +264,25 @@ export interface CopilotClientOptions { * Server-wide idle timeout for sessions in seconds. * Sessions without activity for this duration are automatically cleaned up. * Set to 0 or omit to disable (sessions live indefinitely). - * This option is only used when the SDK spawns the CLI process; it is ignored - * when connecting to an external server via {@link cliUrl}. + * Ignored when connecting to an existing runtime via {@link RuntimeConnection.forUri}. * @default undefined (disabled) */ sessionIdleTimeoutSeconds?: number; - /** - * Connection token for the headless CLI server (TCP only). When the SDK - * spawns its own CLI in TCP mode and this is omitted, a UUID is generated - * automatically so the loopback listener is safe by default. Rejected with - * `useStdio: true` (stdio is pre-authenticated by transport). - */ - tcpConnectionToken?: string; - /** * Enable remote session support (Mission Control integration). * When true, sessions in a GitHub repository working directory are * accessible from GitHub web and mobile. - * This option is only used when the SDK spawns the CLI process; it is ignored - * when connecting to an external server via {@link cliUrl}. + * Ignored when connecting to an existing runtime via {@link RuntimeConnection.forUri}. * @default false */ remote?: boolean; + + /** + * @internal Hook used by `joinSession()` to construct a client that talks + * to its parent process over stdio. Not part of the public API. + */ + _internalConnection?: InternalRuntimeConnection; } /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index bc4567104..d934bfcaa 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, it, onTestFinished, vi } from "vitest"; -import { approveAll, CopilotClient, type ModelInfo } from "../src/index.js"; +import { approveAll, CopilotClient, RuntimeConnection, type ModelInfo } from "../src/index.js"; import { CopilotSession } from "../src/session.js"; import { defaultJoinSessionPermissionHandler } from "../src/types.js"; @@ -557,45 +557,44 @@ describe("CopilotClient", () => { describe("URL parsing", () => { it("should parse port-only URL format", () => { const client = new CopilotClient({ - cliUrl: "8080", + connection: RuntimeConnection.forUri("8080"), logLevel: "error", }); - // Verify internal state - expect((client as any).actualPort).toBe(8080); + expect((client as any).runtimePort).toBe(8080); expect((client as any).actualHost).toBe("localhost"); expect((client as any).isExternalServer).toBe(true); }); it("should parse host:port URL format", () => { const client = new CopilotClient({ - cliUrl: "127.0.0.1:9000", + connection: RuntimeConnection.forUri("127.0.0.1:9000"), logLevel: "error", }); - expect((client as any).actualPort).toBe(9000); + expect((client as any).runtimePort).toBe(9000); expect((client as any).actualHost).toBe("127.0.0.1"); expect((client as any).isExternalServer).toBe(true); }); it("should parse http://host:port URL format", () => { const client = new CopilotClient({ - cliUrl: "http://localhost:7000", + connection: RuntimeConnection.forUri("http://localhost:7000"), logLevel: "error", }); - expect((client as any).actualPort).toBe(7000); + expect((client as any).runtimePort).toBe(7000); expect((client as any).actualHost).toBe("localhost"); expect((client as any).isExternalServer).toBe(true); }); it("should parse https://host:port URL format", () => { const client = new CopilotClient({ - cliUrl: "https://example.com:443", + connection: RuntimeConnection.forUri("https://example.com:443"), logLevel: "error", }); - expect((client as any).actualPort).toBe(443); + expect((client as any).runtimePort).toBe(443); expect((client as any).actualHost).toBe("example.com"); expect((client as any).isExternalServer).toBe(true); }); @@ -603,7 +602,7 @@ describe("CopilotClient", () => { it("should throw error for invalid URL format", () => { expect(() => { new CopilotClient({ - cliUrl: "invalid-url", + connection: RuntimeConnection.forUri("invalid-url"), logLevel: "error", }); }).toThrow(/Invalid cliUrl format/); @@ -612,7 +611,7 @@ describe("CopilotClient", () => { it("should throw error for invalid port - too high", () => { expect(() => { new CopilotClient({ - cliUrl: "localhost:99999", + connection: RuntimeConnection.forUri("localhost:99999"), logLevel: "error", }); }).toThrow(/Invalid port in cliUrl/); @@ -621,7 +620,7 @@ describe("CopilotClient", () => { it("should throw error for invalid port - zero", () => { expect(() => { new CopilotClient({ - cliUrl: "localhost:0", + connection: RuntimeConnection.forUri("localhost:0"), logLevel: "error", }); }).toThrow(/Invalid port in cliUrl/); @@ -630,57 +629,28 @@ describe("CopilotClient", () => { it("should throw error for invalid port - negative", () => { expect(() => { new CopilotClient({ - cliUrl: "localhost:-1", + connection: RuntimeConnection.forUri("localhost:-1"), logLevel: "error", }); }).toThrow(/Invalid port in cliUrl/); }); - it("should throw error when cliUrl is used with useStdio", () => { - expect(() => { - new CopilotClient({ - cliUrl: "localhost:8080", - useStdio: true, - logLevel: "error", - }); - }).toThrow(/cliUrl is mutually exclusive/); - }); - - it("should throw error when cliUrl is used with cliPath", () => { - expect(() => { - new CopilotClient({ - cliUrl: "localhost:8080", - cliPath: "/path/to/cli", - logLevel: "error", - }); - }).toThrow(/cliUrl is mutually exclusive/); - }); - - it("should set useStdio to false when cliUrl is provided", () => { - const client = new CopilotClient({ - cliUrl: "8080", - logLevel: "error", - }); - - expect(client["options"].useStdio).toBe(false); - }); - it("should mark client as using external server", () => { const client = new CopilotClient({ - cliUrl: "localhost:8080", + connection: RuntimeConnection.forUri("localhost:8080"), logLevel: "error", }); expect((client as any).isExternalServer).toBe(true); }); - it("should not resolve cliPath when cliUrl is provided", () => { + it("should not resolve a CLI path when forUri is used", () => { const client = new CopilotClient({ - cliUrl: "localhost:8080", + connection: RuntimeConnection.forUri("localhost:8080"), logLevel: "error", }); - expect(client["options"].cliPath).toBeUndefined(); + expect((client as any).resolvedCliPath).toBeUndefined(); }); }); @@ -758,41 +728,41 @@ describe("CopilotClient", () => { expect((client as any).options.useLoggedInUser).toBe(false); }); - it("should accept copilotHome option", () => { + it("should accept baseDirectory option", () => { const client = new CopilotClient({ - copilotHome: "/custom/copilot/home", + baseDirectory: "/custom/copilot/home", logLevel: "error", }); - expect((client as any).options.copilotHome).toBe("/custom/copilot/home"); + expect((client as any).options.baseDirectory).toBe("/custom/copilot/home"); }); - it("should leave copilotHome undefined when not provided", () => { + it("should leave baseDirectory undefined when not provided", () => { const client = new CopilotClient({ logLevel: "error", }); - expect((client as any).options.copilotHome).toBeUndefined(); + expect((client as any).options.baseDirectory).toBeUndefined(); }); - it("should throw error when gitHubToken is used with cliUrl", () => { + it("should throw error when gitHubToken is used with forUri", () => { expect(() => { new CopilotClient({ - cliUrl: "localhost:8080", + connection: RuntimeConnection.forUri("localhost:8080"), gitHubToken: "gho_test_token", logLevel: "error", }); - }).toThrow(/gitHubToken and useLoggedInUser cannot be used with cliUrl/); + }).toThrow(/gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/); }); - it("should throw error when useLoggedInUser is used with cliUrl", () => { + it("should throw error when useLoggedInUser is used with forUri", () => { expect(() => { new CopilotClient({ - cliUrl: "localhost:8080", + connection: RuntimeConnection.forUri("localhost:8080"), useLoggedInUser: false, logLevel: "error", }); - }).toThrow(/gitHubToken and useLoggedInUser cannot be used with cliUrl/); + }).toThrow(/gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/); }); }); diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts index 906b4fcf4..667477749 100644 --- a/nodejs/test/e2e/client.e2e.test.ts +++ b/nodejs/test/e2e/client.e2e.test.ts @@ -1,6 +1,6 @@ import { ChildProcess } from "child_process"; import { describe, expect, it, onTestFinished } from "vitest"; -import { CopilotClient, approveAll } from "../../src/index.js"; +import { CopilotClient, approveAll, RuntimeConnection } from "../../src/index.js"; function onTestFinishedForceStop(client: CopilotClient) { onTestFinished(async () => { @@ -14,7 +14,7 @@ function onTestFinishedForceStop(client: CopilotClient) { describe("Client", () => { it("should start and connect to server using stdio", async () => { - const client = new CopilotClient({ useStdio: true }); + const client = new CopilotClient(); onTestFinishedForceStop(client); await client.start(); @@ -29,7 +29,7 @@ describe("Client", () => { }); it("should start and connect to server using tcp", async () => { - const client = new CopilotClient({ useStdio: false }); + const client = new CopilotClient({ connection: RuntimeConnection.forTcp() }); onTestFinishedForceStop(client); await client.start(); @@ -51,7 +51,7 @@ describe("Client", () => { // saying "Cannot call write after a stream was destroyed" // because the JSON-RPC logic is still trying to write to stdin after // the process has exited. - const client = new CopilotClient({ useStdio: false }); + const client = new CopilotClient({ connection: RuntimeConnection.forTcp() }); await client.createSession({ onPermissionRequest: approveAll }); @@ -83,7 +83,7 @@ describe("Client", () => { }); it("should get status with version and protocol info", async () => { - const client = new CopilotClient({ useStdio: true }); + const client = new CopilotClient(); onTestFinishedForceStop(client); await client.start(); @@ -99,7 +99,7 @@ describe("Client", () => { }); it("should get auth status", async () => { - const client = new CopilotClient({ useStdio: true }); + const client = new CopilotClient(); onTestFinishedForceStop(client); await client.start(); @@ -115,7 +115,7 @@ describe("Client", () => { }); it("should list models when authenticated", async () => { - const client = new CopilotClient({ useStdio: true }); + const client = new CopilotClient(); onTestFinishedForceStop(client); await client.start(); @@ -143,8 +143,7 @@ describe("Client", () => { it("should report error with stderr when CLI fails to start", async () => { const client = new CopilotClient({ - cliArgs: ["--nonexistent-flag-for-testing"], - useStdio: true, + connection: RuntimeConnection.forStdio({ args: ["--nonexistent-flag-for-testing"] }), }); onTestFinishedForceStop(client); diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index dbd0e5859..e199af0ac 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -6,7 +6,7 @@ import * as fs from "fs"; import * as net from "net"; import * as path from "path"; import { describe, expect, it, onTestFinished } from "vitest"; -import { approveAll, CopilotClient } from "../../src/index.js"; +import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; const FAKE_STDIO_CLI_SCRIPT = `const fs = require("fs"); @@ -140,11 +140,11 @@ function assertArgumentValue( describe("Client options", async () => { const { copilotClient: defaultClient, env, workDir } = await createSdkTestContext(); - it("autostart false requires explicit start", async () => { + it("createSession starts the client lazily", async () => { const client = new CopilotClient({ cwd: workDir, env, - cliPath: process.env.COPILOT_CLI_PATH + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), }); onTestFinished(async () => { try { @@ -156,14 +156,8 @@ describe("Client options", async () => { expect(client.getState()).toBe("disconnected"); - await expect(client.createSession({ onPermissionRequest: approveAll })).rejects.toThrow( - /start/i - ); - - await client.start(); - expect(client.getState()).toBe("connected"); - const session = await client.createSession({ onPermissionRequest: approveAll }); + expect(client.getState()).toBe("connected"); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); await session.disconnect(); @@ -174,9 +168,10 @@ describe("Client options", async () => { const client = new CopilotClient({ cwd: workDir, env, - cliPath: process.env.COPILOT_CLI_PATH, - useStdio: false, - port, + connection: RuntimeConnection.forTcp({ + path: process.env.COPILOT_CLI_PATH, + port, + }), }); onTestFinished(async () => { try { @@ -189,7 +184,7 @@ describe("Client options", async () => { await client.start(); expect(client.getState()).toBe("connected"); - expect((client as unknown as { actualPort: number }).actualPort).toBe(port); + expect((client as unknown as { runtimePort: number }).runtimePort).toBe(port); const response = await client.ping("fixed-port"); expect(response.message).toBe("pong: fixed-port"); @@ -207,7 +202,7 @@ describe("Client options", async () => { const client = new CopilotClient({ cwd: clientCwd, env, - cliPath: process.env.COPILOT_CLI_PATH, + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), gitHubToken: process.env.CI ? "fake-token-for-e2e-tests" : undefined, }); onTestFinished(async () => { @@ -246,9 +241,11 @@ describe("Client options", async () => { const client = new CopilotClient({ cwd: workDir, env: { ...env, COPILOT_HOME: copilotHomeFromEnv }, - cliPath, - cliArgs: ["--capture-file", capturePath], - copilotHome: copilotHomeFromOption, + connection: RuntimeConnection.forStdio({ + path: cliPath, + args: ["--capture-file", capturePath], + }), + baseDirectory: copilotHomeFromOption, gitHubToken: "process-option-token", logLevel: "debug", sessionIdleTimeoutSeconds: 17, @@ -319,19 +316,19 @@ describe("Client options", async () => { await session.disconnect(); }); - it("should throw when githubtoken used with cliurl", () => { + it("should throw when gitHubToken used with forUri", () => { expect(() => { new CopilotClient({ - cliUrl: "localhost:8080", + connection: RuntimeConnection.forUri("localhost:8080"), gitHubToken: "gho_test_token", }); }).toThrow(); }); - it("should throw when useloggedinuser used with cliurl", () => { + it("should throw when useLoggedInUser used with forUri", () => { expect(() => { new CopilotClient({ - cliUrl: "localhost:8080", + connection: RuntimeConnection.forUri("localhost:8080"), useLoggedInUser: false, }); }).toThrow(); diff --git a/nodejs/test/e2e/commands.e2e.test.ts b/nodejs/test/e2e/commands.e2e.test.ts index 452d51f32..b6c14c024 100644 --- a/nodejs/test/e2e/commands.e2e.test.ts +++ b/nodejs/test/e2e/commands.e2e.test.ts @@ -3,7 +3,7 @@ *--------------------------------------------------------------------------------------------*/ import { afterAll, describe, expect, it } from "vitest"; -import { CopilotClient, approveAll } from "../../src/index.js"; +import { CopilotClient, approveAll, RuntimeConnection } from "../../src/index.js"; import type { SessionEvent } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -11,7 +11,6 @@ describe("Commands", async () => { // Use TCP mode so a second client can connect to the same CLI process const tcpConnectionToken = "commands-test-token"; const ctx = await createSdkTestContext({ - useStdio: false, copilotClientOptions: { tcpConnectionToken }, }); const client1 = ctx.copilotClient; @@ -20,8 +19,8 @@ describe("Commands", async () => { const initSession = await client1.createSession({ onPermissionRequest: approveAll }); await initSession.disconnect(); - const { actualPort } = client1 as unknown as { actualPort: number }; - const client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}`, tcpConnectionToken }); + const { runtimePort } = client1 as unknown as { runtimePort: number }; + const client2 = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { connectionToken: tcpConnectionToken }) }); afterAll(async () => { await client2.stop(); diff --git a/nodejs/test/e2e/connection_token.test.ts b/nodejs/test/e2e/connection_token.test.ts index 50813778c..079eae51a 100644 --- a/nodejs/test/e2e/connection_token.test.ts +++ b/nodejs/test/e2e/connection_token.test.ts @@ -3,23 +3,25 @@ *--------------------------------------------------------------------------------------------*/ import { afterAll, describe, expect, it } from "vitest"; -import { CopilotClient } from "../../src/index.js"; +import { CopilotClient, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Connection token", async () => { const ctx = await createSdkTestContext({ - useStdio: false, - copilotClientOptions: { tcpConnectionToken: "right-token" }, + copilotClientOptions: { + connection: RuntimeConnection.forTcp({ connectionToken: "right-token" }), + }, }); const goodClient = ctx.copilotClient; await goodClient.start(); - const port = (goodClient as unknown as { actualPort: number }).actualPort; + const port = (goodClient as unknown as { runtimePort: number }).runtimePort; const wrongClient = new CopilotClient({ - cliUrl: `localhost:${port}`, - tcpConnectionToken: "wrong", + connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken: "wrong" }), + }); + const noTokenClient = new CopilotClient({ + connection: RuntimeConnection.forUri(`localhost:${port}`), }); - const noTokenClient = new CopilotClient({ cliUrl: `localhost:${port}` }); afterAll(async () => { await wrongClient.forceStop(); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index 970cfcbb9..cd9c74e5e 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -9,7 +9,7 @@ import { basename, dirname, join, resolve } from "path"; import { rimraf } from "rimraf"; import { fileURLToPath } from "url"; import { afterAll, afterEach, beforeEach, onTestFailed, TestContext } from "vitest"; -import { CopilotClient, CopilotClientOptions } from "../../../src"; +import { CopilotClient, CopilotClientOptions, RuntimeConnection } from "../../../src"; import { CapiProxy } from "./CapiProxy"; import { formatError, retry } from "./sdkTestHelper"; @@ -66,13 +66,17 @@ export async function createSdkTestContext({ XDG_STATE_HOME: homeDir, }; + const connection: RuntimeConnection = + useStdio === false + ? RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH }) + : RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }); + const copilotClient = new CopilotClient({ cwd: workDir, env, logLevel: logLevel || "error", - cliPath: process.env.COPILOT_CLI_PATH, + connection, gitHubToken: authTokenToUse, - useStdio: useStdio, ...copilotClientOptions, }); diff --git a/nodejs/test/e2e/multi-client.e2e.test.ts b/nodejs/test/e2e/multi-client.e2e.test.ts index 4a6c5a0d4..e5b62394b 100644 --- a/nodejs/test/e2e/multi-client.e2e.test.ts +++ b/nodejs/test/e2e/multi-client.e2e.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, afterAll } from "vitest"; import { z } from "zod"; -import { CopilotClient, defineTool, approveAll } from "../../src/index.js"; +import { CopilotClient, defineTool, approveAll, RuntimeConnection } from "../../src/index.js"; import type { SessionEvent } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext"; @@ -12,7 +12,6 @@ describe("Multi-client broadcast", async () => { // Use TCP mode so a second client can connect to the same CLI process const tcpConnectionToken = "multi-client-test-token"; const ctx = await createSdkTestContext({ - useStdio: false, copilotClientOptions: { tcpConnectionToken }, }); const client1 = ctx.copilotClient; @@ -21,8 +20,8 @@ describe("Multi-client broadcast", async () => { const initSession = await client1.createSession({ onPermissionRequest: approveAll }); await initSession.disconnect(); - const actualPort = (client1 as unknown as { actualPort: number }).actualPort; - let client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}`, tcpConnectionToken }); + const runtimePort = (client1 as unknown as { runtimePort: number }).runtimePort; + let client2 = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { connectionToken: tcpConnectionToken }) }); const EVENT_TIMEOUT_MS = 30_000; afterAll(async () => { @@ -351,7 +350,7 @@ describe("Multi-client broadcast", async () => { process.removeListener("unhandledRejection", suppressDisposed); // Recreate client2 for cleanup in afterAll (but don't rejoin the session) - client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}`, tcpConnectionToken }); + client2 = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { connectionToken: tcpConnectionToken }) }); // Now only stable_tool should be available const afterResponse = await session1.sendAndWait({ diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index 3769665c9..fe953c226 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { z } from "zod"; -import { approveAll, CopilotClient, defineTool } from "../../src/index.js"; +import { approveAll, CopilotClient, defineTool, RuntimeConnection } from "../../src/index.js"; import type { CopilotSession, ExternalToolRequestedEvent, @@ -129,9 +129,7 @@ describe("Pending work resume", async () => { const server = new CopilotClient({ cwd: workDir, env, - cliPath: process.env.COPILOT_CLI_PATH, - useStdio: false, - tcpConnectionToken: SHARED_TOKEN, + connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken: SHARED_TOKEN }), }); onTestFinished(async () => { try { @@ -144,7 +142,9 @@ describe("Pending work resume", async () => { } function createConnectingClient(cliUrl: string): CopilotClient { - const client = new CopilotClient({ cliUrl, tcpConnectionToken: SHARED_TOKEN }); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(cliUrl, { connectionToken: SHARED_TOKEN }), + }); onTestFinished(async () => { try { await client.forceStop(); @@ -156,7 +156,7 @@ describe("Pending work resume", async () => { } function getCliUrl(server: CopilotClient): string { - const port = (server as unknown as { actualPort: number | null }).actualPort; + const port = (server as unknown as { runtimePort: number | null }).runtimePort; if (!port) { throw new Error("Expected the test server to be listening on a TCP port."); } diff --git a/nodejs/test/e2e/per_session_auth.e2e.test.ts b/nodejs/test/e2e/per_session_auth.e2e.test.ts index e2bf6c197..3b07b664e 100644 --- a/nodejs/test/e2e/per_session_auth.e2e.test.ts +++ b/nodejs/test/e2e/per_session_auth.e2e.test.ts @@ -3,7 +3,7 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, it } from "vitest"; -import { approveAll, CopilotClient } from "../../src/index.js"; +import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Per-session GitHub auth", async () => { @@ -83,7 +83,7 @@ describe("Per-session GitHub auth", async () => { COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, }), logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), useLoggedInUser: false, }); diff --git a/nodejs/test/e2e/rpc.e2e.test.ts b/nodejs/test/e2e/rpc.e2e.test.ts index 028d4b41a..0442ab926 100644 --- a/nodejs/test/e2e/rpc.e2e.test.ts +++ b/nodejs/test/e2e/rpc.e2e.test.ts @@ -14,7 +14,7 @@ function onTestFinishedForceStop(client: CopilotClient) { describe("RPC", () => { it("should call rpc.ping with typed params and result", async () => { - const client = new CopilotClient({ useStdio: true }); + const client = new CopilotClient(); onTestFinishedForceStop(client); await client.start(); @@ -27,7 +27,7 @@ describe("RPC", () => { }); it("should call rpc.models.list with typed result", async () => { - const client = new CopilotClient({ useStdio: true }); + const client = new CopilotClient(); onTestFinishedForceStop(client); await client.start(); @@ -47,7 +47,7 @@ describe("RPC", () => { // account.getQuota is defined in schema but not yet implemented in CLI it.skip("should call rpc.account.getQuota when authenticated", async () => { - const client = new CopilotClient({ useStdio: true }); + const client = new CopilotClient(); onTestFinishedForceStop(client); await client.start(); diff --git a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts index b99103c33..91e23200c 100644 --- a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts @@ -5,7 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import { describe, expect, it } from "vitest"; -import { approveAll } from "../../src/index.js"; +import { approveAll, RuntimeConnection } from "../../src/index.js"; import type { MCPServerConfig } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -13,7 +13,7 @@ describe("Session MCP and skills RPC", async () => { // --yolo auto-approves extension permission gates at the CLI level, // preventing breakage from new gates (e.g., extension-permission-access). const { copilotClient: client, workDir } = await createSdkTestContext({ - copilotClientOptions: { cliArgs: ["--yolo"] }, + copilotClientOptions: { connection: RuntimeConnection.forStdio({ args: ["--yolo"] }) }, }); function createSkill(skillsDir: string, skillName: string, description: string): void { diff --git a/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts index 6601448a4..581567cb3 100644 --- a/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient } from "../../src/index.js"; function startEphemeralClient(): CopilotClient { - const client = new CopilotClient({ useStdio: true }); + const client = new CopilotClient(); onTestFinished(async () => { try { await client.forceStop(); diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index 68b1beca5..27f07cafd 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -5,7 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import { describe, expect, it, onTestFinished } from "vitest"; -import { CopilotClient } from "../../src/index.js"; +import { CopilotClient, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Server-scoped RPC", async () => { @@ -20,7 +20,7 @@ describe("Server-scoped RPC", async () => { cwd: workDir, env: childEnv, logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), gitHubToken: token, }); onTestFinished(async () => { diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index f22ea14d9..f7440950b 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -103,23 +103,21 @@ describe("Session Fs", async () => { it("should reject setProvider when sessions already exist", async () => { const tcpConnectionToken = "session-fs-test-token"; - const client = new CopilotClient({ - useStdio: false, // Use TCP so we can connect from a second client + const client = new CopilotClient({ // Use TCP so we can connect from a second client tcpConnectionToken, env, }); onTestFinished(() => client.forceStop()); await client.createSession({ onPermissionRequest: approveAll, createSessionFsProvider }); - const { actualPort: port } = client as unknown as { actualPort: number }; + const { runtimePort: port } = client as unknown as { runtimePort: number }; // Second client tries to connect with a session fs — should fail // because sessions already exist on the runtime. const client2 = new CopilotClient({ env, logLevel: "error", - cliUrl: `localhost:${port}`, - tcpConnectionToken, + connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken: tcpConnectionToken }), sessionFs: sessionFsConfig, }); onTestFinished(() => client2.forceStop()); diff --git a/nodejs/test/e2e/suspend.e2e.test.ts b/nodejs/test/e2e/suspend.e2e.test.ts index 3ca4c4e3f..2c57a7f9e 100644 --- a/nodejs/test/e2e/suspend.e2e.test.ts +++ b/nodejs/test/e2e/suspend.e2e.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { z } from "zod"; -import { approveAll, CopilotClient, defineTool } from "../../src/index.js"; +import { approveAll, CopilotClient, defineTool, RuntimeConnection } from "../../src/index.js"; import type { PermissionRequest, PermissionRequestResult, SessionEvent } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -65,22 +65,22 @@ describe("Suspend RPC", async () => { const server = new CopilotClient({ cwd: workDir, env, - cliPath: process.env.COPILOT_CLI_PATH, - useStdio: false, - tcpConnectionToken: SHARED_TOKEN, + connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken: SHARED_TOKEN }), }); onTestFinishedForceStop(server); return server; } function createConnectingClient(cliUrl: string): CopilotClient { - const connectedClient = new CopilotClient({ cliUrl, tcpConnectionToken: SHARED_TOKEN }); + const connectedClient = new CopilotClient({ + connection: RuntimeConnection.forUri(cliUrl, { connectionToken: SHARED_TOKEN }), + }); onTestFinishedForceStop(connectedClient); return connectedClient; } function getCliUrl(server: CopilotClient): string { - const port = (server as unknown as { actualPort: number | null }).actualPort; + const port = (server as unknown as { runtimePort: number | null }).runtimePort; if (!port) { throw new Error("Expected the test server to be listening on a TCP port."); } diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index e17228d5d..00435c8e5 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -3,7 +3,7 @@ *--------------------------------------------------------------------------------------------*/ import { afterAll, describe, expect, it } from "vitest"; -import { CopilotClient, approveAll } from "../../src/index.js"; +import { CopilotClient, approveAll, RuntimeConnection } from "../../src/index.js"; import type { SessionEvent } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -55,7 +55,6 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { // Use TCP mode so a second client can connect to the same CLI process const tcpConnectionToken = "ui-elicitation-test-token"; const ctx = await createSdkTestContext({ - useStdio: false, copilotClientOptions: { tcpConnectionToken }, }); const client1 = ctx.copilotClient; @@ -64,8 +63,8 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { const initSession = await client1.createSession({ onPermissionRequest: approveAll }); await initSession.disconnect(); - const { actualPort } = client1 as unknown as { actualPort: number }; - const client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}`, tcpConnectionToken }); + const { runtimePort } = client1 as unknown as { runtimePort: number }; + const client2 = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { connectionToken: tcpConnectionToken }) }); afterAll(async () => { await client2.stop(); @@ -139,8 +138,9 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { // Use a dedicated client so we can stop it without affecting shared client2 const client3 = new CopilotClient({ - cliUrl: `localhost:${actualPort}`, - tcpConnectionToken, + connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { + connectionToken: tcpConnectionToken, + }), }); // Client3 joins WITH elicitation handler diff --git a/test/scenarios/auth/byok-anthropic/typescript/src/index.ts b/test/scenarios/auth/byok-anthropic/typescript/src/index.ts index a7f460d8f..7eaaaeed4 100644 --- a/test/scenarios/auth/byok-anthropic/typescript/src/index.ts +++ b/test/scenarios/auth/byok-anthropic/typescript/src/index.ts @@ -10,7 +10,7 @@ async function main() { } const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), }); try { diff --git a/test/scenarios/auth/byok-azure/typescript/src/index.ts b/test/scenarios/auth/byok-azure/typescript/src/index.ts index 397a0a187..6179aabe4 100644 --- a/test/scenarios/auth/byok-azure/typescript/src/index.ts +++ b/test/scenarios/auth/byok-azure/typescript/src/index.ts @@ -11,7 +11,7 @@ async function main() { } const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), }); try { diff --git a/test/scenarios/auth/byok-ollama/typescript/src/index.ts b/test/scenarios/auth/byok-ollama/typescript/src/index.ts index 936d118a8..eccb325d9 100644 --- a/test/scenarios/auth/byok-ollama/typescript/src/index.ts +++ b/test/scenarios/auth/byok-ollama/typescript/src/index.ts @@ -8,7 +8,7 @@ const COMPACT_SYSTEM_PROMPT = async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), }); try { diff --git a/test/scenarios/auth/byok-openai/typescript/src/index.ts b/test/scenarios/auth/byok-openai/typescript/src/index.ts index 41eda577a..f9c564d0e 100644 --- a/test/scenarios/auth/byok-openai/typescript/src/index.ts +++ b/test/scenarios/auth/byok-openai/typescript/src/index.ts @@ -11,7 +11,7 @@ if (!OPENAI_API_KEY) { async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), }); try { diff --git a/test/scenarios/auth/gh-app/typescript/src/index.ts b/test/scenarios/auth/gh-app/typescript/src/index.ts index a5b8f28e2..cc1ffe544 100644 --- a/test/scenarios/auth/gh-app/typescript/src/index.ts +++ b/test/scenarios/auth/gh-app/typescript/src/index.ts @@ -110,7 +110,7 @@ async function main() { console.log(`Authenticated as: ${user.login}${user.name ? ` (${user.name})` : ""}`); const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: accessToken, }); diff --git a/test/scenarios/bundling/fully-bundled/typescript/src/index.ts b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts index bee246f64..96b0d3175 100644 --- a/test/scenarios/bundling/fully-bundled/typescript/src/index.ts +++ b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/callbacks/hooks/typescript/src/index.ts b/test/scenarios/callbacks/hooks/typescript/src/index.ts index 4ecd7ec33..f328913df 100644 --- a/test/scenarios/callbacks/hooks/typescript/src/index.ts +++ b/test/scenarios/callbacks/hooks/typescript/src/index.ts @@ -4,7 +4,7 @@ async function main() { const hookLog: string[] = []; const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/callbacks/permissions/typescript/src/index.ts b/test/scenarios/callbacks/permissions/typescript/src/index.ts index 8e72fc08b..d86ed2aac 100644 --- a/test/scenarios/callbacks/permissions/typescript/src/index.ts +++ b/test/scenarios/callbacks/permissions/typescript/src/index.ts @@ -4,9 +4,7 @@ async function main() { const permissionLog: string[] = []; const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { - cliPath: process.env.COPILOT_CLI_PATH, - }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/callbacks/user-input/typescript/src/index.ts b/test/scenarios/callbacks/user-input/typescript/src/index.ts index 915008b68..1f38c43a9 100644 --- a/test/scenarios/callbacks/user-input/typescript/src/index.ts +++ b/test/scenarios/callbacks/user-input/typescript/src/index.ts @@ -4,7 +4,7 @@ async function main() { const inputLog: string[] = []; const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/modes/default/typescript/src/index.ts b/test/scenarios/modes/default/typescript/src/index.ts index 89aab3598..b2fa51732 100644 --- a/test/scenarios/modes/default/typescript/src/index.ts +++ b/test/scenarios/modes/default/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/modes/minimal/typescript/src/index.ts b/test/scenarios/modes/minimal/typescript/src/index.ts index f20e476de..3365a2a40 100644 --- a/test/scenarios/modes/minimal/typescript/src/index.ts +++ b/test/scenarios/modes/minimal/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/prompts/attachments/typescript/src/index.ts b/test/scenarios/prompts/attachments/typescript/src/index.ts index 100f7e17d..148ccd95a 100644 --- a/test/scenarios/prompts/attachments/typescript/src/index.ts +++ b/test/scenarios/prompts/attachments/typescript/src/index.ts @@ -6,7 +6,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts index e569fd705..33af00cd0 100644 --- a/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts +++ b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/prompts/system-message/typescript/src/index.ts b/test/scenarios/prompts/system-message/typescript/src/index.ts index e0eb0aab7..6c39e82e8 100644 --- a/test/scenarios/prompts/system-message/typescript/src/index.ts +++ b/test/scenarios/prompts/system-message/typescript/src/index.ts @@ -4,7 +4,7 @@ const PIRATE_PROMPT = `You are a pirate. Always respond in pirate speak. Say 'Ar async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts index 89543d281..51d14969e 100644 --- a/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts +++ b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts @@ -5,7 +5,7 @@ const ROBOT_PROMPT = `You are a robot. Always say BEEP BOOP!`; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts index 9de7b34f7..1586e0a0e 100644 --- a/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts +++ b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/sessions/session-resume/typescript/src/index.ts b/test/scenarios/sessions/session-resume/typescript/src/index.ts index 9e0a16859..b7169852a 100644 --- a/test/scenarios/sessions/session-resume/typescript/src/index.ts +++ b/test/scenarios/sessions/session-resume/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/sessions/streaming/typescript/src/index.ts b/test/scenarios/sessions/streaming/typescript/src/index.ts index f70dcccec..88274536b 100644 --- a/test/scenarios/sessions/streaming/typescript/src/index.ts +++ b/test/scenarios/sessions/streaming/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/tools/custom-agents/typescript/src/index.ts b/test/scenarios/tools/custom-agents/typescript/src/index.ts index ffb0bd827..93ee40602 100644 --- a/test/scenarios/tools/custom-agents/typescript/src/index.ts +++ b/test/scenarios/tools/custom-agents/typescript/src/index.ts @@ -11,7 +11,7 @@ const analyzeCodebase = defineTool("analyze-codebase", { async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/tools/mcp-servers/typescript/src/index.ts b/test/scenarios/tools/mcp-servers/typescript/src/index.ts index 1e8c11466..35aaf94f0 100644 --- a/test/scenarios/tools/mcp-servers/typescript/src/index.ts +++ b/test/scenarios/tools/mcp-servers/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/tools/no-tools/typescript/src/index.ts b/test/scenarios/tools/no-tools/typescript/src/index.ts index 487b47622..9e8478190 100644 --- a/test/scenarios/tools/no-tools/typescript/src/index.ts +++ b/test/scenarios/tools/no-tools/typescript/src/index.ts @@ -7,7 +7,7 @@ If asked about your capabilities or tools, clearly state that you have no tools async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/tools/skills/typescript/src/index.ts b/test/scenarios/tools/skills/typescript/src/index.ts index 36447d975..20732177d 100644 --- a/test/scenarios/tools/skills/typescript/src/index.ts +++ b/test/scenarios/tools/skills/typescript/src/index.ts @@ -6,7 +6,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/tools/tool-filtering/typescript/src/index.ts b/test/scenarios/tools/tool-filtering/typescript/src/index.ts index 9976e38f8..f40f13472 100644 --- a/test/scenarios/tools/tool-filtering/typescript/src/index.ts +++ b/test/scenarios/tools/tool-filtering/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/tools/tool-overrides/typescript/src/index.ts b/test/scenarios/tools/tool-overrides/typescript/src/index.ts index 0472115d5..84a5fc983 100644 --- a/test/scenarios/tools/tool-overrides/typescript/src/index.ts +++ b/test/scenarios/tools/tool-overrides/typescript/src/index.ts @@ -3,7 +3,7 @@ import { z } from "zod"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts index fa146da83..49c7bba52 100644 --- a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts +++ b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts @@ -39,9 +39,7 @@ const listFiles = defineTool("list_files", { async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { - cliPath: process.env.COPILOT_CLI_PATH, - }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/transport/reconnect/typescript/src/index.ts b/test/scenarios/transport/reconnect/typescript/src/index.ts index ca28df94b..6fc1c417e 100644 --- a/test/scenarios/transport/reconnect/typescript/src/index.ts +++ b/test/scenarios/transport/reconnect/typescript/src/index.ts @@ -1,8 +1,8 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - cliUrl: process.env.COPILOT_CLI_URL || "localhost:3000", + connection: RuntimeConnection.forUri(process.env.COPILOT_CLI_URL || "localhost:3000"), }); try { diff --git a/test/scenarios/transport/stdio/typescript/src/index.ts b/test/scenarios/transport/stdio/typescript/src/index.ts index bee246f64..96b0d3175 100644 --- a/test/scenarios/transport/stdio/typescript/src/index.ts +++ b/test/scenarios/transport/stdio/typescript/src/index.ts @@ -2,7 +2,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), githubToken: process.env.GITHUB_TOKEN, }); diff --git a/test/scenarios/transport/tcp/typescript/src/index.ts b/test/scenarios/transport/tcp/typescript/src/index.ts index 29a19dd10..e4775f545 100644 --- a/test/scenarios/transport/tcp/typescript/src/index.ts +++ b/test/scenarios/transport/tcp/typescript/src/index.ts @@ -1,8 +1,8 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ - cliUrl: process.env.COPILOT_CLI_URL || "localhost:3000", + connection: RuntimeConnection.forUri(process.env.COPILOT_CLI_URL || "localhost:3000"), }); try { From 9ccf6d0ff9715c44b8b124a399fad0aa68f2feb4 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:55:41 +0100 Subject: [PATCH 10/27] Phase J: send / sendAndWait string overloads Both methods now accept either a MessageOptions object or just a string prompt. The string form is a shorthand for { prompt }: await session.send('Hello'); await session.sendAndWait('Hello'); Mirrors C# PR #1343 Phase 7. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/session.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index ee305cbe8..590912066 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -185,7 +185,11 @@ export class CopilotSession { * }); * ``` */ - async send(options: MessageOptions): Promise { + async send(prompt: string): Promise; + async send(options: MessageOptions): Promise; + async send(optionsOrPrompt: MessageOptions | string): Promise { + const options: MessageOptions = + typeof optionsOrPrompt === "string" ? { prompt: optionsOrPrompt } : optionsOrPrompt; const response = await this.connection.sendRequest("session.send", { ...(await getTraceContext(this.traceContextProvider)), sessionId: this.sessionId, @@ -221,10 +225,17 @@ export class CopilotSession { * console.log(response?.data.content); // "4" * ``` */ + async sendAndWait(prompt: string, timeout?: number): Promise; async sendAndWait( options: MessageOptions, timeout?: number + ): Promise; + async sendAndWait( + optionsOrPrompt: MessageOptions | string, + timeout?: number ): Promise { + const options: MessageOptions = + typeof optionsOrPrompt === "string" ? { prompt: optionsOrPrompt } : optionsOrPrompt; const effectiveTimeout = timeout ?? 60_000; let resolveIdle: () => void; From 9ba1bf1728d587bce9cb941c30021dfc7734b95c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 11:58:18 +0100 Subject: [PATCH 11/27] Phase K: stripInternal, AsyncDisposable, clean stop(), drop destroy() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable stripInternal in tsconfig.json so @internal members no longer appear in the published .d.ts. Verified that CopilotSession constructor, the register*/clientSessionApis hooks, _handle* methods, NO_RESULT_PERMISSION_V2_ERROR, _internalConnection, and ParentProcessRuntimeConnection are all stripped from the public types. The MessageConnection import from vscode-jsonrpc no longer leaks either. - Tag NO_RESULT_PERMISSION_V2_ERROR with @internal explicitly. - Implement Symbol.asyncDispose on CopilotClient so it works inside 'await using' blocks, matching CopilotSession. - Tighten client.stop() so the Node process can exit cleanly without process.exit(): socket.destroy() in addition to socket.end(), explicit destroy() on the child process stdio streams, and cliProcess.unref(). Manually verified by running examples/basic-example.ts against a live runtime: the process exits within a few seconds of the await using block ending. - Remove the deprecated CopilotSession.destroy() alias. - Rewrite examples/basic-example.ts to import from '@github/copilot-sdk' (not '../src/index.js') and demonstrate the await using pattern with the new send/sendAndWait string overloads. Covers review §2.4, §2.5, §2.10, §3.1, §3.2, §4.1, §4.3 and the C# PR's Phase 8 docs/sample updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/examples/basic-example.ts | 20 ++++++++------------ nodejs/src/client.ts | 26 ++++++++++++++++++++++++++ nodejs/src/session.ts | 14 +------------- nodejs/tsconfig.json | 1 + 4 files changed, 36 insertions(+), 25 deletions(-) diff --git a/nodejs/examples/basic-example.ts b/nodejs/examples/basic-example.ts index c20a85af0..0a6c0336b 100644 --- a/nodejs/examples/basic-example.ts +++ b/nodejs/examples/basic-example.ts @@ -3,7 +3,7 @@ *--------------------------------------------------------------------------------------------*/ import { z } from "zod"; -import { CopilotClient, defineTool } from "../src/index.js"; +import { approveAll, CopilotClient, defineTool } from "@github/copilot-sdk"; console.log("🚀 Starting Copilot SDK Example\n"); @@ -20,27 +20,23 @@ const lookupFactTool = defineTool("lookup_fact", { handler: ({ topic }) => facts[topic.toLowerCase()] ?? `No fact stored for ${topic}.`, }); -// Create client - will auto-start CLI server (searches PATH for "copilot") -const client = new CopilotClient({ logLevel: "info" }); -const session = await client.createSession({ tools: [lookupFactTool] }); +await using client = new CopilotClient({ logLevel: "info" }); +await using session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [lookupFactTool], +}); console.log(`✅ Session created: ${session.sessionId}\n`); -// Listen to events session.on((event) => { console.log(`📢 Event [${event.type}]:`, JSON.stringify(event.data, null, 2)); }); -// Send a simple message console.log("💬 Sending message..."); -const result1 = await session.sendAndWait({ prompt: "Tell me 2+2" }); +const result1 = await session.sendAndWait("Tell me 2+2"); console.log("📝 Response:", result1?.data.content); -// Send another message that uses the tool console.log("💬 Sending follow-up message..."); -const result2 = await session.sendAndWait({ prompt: "Use lookup_fact to tell me about 'node'" }); +const result2 = await session.sendAndWait("Use lookup_fact to tell me about 'node'"); console.log("📝 Response:", result2?.data.content); -// Clean up -await session.disconnect(); -await client.stop(); console.log("✅ Done!"); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index a4686149f..9d010abf9 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -603,7 +603,11 @@ export class CopilotClient { if (this.socket) { try { + // .end() initiates the close, but we also .destroy() to make + // sure the underlying handle is released so the Node event + // loop can exit cleanly without an explicit process.exit(). this.socket.end(); + this.socket.destroy(); } catch (error) { errors.push( new Error( @@ -617,7 +621,13 @@ export class CopilotClient { // Kill CLI process (only if we spawned it) if (this.cliProcess && !this.isExternalServer) { try { + // Detach stdio streams so they don't keep the event loop alive + // while we wait for the child to exit. + this.cliProcess.stdin?.destroy(); + this.cliProcess.stdout?.destroy(); + this.cliProcess.stderr?.destroy(); this.cliProcess.kill(); + this.cliProcess.unref(); } catch (error) { errors.push( new Error( @@ -640,6 +650,22 @@ export class CopilotClient { return errors; } + /** + * Alias for {@link stop} that lets `CopilotClient` participate in `await using` + * blocks for automatic cleanup. + * + * @example + * ```typescript + * await using client = new CopilotClient(); + * const session = await client.createSession({ onPermissionRequest: approveAll }); + * await session.sendAndWait("Hello"); + * // client.stop() is called automatically when the block exits. + * ``` + */ + async [Symbol.asyncDispose](): Promise { + await this.stop(); + } + /** * Forcefully stops the CLI server without graceful cleanup. * diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 590912066..514719685 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -50,6 +50,7 @@ import type { UserInputResponse, } from "./types.js"; +/** @internal */ export const NO_RESULT_PERMISSION_V2_ERROR = "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."; @@ -1045,19 +1046,6 @@ export class CopilotSession { this.autoModeSwitchHandler = undefined; } - /** - * @deprecated Use {@link disconnect} instead. This method will be removed in a future release. - * - * Disconnects this session and releases all in-memory resources. - * Session data on disk is preserved for later resumption. - * - * @returns A promise that resolves when the session is disconnected - * @throws Error if the connection fails - */ - async destroy(): Promise { - return this.disconnect(); - } - /** Enables `await using session = ...` syntax for automatic cleanup. */ async [Symbol.asyncDispose](): Promise { return this.disconnect(); diff --git a/nodejs/tsconfig.json b/nodejs/tsconfig.json index 55828124d..4ec4c2121 100644 --- a/nodejs/tsconfig.json +++ b/nodejs/tsconfig.json @@ -9,6 +9,7 @@ "declarationMap": false, "emitDeclarationOnly": true, "strict": true, + "stripInternal": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, From 659936e4c3587761495d13332618de986601e03a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:02:58 +0100 Subject: [PATCH 12/27] Phase L: fix githubToken typos in scenario fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-one test/scenarios/**/typescript/src/index.ts files used 'githubToken: process.env.GITHUB_TOKEN' (lowercase h) instead of the correct 'gitHubToken'. They silently passed an unrecognized property and the runtime ignored the token. Fix in lock-step across all affected scenarios. The existing 'typecheck' npm script already runs 'tsc --noEmit -p tsconfig.test.json' in CI, so no further CI wiring is needed to prevent regressions: this typo would now be a compile error under the SDK's strict CopilotClientOptions shape. Other Phase L items (missing onPermissionRequest, invalid permission kinds, resumeSession scenarios without config) were either already caught by the runtime PR or do not apply to TS — no remaining work. Covers review §1.1, §2.1, §2.8, §2.11. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/scenarios/auth/gh-app/typescript/src/index.ts | 2 +- test/scenarios/bundling/fully-bundled/typescript/src/index.ts | 2 +- test/scenarios/callbacks/hooks/typescript/src/index.ts | 2 +- test/scenarios/callbacks/permissions/typescript/src/index.ts | 2 +- test/scenarios/callbacks/user-input/typescript/src/index.ts | 2 +- test/scenarios/modes/default/typescript/src/index.ts | 2 +- test/scenarios/modes/minimal/typescript/src/index.ts | 2 +- test/scenarios/prompts/attachments/typescript/src/index.ts | 2 +- test/scenarios/prompts/reasoning-effort/typescript/src/index.ts | 2 +- test/scenarios/prompts/system-message/typescript/src/index.ts | 2 +- .../sessions/concurrent-sessions/typescript/src/index.ts | 2 +- .../sessions/infinite-sessions/typescript/src/index.ts | 2 +- test/scenarios/sessions/session-resume/typescript/src/index.ts | 2 +- test/scenarios/sessions/streaming/typescript/src/index.ts | 2 +- test/scenarios/tools/custom-agents/typescript/src/index.ts | 2 +- test/scenarios/tools/mcp-servers/typescript/src/index.ts | 2 +- test/scenarios/tools/no-tools/typescript/src/index.ts | 2 +- test/scenarios/tools/skills/typescript/src/index.ts | 2 +- test/scenarios/tools/tool-filtering/typescript/src/index.ts | 2 +- test/scenarios/tools/tool-overrides/typescript/src/index.ts | 2 +- test/scenarios/tools/virtual-filesystem/typescript/src/index.ts | 2 +- test/scenarios/transport/stdio/typescript/src/index.ts | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/test/scenarios/auth/gh-app/typescript/src/index.ts b/test/scenarios/auth/gh-app/typescript/src/index.ts index cc1ffe544..ded81fb22 100644 --- a/test/scenarios/auth/gh-app/typescript/src/index.ts +++ b/test/scenarios/auth/gh-app/typescript/src/index.ts @@ -111,7 +111,7 @@ async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: accessToken, + gitHubToken: accessToken, }); try { diff --git a/test/scenarios/bundling/fully-bundled/typescript/src/index.ts b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts index 96b0d3175..ac9b0c57b 100644 --- a/test/scenarios/bundling/fully-bundled/typescript/src/index.ts +++ b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/callbacks/hooks/typescript/src/index.ts b/test/scenarios/callbacks/hooks/typescript/src/index.ts index f328913df..4c4f2c466 100644 --- a/test/scenarios/callbacks/hooks/typescript/src/index.ts +++ b/test/scenarios/callbacks/hooks/typescript/src/index.ts @@ -5,7 +5,7 @@ async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/callbacks/permissions/typescript/src/index.ts b/test/scenarios/callbacks/permissions/typescript/src/index.ts index d86ed2aac..ada6a5732 100644 --- a/test/scenarios/callbacks/permissions/typescript/src/index.ts +++ b/test/scenarios/callbacks/permissions/typescript/src/index.ts @@ -5,7 +5,7 @@ async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/callbacks/user-input/typescript/src/index.ts b/test/scenarios/callbacks/user-input/typescript/src/index.ts index 1f38c43a9..7c642be3e 100644 --- a/test/scenarios/callbacks/user-input/typescript/src/index.ts +++ b/test/scenarios/callbacks/user-input/typescript/src/index.ts @@ -5,7 +5,7 @@ async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/modes/default/typescript/src/index.ts b/test/scenarios/modes/default/typescript/src/index.ts index b2fa51732..255550f7f 100644 --- a/test/scenarios/modes/default/typescript/src/index.ts +++ b/test/scenarios/modes/default/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/modes/minimal/typescript/src/index.ts b/test/scenarios/modes/minimal/typescript/src/index.ts index 3365a2a40..aa5e4e440 100644 --- a/test/scenarios/modes/minimal/typescript/src/index.ts +++ b/test/scenarios/modes/minimal/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/prompts/attachments/typescript/src/index.ts b/test/scenarios/prompts/attachments/typescript/src/index.ts index 148ccd95a..d1f37c90a 100644 --- a/test/scenarios/prompts/attachments/typescript/src/index.ts +++ b/test/scenarios/prompts/attachments/typescript/src/index.ts @@ -7,7 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts index 33af00cd0..6cd8bda34 100644 --- a/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts +++ b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/prompts/system-message/typescript/src/index.ts b/test/scenarios/prompts/system-message/typescript/src/index.ts index 6c39e82e8..b3a86ccfe 100644 --- a/test/scenarios/prompts/system-message/typescript/src/index.ts +++ b/test/scenarios/prompts/system-message/typescript/src/index.ts @@ -5,7 +5,7 @@ const PIRATE_PROMPT = `You are a pirate. Always respond in pirate speak. Say 'Ar async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts index 51d14969e..a466bf42d 100644 --- a/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts +++ b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts @@ -6,7 +6,7 @@ const ROBOT_PROMPT = `You are a robot. Always say BEEP BOOP!`; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts index 1586e0a0e..ddf50f176 100644 --- a/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts +++ b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/sessions/session-resume/typescript/src/index.ts b/test/scenarios/sessions/session-resume/typescript/src/index.ts index b7169852a..12407145f 100644 --- a/test/scenarios/sessions/session-resume/typescript/src/index.ts +++ b/test/scenarios/sessions/session-resume/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/sessions/streaming/typescript/src/index.ts b/test/scenarios/sessions/streaming/typescript/src/index.ts index 88274536b..a0dabb3b7 100644 --- a/test/scenarios/sessions/streaming/typescript/src/index.ts +++ b/test/scenarios/sessions/streaming/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/tools/custom-agents/typescript/src/index.ts b/test/scenarios/tools/custom-agents/typescript/src/index.ts index 93ee40602..864db4cbe 100644 --- a/test/scenarios/tools/custom-agents/typescript/src/index.ts +++ b/test/scenarios/tools/custom-agents/typescript/src/index.ts @@ -12,7 +12,7 @@ const analyzeCodebase = defineTool("analyze-codebase", { async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/tools/mcp-servers/typescript/src/index.ts b/test/scenarios/tools/mcp-servers/typescript/src/index.ts index 35aaf94f0..e1348470d 100644 --- a/test/scenarios/tools/mcp-servers/typescript/src/index.ts +++ b/test/scenarios/tools/mcp-servers/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/tools/no-tools/typescript/src/index.ts b/test/scenarios/tools/no-tools/typescript/src/index.ts index 9e8478190..2f240e4f5 100644 --- a/test/scenarios/tools/no-tools/typescript/src/index.ts +++ b/test/scenarios/tools/no-tools/typescript/src/index.ts @@ -8,7 +8,7 @@ If asked about your capabilities or tools, clearly state that you have no tools async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/tools/skills/typescript/src/index.ts b/test/scenarios/tools/skills/typescript/src/index.ts index 20732177d..6aaa219d8 100644 --- a/test/scenarios/tools/skills/typescript/src/index.ts +++ b/test/scenarios/tools/skills/typescript/src/index.ts @@ -7,7 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/tools/tool-filtering/typescript/src/index.ts b/test/scenarios/tools/tool-filtering/typescript/src/index.ts index f40f13472..1d90ecc2b 100644 --- a/test/scenarios/tools/tool-filtering/typescript/src/index.ts +++ b/test/scenarios/tools/tool-filtering/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/tools/tool-overrides/typescript/src/index.ts b/test/scenarios/tools/tool-overrides/typescript/src/index.ts index 84a5fc983..54f46328a 100644 --- a/test/scenarios/tools/tool-overrides/typescript/src/index.ts +++ b/test/scenarios/tools/tool-overrides/typescript/src/index.ts @@ -4,7 +4,7 @@ import { z } from "zod"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts index 49c7bba52..c17463d63 100644 --- a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts +++ b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts @@ -40,7 +40,7 @@ const listFiles = defineTool("list_files", { async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { diff --git a/test/scenarios/transport/stdio/typescript/src/index.ts b/test/scenarios/transport/stdio/typescript/src/index.ts index 96b0d3175..ac9b0c57b 100644 --- a/test/scenarios/transport/stdio/typescript/src/index.ts +++ b/test/scenarios/transport/stdio/typescript/src/index.ts @@ -3,7 +3,7 @@ import { CopilotClient } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - githubToken: process.env.GITHUB_TOKEN, + gitHubToken: process.env.GITHUB_TOKEN, }); try { From 09dada7f501342cc79bfd2b78eb02355a5889198 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:07:37 +0100 Subject: [PATCH 13/27] Phase L follow-up: run prettier --write over all modified files Prettier check failed on Ubuntu CI because several files modified in earlier phases didn't get re-formatted after the bulk regex rewrites. Running 'npm run format' (prettier --write) normalizes them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 9 ++++++--- nodejs/test/client.test.ts | 12 ++++++++---- nodejs/test/e2e/commands.e2e.test.ts | 6 +++++- nodejs/test/e2e/multi-client.e2e.test.ts | 12 ++++++++++-- nodejs/test/e2e/pending_work_resume.e2e.test.ts | 5 ++++- nodejs/test/e2e/session_fs.e2e.test.ts | 7 +++++-- nodejs/test/e2e/suspend.e2e.test.ts | 5 ++++- nodejs/test/e2e/ui_elicitation.e2e.test.ts | 6 +++++- 8 files changed, 47 insertions(+), 15 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9d010abf9..d505a69b7 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -333,8 +333,8 @@ export class CopilotClient { // Resolve the connection mode. `_internalConnection` is set by // `joinSession()` to opt into the parent-process stdio path; consumers // should always go through the public `connection` field. - const conn: InternalRuntimeConnection = - options._internalConnection ?? options.connection ?? { kind: "stdio" }; + const conn: InternalRuntimeConnection = options._internalConnection ?? + options.connection ?? { kind: "stdio" }; if ( conn.kind === "uri" && @@ -2059,7 +2059,10 @@ export class CopilotClient { throw new Error(`Session not found: ${params.sessionId}`); } - const output = await session._handleHooksInvoke(params.hookType, normalizeHookInput(params.input)); + const output = await session._handleHooksInvoke( + params.hookType, + normalizeHookInput(params.input) + ); return { output }; } diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index d934bfcaa..ec54b850b 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -8,13 +8,13 @@ import { defaultJoinSessionPermissionHandler } from "../src/types.js"; describe("CopilotClient", () => { it("allows createSession without onPermissionRequest", async () => { - const client = new CopilotClient({ }); + const client = new CopilotClient({}); await expect(client.createSession({})).rejects.toThrow(/Client not connected/); }); it("allows resumeSession without onPermissionRequest", async () => { - const client = new CopilotClient({ }); + const client = new CopilotClient({}); await expect(client.resumeSession("session-1", {})).rejects.toThrow(/Client not connected/); }); @@ -752,7 +752,9 @@ describe("CopilotClient", () => { gitHubToken: "gho_test_token", logLevel: "error", }); - }).toThrow(/gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/); + }).toThrow( + /gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/ + ); }); it("should throw error when useLoggedInUser is used with forUri", () => { @@ -762,7 +764,9 @@ describe("CopilotClient", () => { useLoggedInUser: false, logLevel: "error", }); - }).toThrow(/gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/); + }).toThrow( + /gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/ + ); }); }); diff --git a/nodejs/test/e2e/commands.e2e.test.ts b/nodejs/test/e2e/commands.e2e.test.ts index b6c14c024..def944243 100644 --- a/nodejs/test/e2e/commands.e2e.test.ts +++ b/nodejs/test/e2e/commands.e2e.test.ts @@ -20,7 +20,11 @@ describe("Commands", async () => { await initSession.disconnect(); const { runtimePort } = client1 as unknown as { runtimePort: number }; - const client2 = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { connectionToken: tcpConnectionToken }) }); + const client2 = new CopilotClient({ + connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { + connectionToken: tcpConnectionToken, + }), + }); afterAll(async () => { await client2.stop(); diff --git a/nodejs/test/e2e/multi-client.e2e.test.ts b/nodejs/test/e2e/multi-client.e2e.test.ts index e5b62394b..c45595bd5 100644 --- a/nodejs/test/e2e/multi-client.e2e.test.ts +++ b/nodejs/test/e2e/multi-client.e2e.test.ts @@ -21,7 +21,11 @@ describe("Multi-client broadcast", async () => { await initSession.disconnect(); const runtimePort = (client1 as unknown as { runtimePort: number }).runtimePort; - let client2 = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { connectionToken: tcpConnectionToken }) }); + let client2 = new CopilotClient({ + connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { + connectionToken: tcpConnectionToken, + }), + }); const EVENT_TIMEOUT_MS = 30_000; afterAll(async () => { @@ -350,7 +354,11 @@ describe("Multi-client broadcast", async () => { process.removeListener("unhandledRejection", suppressDisposed); // Recreate client2 for cleanup in afterAll (but don't rejoin the session) - client2 = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { connectionToken: tcpConnectionToken }) }); + client2 = new CopilotClient({ + connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { + connectionToken: tcpConnectionToken, + }), + }); // Now only stable_tool should be available const afterResponse = await session1.sendAndWait({ diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index fe953c226..3bea1b417 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -129,7 +129,10 @@ describe("Pending work resume", async () => { const server = new CopilotClient({ cwd: workDir, env, - connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken: SHARED_TOKEN }), + connection: RuntimeConnection.forTcp({ + path: process.env.COPILOT_CLI_PATH, + connectionToken: SHARED_TOKEN, + }), }); onTestFinished(async () => { try { diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index f7440950b..6d251e70a 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -103,7 +103,8 @@ describe("Session Fs", async () => { it("should reject setProvider when sessions already exist", async () => { const tcpConnectionToken = "session-fs-test-token"; - const client = new CopilotClient({ // Use TCP so we can connect from a second client + const client = new CopilotClient({ + // Use TCP so we can connect from a second client tcpConnectionToken, env, }); @@ -117,7 +118,9 @@ describe("Session Fs", async () => { const client2 = new CopilotClient({ env, logLevel: "error", - connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken: tcpConnectionToken }), + connection: RuntimeConnection.forUri(`localhost:${port}`, { + connectionToken: tcpConnectionToken, + }), sessionFs: sessionFsConfig, }); onTestFinished(() => client2.forceStop()); diff --git a/nodejs/test/e2e/suspend.e2e.test.ts b/nodejs/test/e2e/suspend.e2e.test.ts index 2c57a7f9e..db4ab3936 100644 --- a/nodejs/test/e2e/suspend.e2e.test.ts +++ b/nodejs/test/e2e/suspend.e2e.test.ts @@ -65,7 +65,10 @@ describe("Suspend RPC", async () => { const server = new CopilotClient({ cwd: workDir, env, - connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, connectionToken: SHARED_TOKEN }), + connection: RuntimeConnection.forTcp({ + path: process.env.COPILOT_CLI_PATH, + connectionToken: SHARED_TOKEN, + }), }); onTestFinishedForceStop(server); return server; diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index 00435c8e5..a1af378c4 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -64,7 +64,11 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { await initSession.disconnect(); const { runtimePort } = client1 as unknown as { runtimePort: number }; - const client2 = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { connectionToken: tcpConnectionToken }) }); + const client2 = new CopilotClient({ + connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { + connectionToken: tcpConnectionToken, + }), + }); afterAll(async () => { await client2.stop(); From 6536140ae0ff91295cfb554ebbfdcfc246bb00cb Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:13:38 +0100 Subject: [PATCH 14/27] Fix Phase I/E/L test failures surfaced by CI - commands/multi-client/ui_elicitation: shorthand 'copilotClientOptions: { tcpConnectionToken }' wasn't matched by the earlier batch rewrite, so client1 was still spawned in stdio mode while client2 tried to connect by URI. Switch the harness call to TCP + RuntimeConnection.forTcp({ connectionToken: tcpConnectionToken }). - session_fs.e2e.test.ts: add the missing RuntimeConnection import. - hooks_extended.e2e.test.ts: SessionStart and UserPromptSubmitted timestamp assertions still used toBeGreaterThan(0) but BaseHookInput.timestamp is now Date. Switch to toBeInstanceOf(Date). - client.test.ts: delete the two obsolete 'allows *Session without onPermissionRequest' unit tests. They asserted on the 'Client not connected' error that only occurred when autoStart was false; with Phase C removing autoStart, the client now auto-starts on the first session call and those tests would need a real spawned runtime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/client.test.ts | 12 ------------ nodejs/test/e2e/commands.e2e.test.ts | 5 ++++- nodejs/test/e2e/hooks_extended.e2e.test.ts | 4 ++-- nodejs/test/e2e/multi-client.e2e.test.ts | 5 ++++- nodejs/test/e2e/session_fs.e2e.test.ts | 2 +- nodejs/test/e2e/ui_elicitation.e2e.test.ts | 5 ++++- 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index ec54b850b..49a2331a0 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -7,18 +7,6 @@ import { defaultJoinSessionPermissionHandler } from "../src/types.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead describe("CopilotClient", () => { - it("allows createSession without onPermissionRequest", async () => { - const client = new CopilotClient({}); - - await expect(client.createSession({})).rejects.toThrow(/Client not connected/); - }); - - it("allows resumeSession without onPermissionRequest", async () => { - const client = new CopilotClient({}); - - await expect(client.resumeSession("session-1", {})).rejects.toThrow(/Client not connected/); - }); - it("does not respond to v3 permission requests when handler returns no-result", async () => { const session = new CopilotSession("session-1", {} as any); session.registerPermissionHandler(() => ({ kind: "no-result" })); diff --git a/nodejs/test/e2e/commands.e2e.test.ts b/nodejs/test/e2e/commands.e2e.test.ts index def944243..dae98083c 100644 --- a/nodejs/test/e2e/commands.e2e.test.ts +++ b/nodejs/test/e2e/commands.e2e.test.ts @@ -11,7 +11,10 @@ describe("Commands", async () => { // Use TCP mode so a second client can connect to the same CLI process const tcpConnectionToken = "commands-test-token"; const ctx = await createSdkTestContext({ - copilotClientOptions: { tcpConnectionToken }, + useStdio: false, + copilotClientOptions: { + connection: RuntimeConnection.forTcp({ connectionToken: tcpConnectionToken }), + }, }); const client1 = ctx.copilotClient; diff --git a/nodejs/test/e2e/hooks_extended.e2e.test.ts b/nodejs/test/e2e/hooks_extended.e2e.test.ts index 85b51a528..2eb585994 100644 --- a/nodejs/test/e2e/hooks_extended.e2e.test.ts +++ b/nodejs/test/e2e/hooks_extended.e2e.test.ts @@ -37,7 +37,7 @@ describe("Extended session hooks", async () => { expect(sessionStartInputs.length).toBeGreaterThan(0); expect(sessionStartInputs[0].source).toBe("new"); - expect(sessionStartInputs[0].timestamp).toBeGreaterThan(0); + expect(sessionStartInputs[0].timestamp).toBeInstanceOf(Date); expect(sessionStartInputs[0].cwd).toBeDefined(); await session.disconnect(); @@ -62,7 +62,7 @@ describe("Extended session hooks", async () => { expect(userPromptInputs.length).toBeGreaterThan(0); expect(userPromptInputs[0].prompt).toContain("Say hello"); - expect(userPromptInputs[0].timestamp).toBeGreaterThan(0); + expect(userPromptInputs[0].timestamp).toBeInstanceOf(Date); expect(userPromptInputs[0].cwd).toBeDefined(); await session.disconnect(); diff --git a/nodejs/test/e2e/multi-client.e2e.test.ts b/nodejs/test/e2e/multi-client.e2e.test.ts index c45595bd5..a63b1b0eb 100644 --- a/nodejs/test/e2e/multi-client.e2e.test.ts +++ b/nodejs/test/e2e/multi-client.e2e.test.ts @@ -12,7 +12,10 @@ describe("Multi-client broadcast", async () => { // Use TCP mode so a second client can connect to the same CLI process const tcpConnectionToken = "multi-client-test-token"; const ctx = await createSdkTestContext({ - copilotClientOptions: { tcpConnectionToken }, + useStdio: false, + copilotClientOptions: { + connection: RuntimeConnection.forTcp({ connectionToken: tcpConnectionToken }), + }, }); const client1 = ctx.copilotClient; diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index 6d251e70a..db85f8b60 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient } from "../../src/client.js"; -import { createSessionFsAdapter } from "../../src/index.js"; +import { createSessionFsAdapter, RuntimeConnection } from "../../src/index.js"; import type { SessionFsReaddirWithTypesEntry } from "../../src/generated/rpc.js"; import { approveAll, diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index a1af378c4..3bc9335a2 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -55,7 +55,10 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { // Use TCP mode so a second client can connect to the same CLI process const tcpConnectionToken = "ui-elicitation-test-token"; const ctx = await createSdkTestContext({ - copilotClientOptions: { tcpConnectionToken }, + useStdio: false, + copilotClientOptions: { + connection: RuntimeConnection.forTcp({ connectionToken: tcpConnectionToken }), + }, }); const client1 = ctx.copilotClient; From 3ac7ae8367067729181eece283100e5f9f582602 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:15:10 +0100 Subject: [PATCH 15/27] Add E2E equivalents of removed createSession/resumeSession-without-permission tests The two client.test.ts unit tests deleted in the previous commit only asserted that createSession/resumeSession surface 'Client not connected' when called pre-start. The intent behind them was to confirm that omitting onPermissionRequest doesn't itself throw. With autoStart gone, the only meaningful version of that test is an E2E one that actually spawns a runtime. Port the equivalent C# coverage (ClientE2ETests.Should_Allow_*Session_Called_Without_PermissionHandler) to client.e2e.test.ts so we have parity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/client.e2e.test.ts | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts index 667477749..c6337274c 100644 --- a/nodejs/test/e2e/client.e2e.test.ts +++ b/nodejs/test/e2e/client.e2e.test.ts @@ -13,6 +13,44 @@ function onTestFinishedForceStop(client: CopilotClient) { } describe("Client", () => { + it.each([ + { transport: "stdio", connection: () => undefined }, + { transport: "tcp", connection: () => RuntimeConnection.forTcp() }, + ])("allows createSession without onPermissionRequest ($transport)", async ({ connection }) => { + const client = new CopilotClient({ connection: connection() }); + onTestFinishedForceStop(client); + + await using const session = await client.createSession({}); + expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); + }); + + it("allows resumeSession without onPermissionRequest", async () => { + const connectionToken = "client-e2e-resume-token"; + + const client = new CopilotClient({ + connection: RuntimeConnection.forTcp({ connectionToken }), + }); + onTestFinishedForceStop(client); + + await using const originalSession = await client.createSession({}); + + const port = (client as unknown as { runtimePort: number | null }).runtimePort; + if (port == null) { + throw new Error("Client must be using TCP transport to support multi-client resume."); + } + + const resumeClient = new CopilotClient({ + connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken }), + }); + onTestFinishedForceStop(resumeClient); + + await using const resumedSession = await resumeClient.resumeSession( + originalSession.sessionId, + {} + ); + expect(resumedSession.sessionId).toBe(originalSession.sessionId); + }); + it("should start and connect to server using stdio", async () => { const client = new CopilotClient(); onTestFinishedForceStop(client); From 14d0a9fd5d6e0eb3efaa25b0ea3569af2d505e4c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:15:34 +0100 Subject: [PATCH 16/27] Add E2E tests for createSession/resumeSession without onPermissionRequest Ports dotnet's Should_Allow_CreateSession_Called_Without_PermissionHandler (Theory: stdio + tcp) and Should_Allow_ResumeSession_Called_Without_PermissionHandler from dotnet/test/E2E/ClientE2ETests.cs into nodejs/test/e2e/session.e2e.test.ts. These exercise the contract that {onPermissionRequest} is optional on both SessionConfig and ResumeSessionConfig: when not provided, the runtime leaves permission prompts pending for the consumer to resolve via the low-level RPC. Without these tests, that contract was unprotected against regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/client.e2e.test.ts | 6 +-- nodejs/test/e2e/session.e2e.test.ts | 71 ++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts index c6337274c..b2021152c 100644 --- a/nodejs/test/e2e/client.e2e.test.ts +++ b/nodejs/test/e2e/client.e2e.test.ts @@ -20,7 +20,7 @@ describe("Client", () => { const client = new CopilotClient({ connection: connection() }); onTestFinishedForceStop(client); - await using const session = await client.createSession({}); + await using session = await client.createSession({}); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); }); @@ -32,7 +32,7 @@ describe("Client", () => { }); onTestFinishedForceStop(client); - await using const originalSession = await client.createSession({}); + await using originalSession = await client.createSession({}); const port = (client as unknown as { runtimePort: number | null }).runtimePort; if (port == null) { @@ -44,7 +44,7 @@ describe("Client", () => { }); onTestFinishedForceStop(resumeClient); - await using const resumedSession = await resumeClient.resumeSession( + await using resumedSession = await resumeClient.resumeSession( originalSession.sessionId, {} ); diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index 19118d3a4..deef6e339 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -1,7 +1,7 @@ import { rm } from "fs/promises"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; -import { CopilotClient, approveAll, defineTool } from "../../src/index.js"; +import { CopilotClient, approveAll, defineTool, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext, isCI } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage, getNextEventOfType } from "./harness/sdkTestHelper.js"; @@ -14,6 +14,75 @@ describe("Sessions", async () => { env, } = await createSdkTestContext(); + it.each([ + ["stdio", () => RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH })], + ["tcp", () => RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH })], + ] as const)( + "createSession works without onPermissionRequest (%s)", + async (_name, makeConnection) => { + const standaloneClient = new CopilotClient({ + cwd: workDir, + env, + connection: makeConnection(), + }); + onTestFinished(async () => { + try { + await standaloneClient.forceStop(); + } catch { + // ignore + } + }); + + const session = await standaloneClient.createSession({}); + expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); + await session.disconnect(); + } + ); + + it("resumeSession works without onPermissionRequest", async () => { + const connectionToken = "client-e2e-resume-token"; + + const tcpClient = new CopilotClient({ + cwd: workDir, + env, + connection: RuntimeConnection.forTcp({ + path: process.env.COPILOT_CLI_PATH, + connectionToken, + }), + }); + onTestFinished(async () => { + try { + await tcpClient.forceStop(); + } catch { + // ignore + } + }); + + const originalSession = await tcpClient.createSession({}); + + const port = (tcpClient as unknown as { runtimePort: number | null }).runtimePort; + if (!port) { + throw new Error("Client must be using TCP transport to support multi-client resume."); + } + + const resumeClient = new CopilotClient({ + cwd: workDir, + env, + connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken }), + }); + onTestFinished(async () => { + try { + await resumeClient.forceStop(); + } catch { + // ignore + } + }); + + const resumedSession = await resumeClient.resumeSession(originalSession.sessionId, {}); + expect(resumedSession.sessionId).toBe(originalSession.sessionId); + await resumedSession.disconnect(); + await originalSession.disconnect(); + }); it("should create and disconnect sessions", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, From d1ac2ba9511ebfa299d64e2f48e5c9854c61796d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:18:20 +0100 Subject: [PATCH 17/27] Harness: preserve caller-supplied RuntimeConnection while still injecting CLI path Earlier batch rewrite of createSdkTestContext meant that whenever a test passed copilotClientOptions.connection, the spread overrode the harness's own connection variant entirely - losing the COPILOT_CLI_PATH binding. Now merge by variant kind: if the caller asks for tcp/stdio without a path, the harness fills it in from COPILOT_CLI_PATH; explicit values from the caller win. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/harness/sdkTestContext.ts | 32 +++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index cd9c74e5e..ee7bf3b84 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -66,18 +66,40 @@ export async function createSdkTestContext({ XDG_STATE_HOME: homeDir, }; - const connection: RuntimeConnection = - useStdio === false - ? RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH }) - : RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }); + const userConn = copilotClientOptions?.connection; + let connection: RuntimeConnection; + if (userConn) { + // Caller supplied a RuntimeConnection — merge in the harness-managed + // CLI path (and stay on TCP if the caller asked for that variant). + if (userConn.kind === "tcp") { + connection = RuntimeConnection.forTcp({ + ...userConn, + path: userConn.path ?? process.env.COPILOT_CLI_PATH, + }); + } else if (userConn.kind === "stdio") { + connection = RuntimeConnection.forStdio({ + ...userConn, + path: userConn.path ?? process.env.COPILOT_CLI_PATH, + }); + } else { + connection = userConn; + } + } else { + connection = + useStdio === false + ? RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH }) + : RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }); + } + const { connection: _ignoredConnection, ...remainingClientOptions } = + copilotClientOptions ?? {}; const copilotClient = new CopilotClient({ cwd: workDir, env, logLevel: logLevel || "error", connection, gitHubToken: authTokenToUse, - ...copilotClientOptions, + ...remainingClientOptions, }); const harness = { homeDir, workDir, openAiEndpoint, copilotClient, env }; From 11df3b10420b7c42c69c8d77b654e5446318ac4f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:23:21 +0100 Subject: [PATCH 18/27] session_fs.e2e: fix unconverted tcpConnectionToken flat key on inner client In the 'should reject setProvider when sessions already exist' test, the first client was still using the flat tcpConnectionToken property which is no longer a valid CopilotClientOptions field. Switch to RuntimeConnection.forTcp({ connectionToken }). Verified locally: full session_fs.e2e.test.ts suite (9 tests) now passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/e2e/session_fs.e2e.test.ts | 2 +- ...eturn_modifiedargs_and_suppressoutput.yaml | 26 ------------------ ...ts_permission_and_both_see_the_result.yaml | 27 ------------------- 3 files changed, 1 insertion(+), 54 deletions(-) diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index db85f8b60..cba98996e 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -105,7 +105,7 @@ describe("Session Fs", async () => { const tcpConnectionToken = "session-fs-test-token"; const client = new CopilotClient({ // Use TCP so we can connect from a second client - tcpConnectionToken, + connection: RuntimeConnection.forTcp({ connectionToken: tcpConnectionToken }), env, }); onTestFinished(() => client.forceStop()); diff --git a/test/snapshots/hooks_extended/should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput.yaml b/test/snapshots/hooks_extended/should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput.yaml index 737b54756..cae46a153 100644 --- a/test/snapshots/hooks_extended/should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput.yaml +++ b/test/snapshots/hooks_extended/should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput.yaml @@ -48,29 +48,3 @@ conversations: content: modified by hook - role: assistant content: 'The echo_value returned: **"modified by hook"**' - - messages: - - role: system - content: ${system} - - role: user - content: Call echo_value with value 'original', then reply with the result. - - role: assistant - content: I'll call echo_value with 'original' for you. - tool_calls: - - id: toolcall_0 - type: function - function: - name: report_intent - arguments: '{"intent":"Calling echo_value"}' - - id: toolcall_1 - type: function - function: - name: echo_value - arguments: '{"value":"original"}' - - role: tool - tool_call_id: toolcall_0 - content: Intent logged - - role: tool - tool_call_id: toolcall_1 - content: modified by hook - - role: assistant - content: 'The echo_value returned: **"modified by hook"**' diff --git a/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml b/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml index 46b6d0ce1..ba9db87d0 100644 --- a/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml +++ b/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml @@ -23,30 +23,3 @@ conversations: function: name: view arguments: '{"path":"${workdir}/protected.txt"}' - - messages: - - role: system - content: ${system} - - role: user - content: Edit protected.txt and replace 'protected' with 'hacked'. - - role: assistant - content: I'll help you edit protected.txt to replace 'protected' with 'hacked'. Let me first view the file and then make - the change. - tool_calls: - - id: toolcall_0 - type: function - function: - name: report_intent - arguments: '{"intent":"Editing protected.txt file"}' - - id: toolcall_1 - type: function - function: - name: view - arguments: '{"path":"${workdir}/protected.txt"}' - - role: tool - tool_call_id: toolcall_0 - content: Intent logged - - role: tool - tool_call_id: toolcall_1 - content: Permission denied and could not request permission from user - - role: assistant - content: I don't have permission to view or edit protected.txt, so I can't make that change. From a8a0e0c0bedafd28fd8dd371d4e813106c55431f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:36:54 +0100 Subject: [PATCH 19/27] Move hook input deserialization next to the cast that types it normalizeHookInput lived in client.ts and inspected for a 'timestamp' property by name, which felt magical (brittle against any future hook-shaped wire payload that happens to contain a numeric 'timestamp'). Move the conversion into CopilotSession._handleHooksInvoke, renamed deserializeHookInput, right next to the GenericHandler cast that says 'this unknown is now a HookInput'. That's the only call site that actually knows the payload is a hook input, so it's the correct boundary for the schema transform. This is the TS equivalent of what C# does via UnixMillisecondsDateTimeOffsetConverter (attached per-property on each HookInput.Timestamp); TS just plumbs the same conversion through the hooks dispatcher instead of a per-type JSON converter. Verified 3/3 hooks_extended.e2e tests pass locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 22 +--------------------- nodejs/src/session.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index d505a69b7..11e40fd8a 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -87,23 +87,6 @@ function isZodSchema(value: unknown): value is { toJSONSchema(): Record), timestamp: new Date(t) }; - } - return input; -} - /** * Convert tool parameters to JSON schema format for sending to CLI */ @@ -2059,10 +2042,7 @@ export class CopilotClient { throw new Error(`Session not found: ${params.sessionId}`); } - const output = await session._handleHooksInvoke( - params.hookType, - normalizeHookInput(params.input) - ); + const output = await session._handleHooksInvoke(params.hookType, params.input); return { output }; } diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 514719685..343fb42ec 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -54,6 +54,23 @@ import type { export const NO_RESULT_PERMISSION_V2_ERROR = "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."; +/** + * Convert a raw hook input received over the wire into its public-facing shape. + * Currently this only deserializes the numeric Unix-ms `timestamp` field on + * BaseHookInput into a Date. Anything else passes through unchanged. + */ +function deserializeHookInput(raw: unknown): unknown { + if ( + !raw || + typeof raw !== "object" || + typeof (raw as { timestamp?: unknown }).timestamp !== "number" + ) { + return raw; + } + const obj = raw as Record & { timestamp: number }; + return { ...obj, timestamp: new Date(obj.timestamp) }; +} + /** Assistant message event - the final response from the assistant. */ export type AssistantMessageEvent = Extract; @@ -955,7 +972,14 @@ export class CopilotSession { return undefined; } - // Type-safe handler lookup with explicit casting + // All hook inputs share BaseHookInput, which exposes `timestamp` as a Date. + // The wire format sends it as Unix epoch ms (number), so we deserialize + // here, at the one place that knows the input is a hook payload. Bad data + // is left alone — the user-facing handler types still cast unknown to the + // specific HookInput, so a runtime type mismatch surfaces as a normal + // TypeError in user code rather than being silently masked. + const normalized = deserializeHookInput(input); + type GenericHandler = ( input: unknown, invocation: { sessionId: string } @@ -976,7 +1000,7 @@ export class CopilotSession { } try { - const result = await handler(input, { sessionId: this.sessionId }); + const result = await handler(normalized, { sessionId: this.sessionId }); return result; } catch (_error) { // Hook failed, return undefined From ae0a22870d7b3113db238381d871a4ce73f3f854 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:40:40 +0100 Subject: [PATCH 20/27] Phase K refinement: use unref() instead of destroy() in client.stop() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit destroy() on the child's stdio pipes and the TCP socket is more aggressive than needed. If the child crashes with a useful message on stderr that our existing data listener hasn't drained yet, stderr.destroy() drops it. If there's an in-flight write to stdin, destroy() raises 'error' on the stream. Same trade-off for socket.destroy() short-circuiting the graceful FIN/ACK. unref() solves the actual problem (event loop staying alive after stop()) without disrupting late output. From the Node docs: 'unref will allow the program to exit if this is the only active socket in the event system. The socket does not lose any functionality' — error events still fire, late data still drains through registered listeners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 11e40fd8a..9a6053eab 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -586,11 +586,11 @@ export class CopilotClient { if (this.socket) { try { - // .end() initiates the close, but we also .destroy() to make - // sure the underlying handle is released so the Node event - // loop can exit cleanly without an explicit process.exit(). + // .end() initiates the graceful close. unref() releases the + // event-loop handle so Node can exit while the FIN/ACK + // exchange completes in the background. this.socket.end(); - this.socket.destroy(); + this.socket.unref(); } catch (error) { errors.push( new Error( @@ -604,11 +604,15 @@ export class CopilotClient { // Kill CLI process (only if we spawned it) if (this.cliProcess && !this.isExternalServer) { try { - // Detach stdio streams so they don't keep the event loop alive - // while we wait for the child to exit. - this.cliProcess.stdin?.destroy(); - this.cliProcess.stdout?.destroy(); - this.cliProcess.stderr?.destroy(); + // unref the stdio pipes and the child process itself so they + // don't keep the event loop alive after stop() returns. unref() + // doesn't close the streams or stop our existing data listeners + // from receiving late output — it just removes the "keep the + // loop alive" handle so Node can exit naturally if nothing else + // is pending. + this.cliProcess.stdin?.unref(); + this.cliProcess.stdout?.unref(); + this.cliProcess.stderr?.unref(); this.cliProcess.kill(); this.cliProcess.unref(); } catch (error) { From 9f4eece6164a572a81640ca1d59f5d2977cac74f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:45:03 +0100 Subject: [PATCH 21/27] client.stop(): await child exit and socket close Replace the unref()-based fire-and-forget cleanup with deterministic awaiting: - Socket: end() + await 'close'. By the time stop() returns the FIN/ACK exchange has completed. - ChildProcess: kill() + await 'exit'. By the time stop() returns the child has truly exited, its stdio pipes are closed, and there are no lingering handles to keep the event loop alive. No SIGKILL escalation. If the child ignores SIGTERM, stop() blocks; callers that need a guaranteed-bounded shutdown should use forceStop() (which already sends SIGKILL). Replaces the previous unref() approach: that worked for clean exit but allowed late stderr output to surface after stop() resolved, which is exactly the timing window where consumers expect cleanup to be done. Verified locally: full client.test.ts (75 tests) passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9a6053eab..8213864fa 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -584,13 +584,17 @@ export class CopilotClient { // Clear models cache this.modelsCache = null; + // Close the TCP socket and wait for the close to complete before returning. if (this.socket) { + const socket = this.socket; + this.socket = null; try { - // .end() initiates the graceful close. unref() releases the - // event-loop handle so Node can exit while the FIN/ACK - // exchange completes in the background. - this.socket.end(); - this.socket.unref(); + if (!socket.destroyed) { + await new Promise((resolve) => { + socket.once("close", () => resolve()); + socket.end(); + }); + } } catch (error) { errors.push( new Error( @@ -598,23 +602,22 @@ export class CopilotClient { ) ); } - this.socket = null; } - // Kill CLI process (only if we spawned it) + // Send SIGTERM and await child exit. If the child ignores SIGTERM we + // intentionally block here — callers who need a guaranteed-bounded + // shutdown should reach for forceStop() instead, which sends SIGKILL. if (this.cliProcess && !this.isExternalServer) { + const child = this.cliProcess; + this.cliProcess = null; try { - // unref the stdio pipes and the child process itself so they - // don't keep the event loop alive after stop() returns. unref() - // doesn't close the streams or stop our existing data listeners - // from receiving late output — it just removes the "keep the - // loop alive" handle so Node can exit naturally if nothing else - // is pending. - this.cliProcess.stdin?.unref(); - this.cliProcess.stdout?.unref(); - this.cliProcess.stderr?.unref(); - this.cliProcess.kill(); - this.cliProcess.unref(); + if (child.exitCode === null && child.signalCode === null) { + const exited = new Promise((resolve) => { + child.once("exit", () => resolve()); + }); + child.kill(); + await exited; + } } catch (error) { errors.push( new Error( @@ -622,7 +625,6 @@ export class CopilotClient { ) ); } - this.cliProcess = null; } if (this.cliStartTimeout) { clearTimeout(this.cliStartTimeout); From 8f3dda0e851ae8936e2d5b3fce567dd47438eff9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:45:47 +0100 Subject: [PATCH 22/27] Revert incorrect feedback widening of PermissionRequestResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous '& { feedback?: string }' incorrectly added feedback to every variant of the union. In the runtime schema, feedback is reject-only — it appears only on PermissionDecisionReject and is already typed by the generated PermissionDecisionRequest['result'] union. No manual augmentation needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/types.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index b44cae41a..a56c71e38 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -873,13 +873,13 @@ import type { PermissionDecisionRequest } from "./generated/rpc.js"; /** * Permission decision result returned from a {@link PermissionHandler}. - * The discriminated `kind` field selects the decision; `feedback` is - * optional free-form text forwarded to the model along with the decision. + * The discriminated `kind` field selects the decision. Variant-specific + * fields (e.g. `feedback` on `{ kind: "reject" }`) come from the generated + * `PermissionDecisionRequest["result"]` union. */ -export type PermissionRequestResult = ( +export type PermissionRequestResult = | PermissionDecisionRequest["result"] - | { kind: "no-result" } -) & { feedback?: string }; + | { kind: "no-result" }; export type PermissionHandler = ( request: PermissionRequest, From b1b2a35dfaeb8a845a6f8c94a4edc577352fd4db Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:49:46 +0100 Subject: [PATCH 23/27] Rename client.on -> client.onLifecycle for cross-SDK consistency Matches the C# rename in PR #1357 Phase 4f (client.On -> client.OnLifecycle). client.onLifecycle is clearer than client.on at the call site because the two on() methods on CopilotClient and CopilotSession listen for completely different event families (lifecycle vs per-session). The receiver alone isn't always enough to disambiguate, especially in mixed code that holds both objects. session.on stays unchanged because that's where the bare 'on' verb belongs: session events are the primary stream for that object. Updates README + client_lifecycle.e2e.test.ts to the new name. Tests still pass (validated via tsc -p tsconfig.test.json). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 20 ++++++++++---------- nodejs/src/client.ts | 14 +++++++------- nodejs/test/e2e/client_lifecycle.e2e.test.ts | 14 +++++++------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/nodejs/README.md b/nodejs/README.md index 808530e04..eee358873 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -172,7 +172,7 @@ Request the TUI to switch to displaying the specified session. Only available in Subscribe to a specific session lifecycle event type. Returns an unsubscribe function. ```typescript -const unsubscribe = client.on("session.foreground", (event) => { +const unsubscribe = client.onLifecycle("session.foreground", (event) => { console.log(`Session ${event.sessionId} is now in foreground`); }); ``` @@ -182,7 +182,7 @@ const unsubscribe = client.on("session.foreground", (event) => { Subscribe to all session lifecycle events. Returns an unsubscribe function. ```typescript -const unsubscribe = client.on((event) => { +const unsubscribe = client.onLifecycle((event) => { console.log(`${event.type}: ${event.sessionId}`); }); ``` @@ -414,7 +414,7 @@ Note: `assistant.message` and `assistant.reasoning` (final events) are always se ### Manual Server Control ```typescript -const client = new CopilotClient({ }); +const client = new CopilotClient({}); // Start manually await client.start(); @@ -855,15 +855,15 @@ const session = await client.createSession({ The handler must return one of the `PermissionDecision` shapes (or `{ kind: "no-result" }`). Approval scopes are present-tense — they describe the decision to apply, not the outcome reported back on session events: -| Kind | Meaning | Extra fields | -| ------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| `"approve-once"` | Allow this single request | — | -| `"approve-for-session"` | Allow this request and remember the approval for the rest of the session | `approval?` (rule to remember), `domain?` (for URL approvals) | +| Kind | Meaning | Extra fields | +| ------------------------ | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `"approve-once"` | Allow this single request | — | +| `"approve-for-session"` | Allow this request and remember the approval for the rest of the session | `approval?` (rule to remember), `domain?` (for URL approvals) | | `"approve-for-location"` | Allow this request and persist the approval for this project location (git root or cwd) | `approval` (rule to persist), `locationKey` (location to persist under) | | `"approve-permanently"` | Allow this request and persist the approval across sessions (currently used for URL domains) | `domain` (URL domain to approve) | -| `"reject"` | Deny the request | `feedback?` (optional string surfaced to the agent) | -| `"user-not-available"` | Deny the request because no user is available to confirm it | — | -| `"no-result"` | Leave the request unanswered (only valid with protocol v1; rejected by protocol v2 servers) | — | +| `"reject"` | Deny the request | `feedback?` (optional string surfaced to the agent) | +| `"user-not-available"` | Deny the request because no user is available to confirm it | — | +| `"no-result"` | Leave the request unanswered (only valid with protocol v1; rejected by protocol v2 servers) | — | ### Resuming Sessions diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8213864fa..cb5da32a6 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1417,7 +1417,7 @@ export class CopilotClient { * @example * ```typescript * // Listen for when a session becomes foreground in TUI - * const unsubscribe = client.on("session.foreground", (event) => { + * const unsubscribe = client.onLifecycle("session.foreground", (event) => { * console.log(`Session ${event.sessionId} is now displayed in TUI`); * }); * @@ -1425,7 +1425,7 @@ export class CopilotClient { * unsubscribe(); * ``` */ - on( + onLifecycle( eventType: K, handler: TypedSessionLifecycleHandler ): () => void; @@ -1438,7 +1438,7 @@ export class CopilotClient { * * @example * ```typescript - * const unsubscribe = client.on((event) => { + * const unsubscribe = client.onLifecycle((event) => { * switch (event.type) { * case "session.foreground": * console.log(`Session ${event.sessionId} is now in foreground`); @@ -1453,13 +1453,13 @@ export class CopilotClient { * unsubscribe(); * ``` */ - on(handler: SessionLifecycleHandler): () => void; + onLifecycle(handler: SessionLifecycleHandler): () => void; - on( + onLifecycle( eventTypeOrHandler: K | SessionLifecycleHandler, handler?: TypedSessionLifecycleHandler ): () => void { - // Overload 1: on(eventType, handler) - typed event subscription + // Overload 1: onLifecycle(eventType, handler) - typed event subscription if (typeof eventTypeOrHandler === "string" && handler) { const eventType = eventTypeOrHandler; if (!this.typedLifecycleHandlers.has(eventType)) { @@ -1475,7 +1475,7 @@ export class CopilotClient { }; } - // Overload 2: on(handler) - wildcard subscription + // Overload 2: onLifecycle(handler) - wildcard subscription const wildcardHandler = eventTypeOrHandler as SessionLifecycleHandler; this.sessionLifecycleHandlers.add(wildcardHandler); return () => { diff --git a/nodejs/test/e2e/client_lifecycle.e2e.test.ts b/nodejs/test/e2e/client_lifecycle.e2e.test.ts index d85a67531..3ebb59a36 100644 --- a/nodejs/test/e2e/client_lifecycle.e2e.test.ts +++ b/nodejs/test/e2e/client_lifecycle.e2e.test.ts @@ -61,7 +61,7 @@ describe("Client Lifecycle", async () => { it("should emit session lifecycle events", async () => { const events: SessionLifecycleEvent[] = []; - const unsubscribe = client.on((event: SessionLifecycleEvent) => { + const unsubscribe = client.onLifecycle((event: SessionLifecycleEvent) => { events.push(event); }); @@ -93,7 +93,7 @@ describe("Client Lifecycle", async () => { it("should receive session created lifecycle event", async () => { const created = deferred(); - const unsubscribe = client.on((evt) => { + const unsubscribe = client.onLifecycle((evt) => { if (evt.type === "session.created") { created.resolve(evt); } @@ -114,7 +114,7 @@ describe("Client Lifecycle", async () => { it("should filter session lifecycle events by type", async () => { const created = deferred(); - const unsubscribe = client.on("session.created", (evt) => { + const unsubscribe = client.onLifecycle("session.created", (evt) => { created.resolve(evt); }); @@ -134,12 +134,12 @@ describe("Client Lifecycle", async () => { it("disposing lifecycle subscription stops receiving events", async () => { let count = 0; const created = deferred(); - const unsubscribeFirst = client.on(() => { + const unsubscribeFirst = client.onLifecycle(() => { count += 1; }); unsubscribeFirst(); - const unsubscribeActive = client.on("session.created", (evt) => { + const unsubscribeActive = client.onLifecycle("session.created", (evt) => { created.resolve(evt); }); @@ -160,7 +160,7 @@ describe("Client Lifecycle", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); const updated = deferred(); - const unsubscribe = client.on("session.updated", (evt) => { + const unsubscribe = client.onLifecycle("session.updated", (evt) => { if (evt.sessionId === session.sessionId) { updated.resolve(evt); } @@ -187,7 +187,7 @@ describe("Client Lifecycle", async () => { expect(message?.data.content).toContain("SESSION_DELETED_OK"); const deleted = deferred(); - const unsubscribe = client.on("session.deleted", (evt) => { + const unsubscribe = client.onLifecycle("session.deleted", (evt) => { if (evt.sessionId === session.sessionId) { deleted.resolve(evt); } From bc0d2aa69634fecf44d6b5fe7ceb180779f3216d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 12:54:04 +0100 Subject: [PATCH 24/27] Reformat src/types.ts after PermissionRequestResult revert Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/types.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index a56c71e38..e0c58490a 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -877,9 +877,7 @@ import type { PermissionDecisionRequest } from "./generated/rpc.js"; * fields (e.g. `feedback` on `{ kind: "reject" }`) come from the generated * `PermissionDecisionRequest["result"]` union. */ -export type PermissionRequestResult = - | PermissionDecisionRequest["result"] - | { kind: "no-result" }; +export type PermissionRequestResult = PermissionDecisionRequest["result"] | { kind: "no-result" }; export type PermissionHandler = ( request: PermissionRequest, From 10f07069bf48df16eb54c6e3debf264e0c6c22ad Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 13:07:50 +0100 Subject: [PATCH 25/27] Address PR review comments from #1357 - Add missing RuntimeConnection imports to 26 test/scenarios TypeScript fixtures. The earlier batch rewrite added the .forStdio/.forUri call sites but missed the corresponding import statement, so each scenario failed to compile until now. - nodejs/test/e2e/harness/sdkTestContext.ts: strip the 'kind' property before spreading a user-supplied RuntimeConnection back through the forStdio/forTcp factory opts. The factory opt types don't accept 'kind' so the spread was producing excess-property type errors. - nodejs/src/client.ts: rewrite the 'Path to Copilot CLI is required' error message to point at the new connection options (RuntimeConnection.forStdio({ path }), forTcp({ path }), forUri(...), or the COPILOT_CLI_PATH environment variable). The old message referenced removed cliPath / cliUrl options. - nodejs/src/client.ts: change the default logLevel from 'debug' to 'info'. 'debug' was a TS-only outlier; Python and Rust default to 'info', and the README has always claimed 'info'. Go and .NET don't pass --log-level at all when omitted (CLI defaults to info anyway), so 'info' is consistent with every other SDK's effective default. - nodejs/src/types.ts: fix MCPServerConfigBase.tools doc comment to spell the all-tools sentinel as ['*'] (the actual type is string[], so a bare '*' string can't be passed). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 8 ++++++-- nodejs/src/types.ts | 2 +- nodejs/test/e2e/harness/sdkTestContext.ts | 14 +++++++++----- .../auth/byok-anthropic/typescript/src/index.ts | 2 +- .../auth/byok-azure/typescript/src/index.ts | 2 +- .../auth/byok-ollama/typescript/src/index.ts | 2 +- .../auth/byok-openai/typescript/src/index.ts | 2 +- test/scenarios/auth/gh-app/typescript/src/index.ts | 2 +- .../bundling/fully-bundled/typescript/src/index.ts | 2 +- .../callbacks/hooks/typescript/src/index.ts | 2 +- .../callbacks/permissions/typescript/src/index.ts | 2 +- .../callbacks/user-input/typescript/src/index.ts | 2 +- .../modes/default/typescript/src/index.ts | 2 +- .../modes/minimal/typescript/src/index.ts | 2 +- .../prompts/attachments/typescript/src/index.ts | 2 +- .../reasoning-effort/typescript/src/index.ts | 2 +- .../prompts/system-message/typescript/src/index.ts | 2 +- .../concurrent-sessions/typescript/src/index.ts | 2 +- .../infinite-sessions/typescript/src/index.ts | 2 +- .../session-resume/typescript/src/index.ts | 2 +- .../sessions/streaming/typescript/src/index.ts | 2 +- .../tools/custom-agents/typescript/src/index.ts | 2 +- .../tools/mcp-servers/typescript/src/index.ts | 2 +- .../tools/no-tools/typescript/src/index.ts | 2 +- .../scenarios/tools/skills/typescript/src/index.ts | 2 +- .../tools/tool-filtering/typescript/src/index.ts | 2 +- .../tools/tool-overrides/typescript/src/index.ts | 2 +- .../virtual-filesystem/typescript/src/index.ts | 2 +- .../transport/stdio/typescript/src/index.ts | 2 +- 29 files changed, 42 insertions(+), 34 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index cb5da32a6..3287aa7da 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -375,7 +375,7 @@ export class CopilotClient { this.options = { cwd: options.cwd ?? process.cwd(), - logLevel: options.logLevel || "debug", + logLevel: options.logLevel || "info", gitHubToken: options.gitHubToken, // Default useLoggedInUser to false when gitHubToken is provided, otherwise true. useLoggedInUser: options.useLoggedInUser ?? (options.gitHubToken ? false : true), @@ -1550,7 +1550,11 @@ export class CopilotClient { if (!this.resolvedCliPath) { throw new Error( - "Path to Copilot CLI is required. Please provide it via the cliPath option, or use cliUrl to rely on a remote CLI." + "Path to Copilot CLI is required. Please supply it via " + + "`RuntimeConnection.forStdio({ path })` or " + + "`RuntimeConnection.forTcp({ path })`, set the COPILOT_CLI_PATH " + + "environment variable, or use `RuntimeConnection.forUri(...)` to " + + "connect to an already-running runtime." ); } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e0c58490a..481f9aedf 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1211,7 +1211,7 @@ export interface SessionHooks { interface MCPServerConfigBase { /** * List of tools to include from this server. - * `undefined` (the default) or `"*"` means include all tools. + * `undefined` (the default) or `["*"]` means include all tools. * `[]` means include none. */ tools?: string[]; diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index ee7bf3b84..17737d5a3 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -70,16 +70,20 @@ export async function createSdkTestContext({ let connection: RuntimeConnection; if (userConn) { // Caller supplied a RuntimeConnection — merge in the harness-managed - // CLI path (and stay on TCP if the caller asked for that variant). + // CLI path (and stay on the same transport variant). Strip `kind` + // before forwarding to the factory opts since the factories don't + // accept it in their argument shape. if (userConn.kind === "tcp") { + const { kind: _k, ...tcp } = userConn; connection = RuntimeConnection.forTcp({ - ...userConn, - path: userConn.path ?? process.env.COPILOT_CLI_PATH, + ...tcp, + path: tcp.path ?? process.env.COPILOT_CLI_PATH, }); } else if (userConn.kind === "stdio") { + const { kind: _k, ...stdio } = userConn; connection = RuntimeConnection.forStdio({ - ...userConn, - path: userConn.path ?? process.env.COPILOT_CLI_PATH, + ...stdio, + path: stdio.path ?? process.env.COPILOT_CLI_PATH, }); } else { connection = userConn; diff --git a/test/scenarios/auth/byok-anthropic/typescript/src/index.ts b/test/scenarios/auth/byok-anthropic/typescript/src/index.ts index 7eaaaeed4..bb60158c2 100644 --- a/test/scenarios/auth/byok-anthropic/typescript/src/index.ts +++ b/test/scenarios/auth/byok-anthropic/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const apiKey = process.env.ANTHROPIC_API_KEY; diff --git a/test/scenarios/auth/byok-azure/typescript/src/index.ts b/test/scenarios/auth/byok-azure/typescript/src/index.ts index 6179aabe4..14d4e5ced 100644 --- a/test/scenarios/auth/byok-azure/typescript/src/index.ts +++ b/test/scenarios/auth/byok-azure/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const endpoint = process.env.AZURE_OPENAI_ENDPOINT; diff --git a/test/scenarios/auth/byok-ollama/typescript/src/index.ts b/test/scenarios/auth/byok-ollama/typescript/src/index.ts index eccb325d9..7db9dd81c 100644 --- a/test/scenarios/auth/byok-ollama/typescript/src/index.ts +++ b/test/scenarios/auth/byok-ollama/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1"; const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? "llama3.2:3b"; diff --git a/test/scenarios/auth/byok-openai/typescript/src/index.ts b/test/scenarios/auth/byok-openai/typescript/src/index.ts index f9c564d0e..1b69fc665 100644 --- a/test/scenarios/auth/byok-openai/typescript/src/index.ts +++ b/test/scenarios/auth/byok-openai/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "claude-haiku-4.5"; diff --git a/test/scenarios/auth/gh-app/typescript/src/index.ts b/test/scenarios/auth/gh-app/typescript/src/index.ts index ded81fb22..bfd53898c 100644 --- a/test/scenarios/auth/gh-app/typescript/src/index.ts +++ b/test/scenarios/auth/gh-app/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; import readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; diff --git a/test/scenarios/bundling/fully-bundled/typescript/src/index.ts b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts index ac9b0c57b..c80c1b074 100644 --- a/test/scenarios/bundling/fully-bundled/typescript/src/index.ts +++ b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ diff --git a/test/scenarios/callbacks/hooks/typescript/src/index.ts b/test/scenarios/callbacks/hooks/typescript/src/index.ts index 4c4f2c466..1c92c6eec 100644 --- a/test/scenarios/callbacks/hooks/typescript/src/index.ts +++ b/test/scenarios/callbacks/hooks/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const hookLog: string[] = []; diff --git a/test/scenarios/callbacks/permissions/typescript/src/index.ts b/test/scenarios/callbacks/permissions/typescript/src/index.ts index ada6a5732..a9668d0b5 100644 --- a/test/scenarios/callbacks/permissions/typescript/src/index.ts +++ b/test/scenarios/callbacks/permissions/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const permissionLog: string[] = []; diff --git a/test/scenarios/callbacks/user-input/typescript/src/index.ts b/test/scenarios/callbacks/user-input/typescript/src/index.ts index 7c642be3e..7980c3adf 100644 --- a/test/scenarios/callbacks/user-input/typescript/src/index.ts +++ b/test/scenarios/callbacks/user-input/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const inputLog: string[] = []; diff --git a/test/scenarios/modes/default/typescript/src/index.ts b/test/scenarios/modes/default/typescript/src/index.ts index 255550f7f..72ae28960 100644 --- a/test/scenarios/modes/default/typescript/src/index.ts +++ b/test/scenarios/modes/default/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ diff --git a/test/scenarios/modes/minimal/typescript/src/index.ts b/test/scenarios/modes/minimal/typescript/src/index.ts index aa5e4e440..894e31798 100644 --- a/test/scenarios/modes/minimal/typescript/src/index.ts +++ b/test/scenarios/modes/minimal/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ diff --git a/test/scenarios/prompts/attachments/typescript/src/index.ts b/test/scenarios/prompts/attachments/typescript/src/index.ts index d1f37c90a..4448c1dad 100644 --- a/test/scenarios/prompts/attachments/typescript/src/index.ts +++ b/test/scenarios/prompts/attachments/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; import path from "path"; import { fileURLToPath } from "url"; diff --git a/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts index 6cd8bda34..c6d2917d8 100644 --- a/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts +++ b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ diff --git a/test/scenarios/prompts/system-message/typescript/src/index.ts b/test/scenarios/prompts/system-message/typescript/src/index.ts index b3a86ccfe..a0bb44ac8 100644 --- a/test/scenarios/prompts/system-message/typescript/src/index.ts +++ b/test/scenarios/prompts/system-message/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; const PIRATE_PROMPT = `You are a pirate. Always respond in pirate speak. Say 'Arrr!' in every response. Use nautical terms and pirate slang throughout.`; diff --git a/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts index a466bf42d..81f671e91 100644 --- a/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts +++ b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; const PIRATE_PROMPT = `You are a pirate. Always say Arrr!`; const ROBOT_PROMPT = `You are a robot. Always say BEEP BOOP!`; diff --git a/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts index ddf50f176..e2a8c5fdb 100644 --- a/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts +++ b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ diff --git a/test/scenarios/sessions/session-resume/typescript/src/index.ts b/test/scenarios/sessions/session-resume/typescript/src/index.ts index 12407145f..c9ba3b3d5 100644 --- a/test/scenarios/sessions/session-resume/typescript/src/index.ts +++ b/test/scenarios/sessions/session-resume/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ diff --git a/test/scenarios/sessions/streaming/typescript/src/index.ts b/test/scenarios/sessions/streaming/typescript/src/index.ts index a0dabb3b7..9cd530ebb 100644 --- a/test/scenarios/sessions/streaming/typescript/src/index.ts +++ b/test/scenarios/sessions/streaming/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ diff --git a/test/scenarios/tools/custom-agents/typescript/src/index.ts b/test/scenarios/tools/custom-agents/typescript/src/index.ts index 864db4cbe..db6dff214 100644 --- a/test/scenarios/tools/custom-agents/typescript/src/index.ts +++ b/test/scenarios/tools/custom-agents/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient, defineTool } from "@github/copilot-sdk"; +import { CopilotClient, defineTool , RuntimeConnection } from "@github/copilot-sdk"; import { z } from "zod"; const analyzeCodebase = defineTool("analyze-codebase", { diff --git a/test/scenarios/tools/mcp-servers/typescript/src/index.ts b/test/scenarios/tools/mcp-servers/typescript/src/index.ts index e1348470d..5117d3a64 100644 --- a/test/scenarios/tools/mcp-servers/typescript/src/index.ts +++ b/test/scenarios/tools/mcp-servers/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ diff --git a/test/scenarios/tools/no-tools/typescript/src/index.ts b/test/scenarios/tools/no-tools/typescript/src/index.ts index 2f240e4f5..743aafe54 100644 --- a/test/scenarios/tools/no-tools/typescript/src/index.ts +++ b/test/scenarios/tools/no-tools/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; const SYSTEM_PROMPT = `You are a minimal assistant with no tools available. You cannot execute code, read files, edit files, search, or perform any actions. diff --git a/test/scenarios/tools/skills/typescript/src/index.ts b/test/scenarios/tools/skills/typescript/src/index.ts index 6aaa219d8..740adc587 100644 --- a/test/scenarios/tools/skills/typescript/src/index.ts +++ b/test/scenarios/tools/skills/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; import path from "path"; import { fileURLToPath } from "url"; diff --git a/test/scenarios/tools/tool-filtering/typescript/src/index.ts b/test/scenarios/tools/tool-filtering/typescript/src/index.ts index 1d90ecc2b..87a86062e 100644 --- a/test/scenarios/tools/tool-filtering/typescript/src/index.ts +++ b/test/scenarios/tools/tool-filtering/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ diff --git a/test/scenarios/tools/tool-overrides/typescript/src/index.ts b/test/scenarios/tools/tool-overrides/typescript/src/index.ts index 54f46328a..fe6ff874f 100644 --- a/test/scenarios/tools/tool-overrides/typescript/src/index.ts +++ b/test/scenarios/tools/tool-overrides/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient, defineTool, approveAll } from "@github/copilot-sdk"; +import { CopilotClient, defineTool, approveAll , RuntimeConnection } from "@github/copilot-sdk"; import { z } from "zod"; async function main() { diff --git a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts index c17463d63..3fa21db00 100644 --- a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts +++ b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient, defineTool } from "@github/copilot-sdk"; +import { CopilotClient, defineTool , RuntimeConnection } from "@github/copilot-sdk"; import { z } from "zod"; // In-memory virtual filesystem diff --git a/test/scenarios/transport/stdio/typescript/src/index.ts b/test/scenarios/transport/stdio/typescript/src/index.ts index ac9b0c57b..c80c1b074 100644 --- a/test/scenarios/transport/stdio/typescript/src/index.ts +++ b/test/scenarios/transport/stdio/typescript/src/index.ts @@ -1,4 +1,4 @@ -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient , RuntimeConnection } from "@github/copilot-sdk"; async function main() { const client = new CopilotClient({ From a48a63d2efc5d4076e75f2b784e1833d11bbe66a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 13:10:37 +0100 Subject: [PATCH 26/27] logLevel: don't impose any default, match C#/Go Instead of defaulting to 'info' on the SDK side, omit the --log-level flag entirely when the caller didn't set one and let the runtime use its own default. Matches dotnet/Client.cs and go/client.go, which both only pass --log-level when explicitly provided. CopilotClientOptions.logLevel JSDoc and README updated to describe this ('When omitted, the runtime uses its own default'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 2 +- nodejs/src/client.ts | 16 +++++++--------- nodejs/src/types.ts | 3 ++- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/nodejs/README.md b/nodejs/README.md index eee358873..06d88c752 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -85,7 +85,7 @@ new CopilotClient(options?: CopilotClientOptions) - `RuntimeConnection.forUri(url, { connectionToken? })` — connect to an already-running runtime (mutually exclusive with `gitHubToken`/`useLoggedInUser`). - `cwd?: string` - Working directory for the runtime process (default: current process cwd). - `baseDirectory?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When not set, the runtime defaults to `~/.copilot`. Ignored when connecting via `RuntimeConnection.forUri`. -- `logLevel?: string` - Log level (default: "info"). +- `logLevel?: string` - Log level. When omitted, the runtime uses its own default (currently `"info"`). - `gitHubToken?: string` - GitHub token for authentication. When provided, takes priority over other auth methods. - `useLoggedInUser?: boolean` - Whether to use logged-in user for authentication (default: true, but false when `gitHubToken` is provided). Cannot be used with `RuntimeConnection.forUri`. - `telemetry?: TelemetryConfig` - OpenTelemetry configuration for the runtime process. Providing this object enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 3287aa7da..8511a40e1 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -229,7 +229,7 @@ export class CopilotClient { private resolvedEnv: Record; private options: { cwd: string; - logLevel: string; + logLevel?: string; gitHubToken?: string; useLoggedInUser: boolean; telemetry?: TelemetryConfig; @@ -375,7 +375,7 @@ export class CopilotClient { this.options = { cwd: options.cwd ?? process.cwd(), - logLevel: options.logLevel || "info", + logLevel: options.logLevel, gitHubToken: options.gitHubToken, // Default useLoggedInUser to false when gitHubToken is provided, otherwise true. useLoggedInUser: options.useLoggedInUser ?? (options.gitHubToken ? false : true), @@ -1491,13 +1491,11 @@ export class CopilotClient { // Clear stderr buffer for fresh capture this.stderrBuffer = ""; - const args = [ - ...this.connectionExtraArgs, - "--headless", - "--no-auto-update", - "--log-level", - this.options.logLevel, - ]; + const args = [...this.connectionExtraArgs, "--headless", "--no-auto-update"]; + + if (this.options.logLevel) { + args.push("--log-level", this.options.logLevel); + } // Choose transport mode based on the resolved connection config. if (this.connectionConfig.kind === "stdio") { diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 481f9aedf..9fd2eae3b 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -187,7 +187,8 @@ export interface CopilotClientOptions { baseDirectory?: string; /** - * Log level for the Copilot runtime. + * Log level for the Copilot runtime. When omitted, the runtime uses its + * own default (currently `"info"`). */ logLevel?: "none" | "error" | "warning" | "info" | "debug" | "all"; From eae19b18084ebc4a0549ffe4f74ad5bf412001cf Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 21 May 2026 13:29:34 +0100 Subject: [PATCH 27/27] Rename CopilotClientOptions.remote -> enableRemoteSessions for cross-SDK consistency Matches the C# API review rename in #1343 (EnableRemoteSessions on CopilotClientOptions). The wire-level RPC field stays 'remote' since that is the runtime's contract; only the SDK surface changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 6 +++--- nodejs/src/types.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8511a40e1..991f23fa1 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -235,7 +235,7 @@ export class CopilotClient { telemetry?: TelemetryConfig; baseDirectory?: string; sessionIdleTimeoutSeconds: number; - remote: boolean; + enableRemoteSessions: boolean; }; private isExternalServer: boolean = false; private forceStopping: boolean = false; @@ -382,7 +382,7 @@ export class CopilotClient { telemetry: options.telemetry, baseDirectory: options.baseDirectory, sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0, - remote: options.remote ?? false, + enableRemoteSessions: options.enableRemoteSessions ?? false, }; } @@ -1525,7 +1525,7 @@ export class CopilotClient { ); } - if (this.options.remote) { + if (this.options.enableRemoteSessions) { args.push("--remote"); } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9fd2eae3b..ebf701685 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -277,7 +277,7 @@ export interface CopilotClientOptions { * Ignored when connecting to an existing runtime via {@link RuntimeConnection.forUri}. * @default false */ - remote?: boolean; + enableRemoteSessions?: boolean; /** * @internal Hook used by `joinSession()` to construct a client that talks