diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 8880cadfa..647345e0e 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -31,7 +31,7 @@ permissions: jobs: test: - name: "Node.js SDK Tests" + name: "Node.js SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})" if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off @@ -39,6 +39,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + transport: ["default", "inprocess"] runs-on: ${{ matrix.os }} defaults: run: @@ -75,6 +76,11 @@ jobs: if: runner.os == 'Windows' run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Select inprocess transport + if: matrix.transport == 'inprocess' + run: | + echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV" + - name: Run Node.js SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} diff --git a/dotnet/src/FfiRuntimeHost.cs b/dotnet/src/FfiRuntimeHost.cs index 210819de6..ee838ad27 100644 --- a/dotnet/src/FfiRuntimeHost.cs +++ b/dotnet/src/FfiRuntimeHost.cs @@ -167,6 +167,9 @@ private static byte[] BuildArgvJson(string cliEntrypoint) } writer.WriteStringValue(cliEntrypoint); writer.WriteStringValue("--embedded-host"); + // Pin the worker to the bundled pkg matching the loaded cdylib, instead of + // drifting to a newer version under the user's ~/.copilot/pkg (ABI skew). + writer.WriteStringValue("--no-auto-update"); writer.WriteEndArray(); } return stream.ToArray(); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 6ac192830..351b6e8c7 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@github/copilot": "^1.0.70-0", + "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -921,6 +922,230 @@ "dev": true, "license": "MIT" }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.0.tgz", + "integrity": "sha512-VEt5r3fXTfbejr83PnuOP0H7s9Zmazcs+lofu96DOcRkistlMsn59wYyWiKpyAjs9PCgm0Ykh62ChZ3CGMmIOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.0.tgz", + "integrity": "sha512-n/tVRB9xIzdXT5H3zZt8ueThgWTSDL+yU7PWnU8wbZPBSawP/otx3swQyd6nMOqj1bmHgSHopiKSBXRS9pllmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.0.tgz", + "integrity": "sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.0.tgz", + "integrity": "sha512-Vm7Uc97ru6RTSVmae2zCZZQeaizqVZ8WoU4+gG4H03Qe+WOj7kbKt/MxT7VBzdbPYIU5ZJeG/ZED1YlZyab6eQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.0.tgz", + "integrity": "sha512-N+VuVWjoiYPy1Go5mRadZ3B6RM5Qz+eCLhj2LXrMlefbUJ+O4gg7teCUGvPGfBEHDgmSN4yYUrfQmdJC10vOYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.0.tgz", + "integrity": "sha512-Wx5iOkeALe2ympLdiYwRpIg5qUkyQIv8N2foZ9rRker0uE7ZtXew2RRkbEgMir4b0yDYR1zyXd6B62GUzLtZ/g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.0.tgz", + "integrity": "sha512-1DjYm1QehXU0dgn0uE+FGYOb3Of7GiTMqLS+ZI2gbl1b+h76sz4LRBvDVrQyAmSMVVU8/7696S21YgE/iBhBVg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.0.tgz", + "integrity": "sha512-NOa0LdyltdESz3oeTqUH6MErHVoJOHoeXIsEp6xIMTUh4eKXEtlDQeoK6EYqo0DnBt83Xud95qLvi4Aw12pG4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.0.tgz", + "integrity": "sha512-Ye6kiXZCGxGtAIXSly6XuOP5tJZNYOZ2eVg33k1MilKrzimAy9Mpw4d6e9+Sfsc1jesgeNYs1sb5iaI8HS3ncA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.0.tgz", + "integrity": "sha512-3yQTOkQrMna4VX+yeyfYImBjLlGrItMpsWyfaW1uSiz/A6GRydqdwYH7DWnp4Z+RSGYZpsewkf7byMc8pOOQKA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.0.tgz", + "integrity": "sha512-/cDoFHb9yx4+yoT3GUpnKnfi3W2drG+/Ewo0TTZaQHb4PsxnYYyT6V8+t4cL5XXbQcTTcOsZxpmBRrn0NBa3dA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.0.tgz", + "integrity": "sha512-CoQdqgnKvWgTXXZlUst8cBRQEov7QsxlTN2WAsu9wez01Xe6gEcH/zYePANualzzCbnaELfe5P0rA80QkoDuPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.0.tgz", + "integrity": "sha512-WjrA+DEkpy0xEHu48+NSOboHhTnzkIfsFuq3d/WrSs+T9WflWRng3jC7mdJxmR4eHb6i6BqjW3k/U0mNUTjFPA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.0.tgz", + "integrity": "sha512-tnK5+IkzQBauQAQSzuyjso8OOIQRlaTZS39xIWpfqVYDLVDIuLDQk/WwHcOrR5yxlDrZq9ygiebBTOfcJFia7w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2592,6 +2817,32 @@ "json-buffer": "3.0.1" } }, + "node_modules/koffi": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.0.tgz", + "integrity": "sha512-0mCvdjTJBXioiaKNz0vajAEdWtfM5qyhVXSq+wQrrU3odzNvl/J7Cqna79QpNo9mfoKpQgGsyFFDRtDACCwGrQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.0", + "@koromix/koffi-darwin-x64": "3.1.0", + "@koromix/koffi-freebsd-arm64": "3.1.0", + "@koromix/koffi-freebsd-ia32": "3.1.0", + "@koromix/koffi-freebsd-x64": "3.1.0", + "@koromix/koffi-linux-arm64": "3.1.0", + "@koromix/koffi-linux-ia32": "3.1.0", + "@koromix/koffi-linux-loong64": "3.1.0", + "@koromix/koffi-linux-riscv64": "3.1.0", + "@koromix/koffi-linux-x64": "3.1.0", + "@koromix/koffi-openbsd-ia32": "3.1.0", + "@koromix/koffi-openbsd-x64": "3.1.0", + "@koromix/koffi-win32-ia32": "3.1.0", + "@koromix/koffi-win32-x64": "3.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index f9df3320c..d622c2bc0 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -57,6 +57,7 @@ "license": "MIT", "dependencies": { "@github/copilot": "^1.0.70-0", + "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8f4d3ff2d..93ed850b3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -40,6 +40,7 @@ import type { } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; +import type { FfiRuntimeHost } from "./ffiRuntimeHost.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; import { createCopilotRequestAdapter } from "./copilotRequestHandler.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; @@ -58,6 +59,7 @@ import type { BearerTokenProvider, GetStatusResponse, InternalRuntimeConnection, + RuntimeConnection, LargeToolOutputConfig, MCPServerConfig, ModelInfo, @@ -473,6 +475,7 @@ class TeardownResilientStreamMessageWriter extends StreamMessageWriter { export class CopilotClient { private cliStartTimeout: ReturnType | null = null; private cliProcess: ChildProcess | null = null; + private ffiHost: FfiRuntimeHost | null = null; private connection: MessageConnection | null = null; private messageWriter: TeardownResilientStreamMessageWriter | null = null; private socket: Socket | null = null; @@ -563,6 +566,32 @@ export class CopilotClient { } } + /** + * Environment variable that overrides the transport when the caller does not set + * {@link CopilotClientOptions.connection}. Accepts `"inprocess"` or `"stdio"` + * (case-insensitive); unset preserves the default stdio transport. Any other value + * is an error. + */ + private static readonly DEFAULT_CONNECTION_ENV_VAR = "COPILOT_SDK_DEFAULT_CONNECTION"; + + /** + * Resolves the default {@link RuntimeConnection} for the no-connection case, + * honoring {@link CopilotClient.DEFAULT_CONNECTION_ENV_VAR}. + */ + private static resolveDefaultConnection(): RuntimeConnection { + const value = process.env[CopilotClient.DEFAULT_CONNECTION_ENV_VAR]; + if (!value || value.toLowerCase() === "stdio") { + return { kind: "stdio" }; + } + if (value.toLowerCase() === "inprocess") { + return { kind: "inprocess" }; + } + throw new Error( + `Invalid ${CopilotClient.DEFAULT_CONNECTION_ENV_VAR} value '${value}'. ` + + `Expected 'inprocess', 'stdio', or unset.` + ); + } + /** * Creates a new CopilotClient instance. * @@ -594,8 +623,10 @@ 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 ?? + CopilotClient.resolveDefaultConnection(); if ( conn.kind === "uri" && @@ -605,6 +636,14 @@ export class CopilotClient { "gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri (external server manages its own auth)" ); } + if (conn.kind === "inprocess" && options.workingDirectory !== undefined) { + throw new Error( + "workingDirectory is not supported with RuntimeConnection.forInProcess(): the in-process " + + "transport hosts the runtime in this process, so honoring it would require mutating the " + + "shared process-global cwd. Change the host process's working directory before " + + "constructing the client instead." + ); + } 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"); @@ -810,7 +849,9 @@ export class CopilotClient { try { // Only start CLI server process if not connecting to external server - if (!this.isExternalServer) { + if (this.connectionConfig.kind === "inprocess") { + await this.startInProcessFfi(); + } else if (!this.isExternalServer) { await this.startCLIServer(); } @@ -873,6 +914,20 @@ export class CopilotClient { // Disconnect all active sessions with retry logic const activeSessions = [...this.sessions.values()]; + // TEMPORARY: over the in-process (FFI) transport the runtime shares this + // process, so a turn still running when the runtime disposes the session + // can leave that session's SQLite session.db handle open — it isn't + // reclaimed by terminating a child process, so the file stays locked + // (Windows) and the session-state directory can't be removed. Abort any + // in-flight turn first so it cancels and releases the handle. Best-effort + // and idempotent: a session with no active turn is a no-op. Scoped to + // in-process only: stdio/tcp runtimes run in a child process that we kill + // on shutdown (which frees the handle), and for external servers we don't + // own the runtime and aborting would cancel pending work other clients + // may still resume. Remove once the runtime cleans up fully on shutdown. + if (this.connectionConfig.kind === "inprocess") { + await Promise.allSettled(activeSessions.map((session) => session.abort())); + } for (const session of activeSessions) { const sessionId = session.sessionId; let lastError: Error | null = null; @@ -910,7 +965,7 @@ export class CopilotClient { // Ask SDK-owned runtimes to flush and clean up before we tear down // their transport/process. External runtimes may be shared, so only // close our connection to them. - if (this.connection && this.cliProcess && !this.isExternalServer) { + if (this.connection && (this.cliProcess || this.ffiHost) && !this.isExternalServer) { const runtimeShutdownStart = Date.now(); const shutdownPromise = this.rpc.runtime.shutdown(); void shutdownPromise.catch(() => undefined); @@ -1011,6 +1066,21 @@ export class CopilotClient { ); } } + // Tear down the in-process FFI host (closes the native connection and + // shuts down the native runtime host) for SDK-owned in-process runtimes. + if (this.ffiHost) { + const host = this.ffiHost; + this.ffiHost = null; + try { + host.dispose(); + } catch (error) { + errors.push( + new Error( + `Failed to dispose in-process runtime host: ${error instanceof Error ? error.message : String(error)}` + ) + ); + } + } if (this.cliStartTimeout) { clearTimeout(this.cliStartTimeout); this.cliStartTimeout = null; @@ -1113,6 +1183,16 @@ export class CopilotClient { this.cliProcess = null; } + // Tear down the in-process FFI host (if any). + if (this.ffiHost) { + try { + this.ffiHost.dispose(); + } catch { + // Ignore errors during force stop + } + this.ffiHost = null; + } + if (this.cliStartTimeout) { clearTimeout(this.cliStartTimeout); this.cliStartTimeout = null; @@ -2197,6 +2277,47 @@ export class CopilotClient { }; } + /** + * Builds the environment for the spawned runtime child process (stdio/TCP): applies + * the auth token, connection token, `COPILOT_HOME`, keychain setting, and telemetry + * variables on top of the effective env. Not used by the in-process (FFI) transport, + * whose worker inherits the host process's ambient environment + * (see {@link CopilotClient.startInProcessFfi}). + */ + private buildRuntimeEnv(): Record { + const env: Record = { ...this.resolvedEnv }; + delete env.NODE_DEBUG; + + if (this.options.gitHubToken) { + env.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken; + } + if (this.effectiveConnectionToken) { + env.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken; + } + if (this.options.baseDirectory) { + env.COPILOT_HOME = this.options.baseDirectory; + } + // In empty mode, disable the system keychain. Keytar reads from a + // process-wide store that's shared across sessions, which is unsafe + // for multi-tenant hosts. The runtime falls back to file-based + // credential storage scoped to COPILOT_HOME. + if (this.options.mode === "empty") { + env.COPILOT_DISABLE_KEYTAR = "1"; + } + if (this.options.telemetry) { + const t = this.options.telemetry; + env.COPILOT_OTEL_ENABLED = "true"; + if (t.otlpEndpoint !== undefined) env.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; + if (t.otlpProtocol !== undefined) env.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; + if (t.filePath !== undefined) env.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; + if (t.exporterType !== undefined) env.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType; + if (t.sourceName !== undefined) env.COPILOT_OTEL_SOURCE_NAME = t.sourceName; + if (t.captureContent !== undefined) + env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = String(t.captureContent); + } + return env; + } + /** * Start the CLI server process */ @@ -2243,30 +2364,9 @@ export class CopilotClient { args.push("--remote"); } - // Suppress debug/trace output that might pollute stdout - const envWithoutNodeDebug = { ...this.resolvedEnv }; - delete envWithoutNodeDebug.NODE_DEBUG; - - // Set auth token in environment if provided - if (this.options.gitHubToken) { - envWithoutNodeDebug.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken; - } - - if (this.effectiveConnectionToken) { - envWithoutNodeDebug.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken; - } - - if (this.options.baseDirectory) { - envWithoutNodeDebug.COPILOT_HOME = this.options.baseDirectory; - } - - // In empty mode, disable the system keychain. Keytar reads from a - // process-wide store that's shared across sessions, which is unsafe - // for multi-tenant hosts. The runtime falls back to file-based - // credential storage scoped to COPILOT_HOME. - if (this.options.mode === "empty") { - envWithoutNodeDebug.COPILOT_DISABLE_KEYTAR = "1"; - } + // Suppress debug/trace output that might pollute stdout, and apply the + // shared runtime env (auth token, connection token, COPILOT_HOME, telemetry). + const envWithoutNodeDebug = this.buildRuntimeEnv(); if (!this.resolvedCliPath) { throw new Error( @@ -2278,26 +2378,6 @@ export class CopilotClient { ); } - // Set OpenTelemetry environment variables if telemetry is configured - if (this.options.telemetry) { - const t = this.options.telemetry; - envWithoutNodeDebug.COPILOT_OTEL_ENABLED = "true"; - if (t.otlpEndpoint !== undefined) - envWithoutNodeDebug.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; - if (t.otlpProtocol !== undefined) - envWithoutNodeDebug.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; - if (t.filePath !== undefined) - envWithoutNodeDebug.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; - if (t.exporterType !== undefined) - envWithoutNodeDebug.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType; - if (t.sourceName !== undefined) - envWithoutNodeDebug.COPILOT_OTEL_SOURCE_NAME = t.sourceName; - if (t.captureContent !== undefined) - envWithoutNodeDebug.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = String( - t.captureContent - ); - } - // Verify CLI exists before attempting to spawn if (!existsSync(this.resolvedCliPath)) { throw new Error( @@ -2434,12 +2514,77 @@ export class CopilotClient { return this.connectToParentProcessViaStdio(); case "stdio": return this.connectToChildProcessViaStdio(); + case "inprocess": + return this.connectViaFfi(); case "tcp": case "uri": return this.connectViaTcp(); } } + /** + * Start the in-process FFI runtime host: resolve the CLI entrypoint and native + * runtime library, then let the native host spawn the CLI worker. + * + * The worker inherits this host process's ambient environment; per-client options + * that lower to environment variables (`env`, `telemetry`, `gitHubToken`, + * `baseDirectory`) are intentionally not applied here, because the native runtime + * loads into the shared host process and a single env block cannot carry per-client + * values. Configure the in-process runtime via the host process environment instead. + * See https://github.com/github/copilot-sdk/issues/1934. + */ + private async startInProcessFfi(): Promise { + const entrypoint = this.resolveCliPathForFfi(); + // Load the FFI host lazily so the native `koffi` addon (and its + // platform-specific `koffi.node`) is only loaded on the in-process path; + // out-of-process (stdio/tcp) consumers never touch the native dependency. + // The transpiled output is per-file (not bundled), so this resolves the + // sibling module at runtime in both the ESM and CJS builds. + const { FfiRuntimeHost } = await import("./ffiRuntimeHost.js"); + const host = FfiRuntimeHost.create(entrypoint, CopilotClient.getNapiPrebuildsFolder()); + this.ffiHost = host; + await host.start(); + } + + /** + * Connect to the in-process FFI runtime host over its receive/send streams, + * reusing the same `vscode-jsonrpc` framing as the stdio transport. + */ + private async connectViaFfi(): Promise { + if (!this.ffiHost) { + throw new Error("In-process FFI runtime host not started"); + } + this.messageWriter = new TeardownResilientStreamMessageWriter(this.ffiHost.sendStream); + this.connection = createMessageConnection( + new StreamMessageReader(this.ffiHost.receiveStream), + this.messageWriter + ); + + this.attachConnectionHandlers(); + this.connection.listen(); + } + + /** + * Resolves the CLI entrypoint used for in-process FFI hosting: `COPILOT_CLI_PATH` + * when set, otherwise the bundled platform-package entrypoint. + */ + private resolveCliPathForFfi(): string { + return this.resolvedEnv.COPILOT_CLI_PATH ?? getBundledCliPath(); + } + + /** + * Returns the napi prebuilds folder name for the current host — the + * `-` convention (e.g. `win32-x64`, `darwin-arm64`, + * `linux-x64`) under which the runtime ships `prebuilds//runtime.node`. + */ + private static getNapiPrebuildsFolder(): string { + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") { + throw new Error(`Unsupported architecture '${arch}' for in-process FFI hosting.`); + } + return `${process.platform}-${arch}`; + } + /** * Connect to child via stdio pipes */ @@ -2622,8 +2767,9 @@ export class CopilotClient { } const session = this.sessions.get((notification as { sessionId: string }).sessionId); + const event = (notification as { event: SessionEvent }).event; if (session) { - session._dispatchEvent((notification as { event: SessionEvent }).event); + session._dispatchEvent(event); } } diff --git a/nodejs/src/ffiRuntimeHost.ts b/nodejs/src/ffiRuntimeHost.ts new file mode 100644 index 000000000..05c8c7c72 --- /dev/null +++ b/nodejs/src/ffiRuntimeHost.ts @@ -0,0 +1,338 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Hosts the Copilot runtime in-process by loading the native `runtime.node` cdylib + * and speaking JSON-RPC over its C ABI (FFI) instead of spawning a CLI child process + * and communicating over stdio/TCP. + * + * The native `host_start` export spawns the CLI worker itself + * (`node --embedded-host` for a `.js` entrypoint, or ` + * --embedded-host` for a packaged binary), so the SDK never launches the worker + * directly. LSP `Content-Length:`-framed JSON-RPC bytes are pumped across the ABI: + * writes go to `connection_write`; inbound frames arrive on a native callback that + * feeds {@link FfiRuntimeHost.receiveStream}. The existing `vscode-jsonrpc` + * `StreamMessageReader`/`StreamMessageWriter` handle framing unchanged — this is a + * transport swap, not a new protocol. + */ + +import { existsSync } from "node:fs"; +import koffi from "koffi"; +import { dirname, join, resolve } from "node:path"; +import { PassThrough, Writable } from "node:stream"; + +const SYMBOL_PREFIX = "copilot_runtime_"; + +// A long, referenced no-op timer keeps the Node event loop alive while the in-process +// connection is open (see start()); the exact interval is irrelevant. +const KEEP_ALIVE_INTERVAL_MS = 1 << 30; + +type KoffiFunction = ReturnType["func"]>; +type KoffiType = ReturnType; +type KoffiRegisteredCallback = ReturnType; + +interface FfiLibrary { + hostStart: KoffiFunction; + hostShutdown: KoffiFunction; + connectionOpen: KoffiFunction; + connectionWrite: KoffiFunction; + connectionClose: KoffiFunction; + outboundCallbackType: KoffiType; +} + +let loadedLibraryPath: string | undefined; +let loadedLibrary: FfiLibrary | undefined; + +/** + * Loads the cdylib once per process and binds the C ABI exports. Loading a + * different library path in the same process is unsupported. + */ +function loadLibrary(libraryPath: string): FfiLibrary { + if (loadedLibrary) { + if (loadedLibraryPath !== libraryPath) { + throw new Error( + `An in-process FFI runtime library is already loaded from '${loadedLibraryPath}'; ` + + `loading a different library from '${libraryPath}' in the same process is not supported.` + ); + } + return loadedLibrary; + } + + const lib = koffi.load(libraryPath); + const outboundCallbackType = koffi.pointer( + koffi.proto( + `void ${SYMBOL_PREFIX}outbound(void *userData, uint8 *bytesPtr, size_t bytesLen)` + ) + ); + + loadedLibrary = { + hostStart: lib.func(`${SYMBOL_PREFIX}host_start`, "uint32", [ + "uint8*", + "size_t", + "uint8*", + "size_t", + ]), + hostShutdown: lib.func(`${SYMBOL_PREFIX}host_shutdown`, "bool", ["uint32"]), + connectionOpen: lib.func(`${SYMBOL_PREFIX}connection_open`, "uint32", [ + "uint32", + outboundCallbackType, + "void*", + "uint8*", + "size_t", + "uint8*", + "size_t", + "uint8*", + "size_t", + ]), + connectionWrite: lib.func(`${SYMBOL_PREFIX}connection_write`, "bool", [ + "uint32", + "uint8*", + "size_t", + ]), + connectionClose: lib.func(`${SYMBOL_PREFIX}connection_close`, "bool", ["uint32"]), + outboundCallbackType, + }; + loadedLibraryPath = libraryPath; + return loadedLibrary; +} + +function buildArgvJson(cliEntrypoint: string): Buffer { + // A `.js` entrypoint is launched via node; the packaged single-file CLI binary + // embeds its own Node and is invoked directly. `--no-auto-update` pins the worker + // to the bundled pkg matching the loaded cdylib, instead of drifting to a newer + // version installed under the user's `~/.copilot/pkg` (which would cause ABI skew). + const argv = cliEntrypoint.toLowerCase().endsWith(".js") + ? ["node", cliEntrypoint, "--embedded-host", "--no-auto-update"] + : [cliEntrypoint, "--embedded-host", "--no-auto-update"]; + return Buffer.from(JSON.stringify(argv), "utf8"); +} + +function buildEnvJson(environment?: Record): Buffer | null { + if (!environment) { + return null; + } + const obj: Record = {}; + for (const [key, value] of Object.entries(environment)) { + if (value !== undefined) { + obj[key] = value; + } + } + if (Object.keys(obj).length === 0) { + return null; + } + return Buffer.from(JSON.stringify(obj), "utf8"); +} + +export class FfiRuntimeHost { + private readonly lib: FfiLibrary; + private serverId = 0; + private connectionId = 0; + private disposed = false; + private outboundCallback: KoffiRegisteredCallback | undefined; + private keepAliveTimer: ReturnType | undefined; + + /** The stream JSON-RPC reads server→client frames from. */ + readonly receiveStream: PassThrough; + /** The stream JSON-RPC writes client→server frames to. */ + readonly sendStream: Writable; + + private constructor( + private readonly libraryPath: string, + private readonly cliEntrypoint: string, + private readonly environment?: Record + ) { + this.lib = loadLibrary(libraryPath); + this.receiveStream = new PassThrough(); + this.sendStream = new Writable({ + // connection_write enqueues the frame into the runtime's inbound channel and + // returns immediately, so a synchronous FFI call is sufficient here. + write: (chunk: Buffer, _encoding, callback) => { + try { + this.writeFrame(chunk); + callback(); + } catch (error) { + callback(error as Error); + } + }, + }); + } + + /** + * Resolves the cdylib next to the given CLI entrypoint and prepares the FFI host. + * The cdylib is resolved as `prebuilds//runtime.node` relative to + * the entrypoint directory (the napi-rs `-` layout, e.g. + * `linux-x64`). Throws if it cannot be found. + */ + static create( + cliEntrypoint: string, + prebuildsFolder: string, + environment?: Record + ): FfiRuntimeHost { + const fullEntrypoint = resolve(cliEntrypoint); + const distDir = dirname(fullEntrypoint); + const libraryPath = join(distDir, "prebuilds", prebuildsFolder, "runtime.node"); + if (!existsSync(libraryPath)) { + throw new Error(`FFI runtime library not found. Looked for '${libraryPath}'.`); + } + return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment); + } + + /** + * Starts the in-process runtime: spawns the CLI worker via the native host, + * waits for readiness, and opens the FFI JSON-RPC connection. + */ + async start(): Promise { + const argvJson = buildArgvJson(this.cliEntrypoint); + const envJson = buildEnvJson(this.environment); + + // The native host spawns the CLI worker itself and has no cwd parameter, so the + // worker inherits this process's cwd. A custom working directory is intentionally + // unsupported for the in-process transport (rejected by the client constructor) + // rather than mutating the shared process-global cwd here. + + // host_start blocks until the worker connects back and signals readiness + // (up to ~30s); run it as an async FFI call so the Node event loop isn't blocked. + this.serverId = await new Promise((resolvePromise, rejectPromise) => { + this.lib.hostStart.async( + argvJson, + argvJson.length, + envJson, + envJson ? envJson.length : 0, + (error: Error | null, result: number) => { + if (error) { + rejectPromise(error); + } else { + resolvePromise(result); + } + } + ); + }); + if (!this.serverId) { + throw new Error( + `copilot_runtime_host_start failed (library '${this.libraryPath}', entrypoint '${this.cliEntrypoint}').` + ); + } + + this.outboundCallback = koffi.register( + (_userData: unknown, bytesPtr: unknown, bytesLen: number | bigint) => + this.feedInbound(bytesPtr, bytesLen), + this.lib.outboundCallbackType + ); + + this.connectionId = this.lib.connectionOpen( + this.serverId, + this.outboundCallback, + null, + null, + 0, + null, + 0, + null, + 0 + ); + if (!this.connectionId) { + this.unregisterCallback(); + this.lib.hostShutdown(this.serverId); + this.serverId = 0; + throw new Error("copilot_runtime_connection_open failed."); + } + + // The in-process transport has no socket/pipe handle to keep the Node event loop + // alive while the SDK is idle awaiting a server→client frame. koffi delivers the + // outbound callback on the loop but does not reference it, so hold one referenced + // timer for the lifetime of the connection. + this.keepAliveTimer = setInterval(() => {}, KEEP_ALIVE_INTERVAL_MS); + } + + private writeFrame(frame: Buffer): void { + if (this.disposed || !this.connectionId) { + throw new Error("The in-process runtime connection is closed."); + } + const ok = this.lib.connectionWrite(this.connectionId, frame, frame.length); + if (!ok) { + throw new Error("Failed to write a frame to the in-process runtime connection."); + } + } + + /** + * Native outbound (server→client) callback. koffi delivers it on the JS event loop + * via a threadsafe function, so the frame is decoded and written straight to + * {@link receiveStream}. The native pointer is only valid for this call, so the + * bytes are copied out before returning. + */ + private feedInbound(bytesPtr: unknown, bytesLen: number | bigint): void { + // An exception thrown across the native→JS (Node-API) boundary cannot propagate + // and would surface only as a DEP0168 "uncaught Node-API callback exception" + // warning, so catch and log it here instead of letting it escape. + try { + // A native outbound callback can still be delivered on the event loop after + // dispose() has ended receiveStream; writing then would throw + // ERR_STREAM_WRITE_AFTER_END. Drop late frames instead — the connection is + // gone and nothing is reading them. + if (this.disposed || this.receiveStream.writableEnded) { + return; + } + const length = Number(bytesLen); + if (!bytesPtr || length <= 0) { + return; + } + const bytes = koffi.decode( + bytesPtr, + koffi.array("uint8", length, "Typed") + ) as Uint8Array; + this.receiveStream.write(Buffer.from(bytes)); + } catch (error) { + console.error( + `In-process FFI inbound callback failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` + ); + } + } + + private unregisterCallback(): void { + if (this.outboundCallback === undefined) { + return; + } + const callback = this.outboundCallback; + this.outboundCallback = undefined; + try { + koffi.unregister(callback); + } catch { + // Ignore teardown failures. + } + } + + /** Closes the FFI connection, shuts down the native host, and releases resources. */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + + if (this.keepAliveTimer !== undefined) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = undefined; + } + + try { + if (this.connectionId) { + this.lib.connectionClose(this.connectionId); + this.connectionId = 0; + } + } catch { + // Ignore teardown failures. + } + + try { + if (this.serverId) { + this.lib.hostShutdown(this.serverId); + this.serverId = 0; + } + } catch { + // Ignore teardown failures. + } + + this.receiveStream.end(); + this.unregisterCallback(); + } +} diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index e05b33c15..cf65cb653 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -59,6 +59,7 @@ export type { CopilotClientMode, CopilotClientOptions, StdioRuntimeConnection, + InProcessRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection, CustomAgentConfig, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 65fc00cfe..768d0856d 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -96,6 +96,7 @@ export interface TelemetryConfig { */ export type RuntimeConnection = | StdioRuntimeConnection + | InProcessRuntimeConnection | TcpRuntimeConnection | UriRuntimeConnection; @@ -111,6 +112,26 @@ export interface StdioRuntimeConnection { readonly args?: readonly string[]; } +/** + * Hosts the runtime in-process by loading the native runtime library and speaking + * JSON-RPC over its C ABI (FFI), instead of spawning a runtime child process. The + * native host spawns the CLI worker itself. Construct via + * {@link RuntimeConnection.forInProcess}. + * + * @experimental The in-process (FFI) transport is experimental and its behavior may + * change. Per-client options that are lowered to environment variables — including + * {@link CopilotClientOptions.env}, {@link CopilotClientOptions.telemetry}, + * {@link CopilotClientOptions.gitHubToken}, and + * {@link CopilotClientOptions.baseDirectory} — are **not** honored with this + * transport, because the native runtime loads into the shared host process and its + * worker inherits that process's ambient environment. To configure the in-process + * runtime, set the corresponding environment variables on the host process before + * constructing the client. See https://github.com/github/copilot-sdk/issues/1934. + */ +export interface InProcessRuntimeConnection { + readonly kind: "inprocess"; +} + /** * Spawns a runtime child process that listens on a TCP socket and connects to it. */ @@ -183,6 +204,18 @@ export const RuntimeConnection = { forUri(url: string, opts: { connectionToken?: string } = {}): UriRuntimeConnection { return { kind: "uri", url, connectionToken: opts.connectionToken }; }, + /** + * Host the runtime in-process over the native runtime library's C ABI (FFI). + * + * @experimental Per-client options lowered to environment variables (`env`, + * `telemetry`, `gitHubToken`, `baseDirectory`) are **not** honored in-process; + * the worker inherits the host process's ambient environment. Set the + * corresponding environment variables on the host process instead. See + * https://github.com/github/copilot-sdk/issues/1934. + */ + forInProcess(): InProcessRuntimeConnection { + return { kind: "inprocess" }; + }, } as const; /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 96c32a595..afca975a0 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { EventEmitter } from "node:events"; -import { describe, expect, it, onTestFinished, vi } from "vitest"; import { PassThrough } from "stream"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { approveAll, CopilotClient, @@ -15,6 +15,10 @@ import { defaultJoinSessionPermissionHandler } from "../src/types.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead +async function stopClient(client: CopilotClient): Promise { + await client.stop(); +} + describe("CopilotClient", () => { it("disposes the stdio connection when child stdin emits an error", async () => { const client = new CopilotClient(); @@ -128,7 +132,7 @@ describe("CopilotClient", () => { it("registers interest in MCP OAuth required events after create when an auth handler is configured", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -156,7 +160,7 @@ describe("CopilotClient", () => { it("does not register MCP OAuth interest without an auth handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -183,7 +187,7 @@ describe("CopilotClient", () => { it("registers MCP OAuth interest after cloud create only when an auth handler is configured", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); let cloudCreateCount = 0; const spy = vi @@ -224,7 +228,7 @@ describe("CopilotClient", () => { it("registers MCP OAuth interest after resuming only when an auth handler is configured", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -278,7 +282,7 @@ describe("CopilotClient", () => { it("forwards canvas declarations and request flags in session.create", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const canvas = createCanvas({ id: "counter", @@ -323,7 +327,7 @@ describe("CopilotClient", () => { it("forwards canvas declarations in session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const canvas = createCanvas({ @@ -361,7 +365,7 @@ describe("CopilotClient", () => { it("forwards reasoningSummary in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -393,7 +397,7 @@ describe("CopilotClient", () => { it("forwards contextTier in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -425,7 +429,7 @@ describe("CopilotClient", () => { it("forwards new session options in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -465,7 +469,7 @@ describe("CopilotClient", () => { it("opts into GitHub telemetry forwarding when onGitHubTelemetry is provided", async () => { const client = new CopilotClient({ onGitHubTelemetry: () => {} }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -490,7 +494,7 @@ describe("CopilotClient", () => { it("opts into GitHub telemetry forwarding on the connect handshake when a handler is provided", async () => { const client = new CopilotClient({ onGitHubTelemetry: () => {} }); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const sendRequest = vi.fn(async (method: string) => { if (method === "connect") return { ok: true, protocolVersion: 3, version: "test" }; @@ -507,7 +511,7 @@ describe("CopilotClient", () => { it("does not opt into GitHub telemetry forwarding on the connect handshake without a handler", async () => { const client = new CopilotClient(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const sendRequest = vi.fn(async (method: string) => { if (method === "connect") return { ok: true, protocolVersion: 3, version: "test" }; @@ -525,7 +529,7 @@ describe("CopilotClient", () => { it("does not opt into GitHub telemetry forwarding without a handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -604,7 +608,7 @@ describe("CopilotClient", () => { it("registers no gitHubTelemetry handler when onGitHubTelemetry is omitted", () => { const client = new CopilotClient(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const handlers = (client as any).clientGlobalHandlers; expect(handlers.gitHubTelemetry).toBeUndefined(); @@ -613,7 +617,7 @@ describe("CopilotClient", () => { it("forwards gitHubTelemetry events to the onGitHubTelemetry handler", () => { const received: GitHubTelemetryNotification[] = []; const client = new CopilotClient({ onGitHubTelemetry: (n) => received.push(n) }); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const handlers = (client as any).clientGlobalHandlers; expect(handlers.gitHubTelemetry).toBeDefined(); @@ -630,7 +634,7 @@ describe("CopilotClient", () => { it("forwards expAssignments in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -667,7 +671,7 @@ describe("CopilotClient", () => { it("omits expAssignments from session.create and session.resume when unset", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -693,7 +697,7 @@ describe("CopilotClient", () => { it("forwards capi options in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -725,7 +729,7 @@ describe("CopilotClient", () => { it("forwards pluginDirectories and largeOutput in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -966,7 +970,7 @@ describe("CopilotClient", () => { it("forwards clientName in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ clientName: "my-app", onPermissionRequest: approveAll }); @@ -980,7 +984,7 @@ describe("CopilotClient", () => { it("forwards cloud options in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1005,7 +1009,7 @@ describe("CopilotClient", () => { it("forwards clientName in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); // Mock sendRequest to capture the call without hitting the runtime @@ -1030,7 +1034,7 @@ describe("CopilotClient", () => { it("forwards enableSessionTelemetry in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -1047,7 +1051,7 @@ describe("CopilotClient", () => { it("forwards enableSessionTelemetry in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1071,7 +1075,7 @@ describe("CopilotClient", () => { it("forwards enableOnDemandInstructionDiscovery in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -1088,7 +1092,7 @@ describe("CopilotClient", () => { it("forwards enableOnDemandInstructionDiscovery in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1115,7 +1119,7 @@ describe("CopilotClient", () => { it("defaults includeSubAgentStreamingEvents to true in session.create when not specified", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -1127,7 +1131,7 @@ describe("CopilotClient", () => { it("forwards explicit false for includeSubAgentStreamingEvents in session.create", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -1142,7 +1146,7 @@ describe("CopilotClient", () => { it("defaults includeSubAgentStreamingEvents to true in session.resume when not specified", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1161,7 +1165,7 @@ describe("CopilotClient", () => { it("forwards explicit false for includeSubAgentStreamingEvents in session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1183,7 +1187,7 @@ describe("CopilotClient", () => { it("defaults mcpOAuthTokenStorage to 'in-memory' in session.create when mode is empty", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1201,7 +1205,7 @@ describe("CopilotClient", () => { it("does not send mcpOAuthTokenStorage in session.create when mode is copilot-cli", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -1213,7 +1217,7 @@ describe("CopilotClient", () => { it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.create", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1235,7 +1239,7 @@ describe("CopilotClient", () => { it("defaults mcpOAuthTokenStorage to 'in-memory' in session.resume when mode is empty", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1255,7 +1259,7 @@ describe("CopilotClient", () => { it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.resume", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1279,7 +1283,7 @@ describe("CopilotClient", () => { it("defaults memory to { enabled: false } in session.create when mode is empty", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1297,7 +1301,7 @@ describe("CopilotClient", () => { it("does not send memory in session.create when mode is copilot-cli", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -1309,7 +1313,7 @@ describe("CopilotClient", () => { it("forwards explicit memory config in session.create even in empty mode", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1331,7 +1335,7 @@ describe("CopilotClient", () => { it("defaults memory to { enabled: false } in session.resume when mode is empty", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1351,7 +1355,7 @@ describe("CopilotClient", () => { it("does not send memory in session.resume when mode is copilot-cli", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1370,7 +1374,7 @@ describe("CopilotClient", () => { it("forwards continuePendingWork in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1392,7 +1396,7 @@ describe("CopilotClient", () => { it("omits continuePendingWork from session.resume payload when not specified", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1411,7 +1415,7 @@ describe("CopilotClient", () => { it("forwards memory configuration in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1434,7 +1438,7 @@ describe("CopilotClient", () => { it("forwards memory configuration in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1456,7 +1460,7 @@ describe("CopilotClient", () => { it("omits memory from session.create payload when not specified", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1477,7 +1481,7 @@ describe("CopilotClient", () => { it("forwards provider headers in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1518,7 +1522,7 @@ describe("CopilotClient", () => { it("forwards provider headers in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1559,7 +1563,7 @@ describe("CopilotClient", () => { it("forwards defaultAgent in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -1578,7 +1582,7 @@ describe("CopilotClient", () => { it("forwards defaultAgent in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -1598,7 +1602,7 @@ describe("CopilotClient", () => { it("forwards instructionDirectories in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const instructionDirectories = ["C:\\extra-instructions", "C:\\more-instructions"]; const spy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -1616,7 +1620,7 @@ describe("CopilotClient", () => { it("forwards instructionDirectories in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const instructionDirectories = ["C:\\resume-instructions"]; @@ -1644,7 +1648,7 @@ describe("CopilotClient", () => { it("does not request permissions on session.resume when using the default joinSession handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1671,7 +1675,7 @@ describe("CopilotClient", () => { it("requests permissions on session.resume when using an explicit handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1698,7 +1702,7 @@ describe("CopilotClient", () => { it("forwards mode callback request flags in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1728,7 +1732,7 @@ describe("CopilotClient", () => { it("sends session.model.switchTo RPC with correct params", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -1754,7 +1758,7 @@ describe("CopilotClient", () => { it("sends reasoning options with session.model.switchTo when provided", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -2002,7 +2006,7 @@ describe("CopilotClient", () => { it("sends overridesBuiltInTool in tool definition on session.create", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2026,7 +2030,7 @@ describe("CopilotClient", () => { it("sends overridesBuiltInTool in tool definition on session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); // Mock sendRequest to capture the call without hitting the runtime @@ -2060,7 +2064,7 @@ describe("CopilotClient", () => { it("sends defer in tool definition on session.create", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2084,7 +2088,7 @@ describe("CopilotClient", () => { it("sends defer in tool definition on session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2117,7 +2121,7 @@ describe("CopilotClient", () => { it("forwards agent in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2139,7 +2143,7 @@ describe("CopilotClient", () => { it("forwards custom agent model in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2162,7 +2166,7 @@ describe("CopilotClient", () => { it("forwards agent in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2220,7 +2224,7 @@ describe("CopilotClient", () => { const handler = vi.fn().mockReturnValue(customModels); const client = new CopilotClient({ onListModels: handler }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const models = await client.listModels(); expect(handler).toHaveBeenCalledTimes(1); @@ -2243,7 +2247,7 @@ describe("CopilotClient", () => { const handler = vi.fn().mockReturnValue(customModels); const client = new CopilotClient({ onListModels: handler }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); await client.listModels(); await client.listModels(); @@ -2265,7 +2269,7 @@ describe("CopilotClient", () => { const handler = vi.fn().mockResolvedValue(customModels); const client = new CopilotClient({ onListModels: handler }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const models = await client.listModels(); expect(models).toEqual(customModels); @@ -2293,22 +2297,29 @@ describe("CopilotClient", () => { }); describe("unexpected disconnection", () => { - it("transitions to disconnected when child process is killed", async () => { - const client = new CopilotClient(); - await client.start(); - onTestFinished(() => client.forceStop()); - - expect((client as any).state).toBe("connected"); - - // Kill the child process to simulate unexpected termination - const proc = (client as any).cliProcess as import("node:child_process").ChildProcess; - proc.kill(); - - // Wait for the connection.onClose handler to fire - await vi.waitFor(() => { - expect((client as any).state).toBe("disconnected"); - }); - }); + // No child process exists over the in-process (FFI) transport, so this + // child-process-kill scenario does not apply there. Covered by the default + // (stdio) cell. + it.skipIf((process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess")( + "transitions to disconnected when child process is killed", + async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + expect((client as any).state).toBe("connected"); + + // Kill the child process to simulate unexpected termination + const proc = (client as any) + .cliProcess as import("node:child_process").ChildProcess; + proc.kill(); + + // Wait for the connection.onClose handler to fire + await vi.waitFor(() => { + expect((client as any).state).toBe("disconnected"); + }); + } + ); }); describe("onGetTraceContext", () => { @@ -2320,7 +2331,7 @@ describe("CopilotClient", () => { const provider = vi.fn().mockReturnValue(traceContext); const client = new CopilotClient({ onGetTraceContext: provider }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -2342,7 +2353,7 @@ describe("CopilotClient", () => { const provider = vi.fn().mockReturnValue(traceContext); const client = new CopilotClient({ onGetTraceContext: provider }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2368,7 +2379,7 @@ describe("CopilotClient", () => { const provider = vi.fn().mockReturnValue(traceContext); const client = new CopilotClient({ onGetTraceContext: provider }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2390,7 +2401,7 @@ describe("CopilotClient", () => { it("forwards requestHeaders in session.send request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2417,7 +2428,7 @@ describe("CopilotClient", () => { it("does not include trace context when no callback is provided", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -2432,7 +2443,7 @@ describe("CopilotClient", () => { it("forwards commands in session.create RPC", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2453,7 +2464,7 @@ describe("CopilotClient", () => { it("forwards commands in session.resume RPC", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2475,7 +2486,7 @@ describe("CopilotClient", () => { it("routes command.execute event to the correct handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const handler = vi.fn(); const session = await client.createSession({ @@ -2529,7 +2540,7 @@ describe("CopilotClient", () => { it("sends error when command handler throws", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll, @@ -2577,7 +2588,7 @@ describe("CopilotClient", () => { it("sends error for unknown command", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll, @@ -2623,7 +2634,7 @@ describe("CopilotClient", () => { it("reads capabilities from session.create response", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); // Intercept session.create to inject capabilities const origSendRequest = (client as any).connection!.sendRequest.bind( @@ -2649,7 +2660,7 @@ describe("CopilotClient", () => { it("defaults capabilities when not injected", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); // CLI returns actual capabilities (elicitation false in headless mode) @@ -2659,7 +2670,7 @@ describe("CopilotClient", () => { it("elicitation throws when capability is missing", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -2678,7 +2689,7 @@ describe("CopilotClient", () => { it("sends requestElicitation flag when onElicitationRequest is provided", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -2704,7 +2715,7 @@ describe("CopilotClient", () => { it("does not send requestElicitation when no handler provided", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -2726,7 +2737,7 @@ describe("CopilotClient", () => { it("sends mode callback request flags based on handler presence", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -2761,7 +2772,7 @@ describe("CopilotClient", () => { it("dispatches mode callback requests to registered handlers", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll, @@ -2809,7 +2820,7 @@ describe("CopilotClient", () => { it("sends cancel when elicitation handler throws", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll, @@ -2869,7 +2880,7 @@ describe("CopilotClient", () => { it("dispatches postToolUseFailure to onPostToolUseFailure handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const received: { input: any; invocation: any }[] = []; const session = await client.createSession({ @@ -2910,7 +2921,7 @@ describe("CopilotClient", () => { it("does not fall back to onPostToolUse for postToolUseFailure events", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const postUseCalls: string[] = []; const session = await client.createSession({ @@ -2939,7 +2950,7 @@ describe("CopilotClient", () => { it("dispatches postToolUse and postToolUseFailure to their respective handlers", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const postCalls: string[] = []; const failureCalls: string[] = []; @@ -2989,7 +3000,7 @@ describe("CopilotClient", () => { // The SDK maps that to public `{..., timestamp: Date, workingDirectory}`. const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const received: { input: any; invocation: any }[] = []; const session = await client.createSession({ diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts index 33b7a0636..89489f78e 100644 --- a/nodejs/test/e2e/client.e2e.test.ts +++ b/nodejs/test/e2e/client.e2e.test.ts @@ -1,11 +1,12 @@ import { ChildProcess } from "child_process"; import { describe, expect, it, onTestFinished } from "vitest"; -import { CopilotClient, approveAll, RuntimeConnection } from "../../src/index.js"; +import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; +import { isInProcessTransport } from "./harness/sdkTestContext.js"; -function onTestFinishedForceStop(client: CopilotClient) { +function onTestFinishedStop(client: CopilotClient) { onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors - process may already be stopped } @@ -18,7 +19,7 @@ describe("Client", () => { { transport: "tcp", connection: () => RuntimeConnection.forTcp() }, ])("allows createSession without onPermissionRequest ($transport)", async ({ connection }) => { const client = new CopilotClient({ connection: connection() }); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await using session = await client.createSession({}); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); @@ -30,7 +31,7 @@ describe("Client", () => { const client = new CopilotClient({ connection: RuntimeConnection.forTcp({ connectionToken }), }); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await using originalSession = await client.createSession({}); @@ -42,7 +43,7 @@ describe("Client", () => { const resumeClient = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken }), }); - onTestFinishedForceStop(resumeClient); + onTestFinishedStop(resumeClient); await using resumedSession = await resumeClient.resumeSession( originalSession.sessionId, @@ -53,7 +54,7 @@ describe("Client", () => { it("should start and connect to server using stdio", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -66,7 +67,7 @@ describe("Client", () => { it("should start and connect to server using tcp", async () => { const client = new CopilotClient({ connection: RuntimeConnection.forTcp() }); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -106,9 +107,14 @@ describe("Client", () => { 60_000 ); - it("should forceStop without cleanup", async () => { + // Skipping on in-proc: + // - It breaks the macOS E2E run (failure: EPIPE) + // - It's not clear that anyone should use forceStop in the in-proc case - there's no child process + // to terminate, so we can't be sure to leave a clean state + // - If you want to get to a clean state within your process, that's what "stop" (not "forceStop") is for + it.skipIf(isInProcessTransport)("should forceStop without cleanup", async () => { const client = new CopilotClient({}); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.createSession({ onPermissionRequest: approveAll }); await client.forceStop(); @@ -116,7 +122,7 @@ describe("Client", () => { it("should get status with version and protocol info", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -132,7 +138,7 @@ describe("Client", () => { it("should get auth status", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -148,7 +154,7 @@ describe("Client", () => { it("should list models when authenticated", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -177,7 +183,7 @@ describe("Client", () => { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ args: ["--nonexistent-flag-for-testing"] }), }); - onTestFinishedForceStop(client); + onTestFinishedStop(client); let initialError: Error | undefined; try { diff --git a/nodejs/test/e2e/client_api.e2e.test.ts b/nodejs/test/e2e/client_api.e2e.test.ts index 4adaad6ec..46c23cee6 100644 --- a/nodejs/test/e2e/client_api.e2e.test.ts +++ b/nodejs/test/e2e/client_api.e2e.test.ts @@ -44,6 +44,7 @@ describe("Client session management", async () => { await waitFor(async () => (await client.listSessions()).some((s) => s.sessionId === sessionId) ); + await session.abort(); await session.disconnect(); await client.deleteSession(sessionId); diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index 2910b6fb9..e207f275a 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -182,7 +182,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -206,7 +206,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -237,7 +237,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -291,7 +291,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -374,7 +374,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -523,7 +523,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -590,7 +590,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } diff --git a/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts b/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts index 5a9cad5a5..69bacd4f6 100644 --- a/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts +++ b/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { approveAll, CopilotRequestHandler, type CopilotRequestContext } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; /** * Cancellation and error coverage for {@link CopilotRequestHandler}. These two @@ -162,23 +162,33 @@ describe("CopilotRequestHandler observes runtime cancellation", async () => { copilotClientOptions: { requestHandler: handler }, }); - it("fires ctx.signal when the consumer aborts an in-flight inference request", async () => { - await client.start(); - const session = await client.createSession({ onPermissionRequest: approveAll }); - try { - await session.send("Say OK."); - await waitFor(() => handler.inferenceEntered, 60_000); - await session.abort(); - await waitFor(() => handler.sawAbort, 30_000); - } finally { - await session.disconnect(); - } + // The runtime enforces a single, process-wide LLM inference provider: a second + // client.start() with a requestHandler rejects llmInference.setProvider with + // "Another client is already the LLM inference provider." The sibling error test + // above already registers a provider and holds it for this file's lifetime, and + // inproc runs share one runtime host, so this scenario can only run on the default + // (stdio) cell, where each client owns its own runtime process. + it.skipIf(isInProcessTransport)( + "fires ctx.signal when the consumer aborts an in-flight inference request", + async () => { + await client.start(); + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await session.send("Say OK."); + await waitFor(() => handler.inferenceEntered, 60_000); + await session.abort(); + await waitFor(() => handler.sawAbort, 30_000); + } finally { + await session.disconnect(); + } - expect(handler.inferenceEntered, "expected the inference callback to be entered").toBe( - true - ); - expect(handler.sawAbort, "expected the callback to observe runtime cancellation").toBe( - true - ); - }, 90_000); + expect(handler.inferenceEntered, "expected the inference callback to be entered").toBe( + true + ); + expect(handler.sawAbort, "expected the callback to observe runtime cancellation").toBe( + true + ); + }, + 90_000 + ); }); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index a59f62126..ba44dd094 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -16,6 +16,29 @@ import { formatError, retry } from "./sdkTestHelper"; export const isCI = process.env.GITHUB_ACTIONS === "true"; export const DEFAULT_GITHUB_TOKEN = "fake-token-for-e2e-tests"; +/** + * True when the E2E suite is running over the in-process (FFI) transport + * (COPILOT_SDK_DEFAULT_CONNECTION=inprocess). Use with `it.skipIf` / `describe.skipIf` + * to skip tests for features that are not supported over the in-process transport (the + * runtime loads into the shared host process), so the in-process CI cell stays green. + * Such features are covered by the default (stdio) cell. + */ +export const isInProcessTransport = + (process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess"; + +// The in-process (FFI) transport resolves auth host-side, in this test process, and +// ranks HMAC above the GitHub token — so an ambient COPILOT_HMAC_KEY (CI sets one as a +// job-level credential) would be picked over the SDK/Bearer token the replay snapshots +// expect, yielding 401s. Host-side auth can capture the key as early as client +// construction (before any per-test beforeEach runs), so neutralize it at module load — +// the analogue of .NET's InProcessEnvIsolation `[ModuleInitializer]`. Only applied for +// the in-process transport; stdio/tcp children resolve auth in their own process where +// the token already outranks HMAC. See https://github.com/github/copilot-sdk/issues/1934. +if ((process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess") { + delete process.env.COPILOT_HMAC_KEY; + delete process.env.CAPI_HMAC_KEY; +} + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); @@ -69,8 +92,14 @@ export async function createSdkTestContext({ COPILOT_HOME: copilotHomeDir, COPILOT_SDK_AUTH_TOKEN: "", GH_CONFIG_DIR: homeDir, - GH_TOKEN: "", - GITHUB_TOKEN: "", + // Use the proxy-recognized token rather than blanking these. Tests that spin up + // their own client without passing `gitHubToken` (e.g. the stdio/tcp + // "works without onPermissionRequest" cases) rely on GH_TOKEN/GITHUB_TOKEN to + // authenticate against the replay proxy. Blanking them only worked on CI, where an + // ambient COPILOT_HMAC_KEY secret supplies the credential instead; locally there is + // no HMAC key, so the child CLI had nothing to authenticate with and got a 401. + GH_TOKEN: authTokenToUse, + GITHUB_TOKEN: authTokenToUse, // TODO: I'm not convinced the SDK should default to using whatever config you happen to have in your homedir. // The SDK config should be independent of the regular CLI app. Likewise it shouldn't mix sessions from the @@ -102,11 +131,17 @@ export async function createSdkTestContext({ } else { connection = userConn; } + } else if (useStdio === false) { + connection = RuntimeConnection.forTcp({ path: cliPath }); + } else if ( + useStdio === undefined && + (process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess" + ) { + // The in-process FFI transport resolves the CLI entrypoint itself + // (COPILOT_CLI_PATH or the bundled platform package), so no path is passed. + connection = RuntimeConnection.forInProcess(); } else { - connection = - useStdio === false - ? RuntimeConnection.forTcp({ path: cliPath }) - : RuntimeConnection.forStdio({ path: cliPath }); + connection = RuntimeConnection.forStdio({ path: cliPath }); } const { @@ -114,9 +149,40 @@ export async function createSdkTestContext({ env: userEnv, ...remainingClientOptions } = copilotClientOptions ?? {}; + + const mergedEnv = { ...env, ...userEnv }; + + // The in-process (FFI) transport loads the runtime into this test host process, + // and its worker inherits this process's ambient environment rather than a + // per-client env block (see https://github.com/github/copilot-sdk/issues/1934). + // So the per-test redirects, isolated home, and credentials must be mirrored onto + // the real process environment. Node's `process.env` writes reach native `getenv`, + // so host-side runtime reads (auth resolution, GitHub API redirect) observe them. + // Auth flows via GH_TOKEN/GITHUB_TOKEN here (the FFI argv omits the stdio + // `--auth-token-env COPILOT_SDK_AUTH_TOKEN` wiring), and HMAC is disabled so + // host-side auth resolution picks the SDK/Bearer token the replay snapshots expect. + const isInProcess = connection.kind === "inprocess"; + const inProcessEnv: Record = isInProcess + ? { + ...(mergedEnv as Record), + GH_TOKEN: authTokenToUse, + GITHUB_TOKEN: authTokenToUse, + COPILOT_HMAC_KEY: "", + CAPI_HMAC_KEY: "", + } + : {}; + const copilotClient = new CopilotClient({ - workingDirectory: workDir, - env: { ...env, ...userEnv }, + // The in-process transport rejects a per-client workingDirectory (it would have to + // mutate the shared host process cwd). Instead the harness changes this process's + // cwd to workDir around the in-process worker's startup (see beforeEach below), so + // the worker still spawns with workDir as its cwd. Out-of-process clients get it + // as a normal per-client option. + workingDirectory: isInProcess ? undefined : workDir, + // In-process hosting mirrors the environment onto the real process (per test, in + // beforeEach below), so the worker inherits it; passing a per-client env here + // would have no effect. + env: isInProcess ? undefined : mergedEnv, logLevel: logLevel || "error", connection, gitHubToken: authTokenToUse, @@ -128,6 +194,12 @@ export async function createSdkTestContext({ // Track if any test fails to avoid writing corrupted snapshots let anyTestFailed = false; + // Holds the process.env entries the current test overwrote, so afterEach restores them. + let restoreProcessEnv: Array<[string, string | undefined]> = []; + + // Holds the process cwd before an in-process test changed it, so afterEach restores it. + let restoreCwd: string | undefined; + // Wire up to Vitest lifecycle beforeEach(async (testContext) => { // Must be inside beforeEach - vitest requires test context @@ -135,6 +207,25 @@ export async function createSdkTestContext({ anyTestFailed = true; }); + // Mirror this context's environment onto the real process for in-process + // hosting, right before the test runs (see the comment above the client). The + // client auto-starts on first use inside the test body, so the worker spawns + // under these values. + restoreProcessEnv = []; + for (const [key, value] of Object.entries(inProcessEnv)) { + restoreProcessEnv.push([key, process.env[key]]); + process.env[key] = value; + } + + // The in-process worker inherits this process's cwd at spawn (the client auto-starts + // on first use inside the test body). Point cwd at workDir here so the worker spawns + // with the same working directory the out-of-process transport passes explicitly; + // afterEach restores it. + if (isInProcess) { + restoreCwd = process.cwd(); + process.chdir(workDir); + } + await openAiEndpoint.updateConfig({ filePath: getTrafficCapturePath(testContext), workDir, @@ -146,6 +237,20 @@ export async function createSdkTestContext({ }); afterEach(async () => { + // Undo this test's process.env mirror so it can't leak into the next test/suite. + for (const [key, previous] of restoreProcessEnv.reverse()) { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + restoreProcessEnv = []; + // Restore the cwd an in-process test changed for worker startup. + if (restoreCwd !== undefined) { + process.chdir(restoreCwd); + restoreCwd = undefined; + } // Empty directories but leave them in place for next test await rimraf([join(homeDir, "*"), join(workDir, "*")], { glob: true }); }); diff --git a/nodejs/test/e2e/inprocess_ffi.e2e.test.ts b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts new file mode 100644 index 000000000..af879ea77 --- /dev/null +++ b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { CopilotClient, RuntimeConnection } from "../../src/index.js"; + +describe("In-process FFI transport", () => { + // Smoke test that the in-process FFI transport starts and completes a round-trip. + // Resolution of the in-process transport from COPILOT_SDK_DEFAULT_CONNECTION is + // exercised by the full E2E suite running under the `inprocess` CI matrix cell, + // not a dedicated test. + it("should start and connect over in-process FFI", async () => { + // In-process FFI hosting resolves the CLI entrypoint (COPILOT_CLI_PATH or the + // bundled platform package) and its sibling native runtime library itself. If + // neither is available, start() throws and the test fails hard. + const client = new CopilotClient({ connection: RuntimeConnection.forInProcess() }); + await client.start(); + + const pong = await client.ping("ffi message"); + expect(pong.message).toBe("pong: ffi message"); + expect(Date.parse(pong.timestamp)).not.toBeNaN(); + + expect(await client.stop()).toHaveLength(0); // No errors on stop + }); +}); diff --git a/nodejs/test/e2e/multi-client.e2e.test.ts b/nodejs/test/e2e/multi-client.e2e.test.ts index a63b1b0eb..a44ceec3c 100644 --- a/nodejs/test/e2e/multi-client.e2e.test.ts +++ b/nodejs/test/e2e/multi-client.e2e.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, afterAll } from "vitest"; import { z } from "zod"; import { CopilotClient, defineTool, approveAll, RuntimeConnection } from "../../src/index.js"; import type { SessionEvent } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext"; describe("Multi-client broadcast", async () => { // Use TCP mode so a second client can connect to the same CLI process @@ -304,71 +304,75 @@ describe("Multi-client broadcast", async () => { } ); - it("disconnecting client removes its tools", { timeout: 90_000 }, async () => { - const toolA = defineTool("stable_tool", { - description: "A tool that persists across disconnects", - parameters: z.object({ input: z.string() }), - handler: ({ input }) => `STABLE_${input}`, - }); + it.skipIf(isInProcessTransport)( + "disconnecting client removes its tools", + { timeout: 90_000 }, + async () => { + 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("ephemeral_tool", { - description: "A tool that will disappear when its client disconnects", - parameters: z.object({ input: z.string() }), - handler: ({ input }) => `EPHEMERAL_${input}`, - }); + 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}`, + }); - // Client 1 creates a session with stable_tool - const session1 = await client1.createSession({ - onPermissionRequest: approveAll, - tools: [toolA], - }); + // Client 1 creates a session with stable_tool + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + tools: [toolA], + }); - // Client 2 resumes with ephemeral_tool - await client2.resumeSession(session1.sessionId, { - onPermissionRequest: approveAll, - tools: [toolB], - }); + // Client 2 resumes with ephemeral_tool + await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + tools: [toolB], + }); - // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) - const stableResponse = await session1.sendAndWait({ - prompt: "Use the stable_tool with input 'test1' and tell me the result.", - }); - expect(stableResponse?.data.content).toContain("STABLE_test1"); + // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) + const stableResponse = await session1.sendAndWait({ + prompt: "Use the stable_tool with input 'test1' and tell me the result.", + }); + expect(stableResponse?.data.content).toContain("STABLE_test1"); - const ephemeralResponse = await session1.sendAndWait({ - prompt: "Use the ephemeral_tool with input 'test2' and tell me the result.", - }); - expect(ephemeralResponse?.data.content).toContain("EPHEMERAL_test2"); - - // Disconnect client 2 without destroying the shared session. - // Suppress "Connection is disposed" rejections that occur when the server - // broadcasts events (e.g. tool_changed_notice) to the now-dead connection. - const suppressDisposed = (reason: unknown) => { - if (reason instanceof Error && reason.message.includes("Connection is disposed")) { - return; - } - throw reason; - }; - process.on("unhandledRejection", suppressDisposed); - await client2.forceStop(); - - // Give the server time to process the connection close and remove tools - await new Promise((resolve) => setTimeout(resolve, 500)); - 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, - }), - }); + const ephemeralResponse = await session1.sendAndWait({ + prompt: "Use the ephemeral_tool with input 'test2' and tell me the result.", + }); + expect(ephemeralResponse?.data.content).toContain("EPHEMERAL_test2"); + + // Disconnect client 2 without destroying the shared session. + // Suppress "Connection is disposed" rejections that occur when the server + // broadcasts events (e.g. tool_changed_notice) to the now-dead connection. + const suppressDisposed = (reason: unknown) => { + if (reason instanceof Error && reason.message.includes("Connection is disposed")) { + return; + } + throw reason; + }; + process.on("unhandledRejection", suppressDisposed); + await client2.forceStop(); + + // Give the server time to process the connection close and remove tools + await new Promise((resolve) => setTimeout(resolve, 500)); + 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, + }), + }); - // Now only stable_tool should be available - const afterResponse = await session1.sendAndWait({ - prompt: "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", - }); - expect(afterResponse?.data.content).toContain("STABLE_still_here"); - // ephemeral_tool should NOT have produced a result - expect(afterResponse?.data.content).not.toContain("EPHEMERAL_"); - }); + // Now only stable_tool should be available + const afterResponse = await session1.sendAndWait({ + prompt: "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", + }); + expect(afterResponse?.data.content).toContain("STABLE_still_here"); + // ephemeral_tool should NOT have produced a result + expect(afterResponse?.data.content).not.toContain("EPHEMERAL_"); + } + ); }); diff --git a/nodejs/test/e2e/rpc.e2e.test.ts b/nodejs/test/e2e/rpc.e2e.test.ts index 0442ab926..f90547da9 100644 --- a/nodejs/test/e2e/rpc.e2e.test.ts +++ b/nodejs/test/e2e/rpc.e2e.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -function onTestFinishedForceStop(client: CopilotClient) { +function onTestFinishedStop(client: CopilotClient) { onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors - process may already be stopped } @@ -15,7 +15,7 @@ function onTestFinishedForceStop(client: CopilotClient) { describe("RPC", () => { it("should call rpc.ping with typed params and result", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -28,7 +28,7 @@ describe("RPC", () => { it("should call rpc.models.list with typed result", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -48,7 +48,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(); - onTestFinishedForceStop(client); + onTestFinishedStop(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 cdd64017c..4025dc444 100644 --- a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts @@ -74,7 +74,7 @@ describe("Session MCP and skills RPC", async () => { }); onTestFinished(async () => { try { - await mcpAppsClient.forceStop(); + await mcpAppsClient.stop(); } catch { // Ignore cleanup errors } diff --git a/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts index 581567cb3..95694a8c6 100644 --- a/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts @@ -9,7 +9,7 @@ function startEphemeralClient(): CopilotClient { const client = new CopilotClient(); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index 8913146b1..5075ae68d 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -39,7 +39,7 @@ describe("Server-scoped RPC", async () => { }); onTestFinished(async () => { try { - await extraClient.forceStop(); + await extraClient.stop(); } catch { // Ignore cleanup errors } diff --git a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts index c38fccca1..4f12e507a 100644 --- a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts @@ -65,7 +65,7 @@ describe("Miscellaneous server-scoped RPC", async () => { async function disposeIsolated(isolatedClient: CopilotClient, home: string): Promise { try { - await isolatedClient.forceStop(); + await isolatedClient.stop(); } catch { // Best-effort cleanup. } @@ -74,7 +74,7 @@ describe("Miscellaneous server-scoped RPC", async () => { async function forceStop(target: CopilotClient): Promise { try { - await target.forceStop(); + await target.stop(); } catch { // Runtime may already be gone. } diff --git a/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts index 4bc3c63c9..20575a911 100644 --- a/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts @@ -59,7 +59,7 @@ describe("Server-scoped plugin RPC", async () => { fixtureDir?: string ): Promise { try { - await client.forceStop(); + await client.stop(); } catch { // Best-effort cleanup. } diff --git a/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts index 2e6c6cf05..3094d3257 100644 --- a/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts @@ -23,7 +23,7 @@ describe("Server-scoped remote-control RPC", async () => { async function forceStop(client: CopilotClient): Promise { try { - await client.forceStop(); + await client.stop(); } catch { // Runtime may already be gone. } diff --git a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts index 6e84a6f66..54f40fe12 100644 --- a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts @@ -84,7 +84,7 @@ describe("Session-scoped state extras RPC", async () => { } finally { await disconnect(session); try { - await authClient.forceStop(); + await authClient.stop(); } catch { // Best-effort cleanup. } diff --git a/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts b/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts index d7f478e1f..78a820f67 100644 --- a/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts +++ b/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts @@ -23,9 +23,9 @@ describe("Session workspace checkpoint RPC", async () => { it("should return null or empty content for unknown checkpoint", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); try { - const result = await session.rpc.workspaces.readCheckpoint({ - number: Number.MAX_SAFE_INTEGER, - }); + // A high but 32-bit-safe checkpoint number that will never exist in a fresh + // session, so the read reports the checkpoint as missing. + const result = await session.rpc.workspaces.readCheckpoint({ number: 4294967294 }); expect(result.content ?? "").toBe(""); } finally { await session.disconnect(); diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index c29f0790d..e26ad5781 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -39,7 +39,7 @@ describe("Sessions", async () => { }); onTestFinished(async () => { try { - await standaloneClient.forceStop(); + await standaloneClient.stop(); } catch { // ignore } @@ -64,7 +64,7 @@ describe("Sessions", async () => { }); onTestFinished(async () => { try { - await tcpClient.forceStop(); + await tcpClient.stop(); } catch { // ignore } @@ -84,7 +84,7 @@ describe("Sessions", async () => { }); onTestFinished(async () => { try { - await resumeClient.forceStop(); + await resumeClient.stop(); } catch { // ignore } @@ -388,7 +388,7 @@ describe("Sessions", async () => { gitHubToken: isCI ? "fake-token-for-e2e-tests" : undefined, }); - onTestFinished(() => newClient.forceStop()); + onTestFinished(() => newClient.stop()); const session2 = await newClient.resumeSession(sessionId, { onPermissionRequest: approveAll, }); @@ -487,7 +487,7 @@ describe("Sessions", async () => { ? DEFAULT_GITHUB_TOKEN : (process.env.GITHUB_TOKEN ?? DEFAULT_GITHUB_TOKEN), }); - onTestFinished(() => newClient.forceStop()); + onTestFinished(() => newClient.stop()); const session2 = await newClient.resumeSession(sessionId, { onPermissionRequest: approveAll, diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index cba98996e..11de9582e 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -108,7 +108,7 @@ describe("Session Fs", async () => { connection: RuntimeConnection.forTcp({ connectionToken: tcpConnectionToken }), env, }); - onTestFinished(() => client.forceStop()); + onTestFinished(() => client.stop()); await client.createSession({ onPermissionRequest: approveAll, createSessionFsProvider }); const { runtimePort: port } = client as unknown as { runtimePort: number }; @@ -123,7 +123,7 @@ describe("Session Fs", async () => { }), sessionFs: sessionFsConfig, }); - onTestFinished(() => client2.forceStop()); + onTestFinished(() => client2.stop()); await expect(client2.start()).rejects.toThrow(); }); diff --git a/nodejs/test/e2e/streaming_fidelity.e2e.test.ts b/nodejs/test/e2e/streaming_fidelity.e2e.test.ts index 52f893469..ec4f5cc6b 100644 --- a/nodejs/test/e2e/streaming_fidelity.e2e.test.ts +++ b/nodejs/test/e2e/streaming_fidelity.e2e.test.ts @@ -85,7 +85,7 @@ describe("Streaming Fidelity", async () => { env, gitHubToken: isCI ? "fake-token-for-e2e-tests" : process.env.GITHUB_TOKEN, }); - onTestFinished(() => newClient.forceStop()); + onTestFinished(() => newClient.stop()); const session2 = await newClient.resumeSession(session.sessionId, { onPermissionRequest: approveAll, streaming: true, @@ -124,7 +124,7 @@ describe("Streaming Fidelity", async () => { env, gitHubToken: isCI ? "fake-token-for-e2e-tests" : process.env.GITHUB_TOKEN, }); - onTestFinished(() => newClient.forceStop()); + onTestFinished(() => newClient.stop()); const session2 = await newClient.resumeSession(session.sessionId, { onPermissionRequest: approveAll, streaming: false, diff --git a/nodejs/test/e2e/suspend.e2e.test.ts b/nodejs/test/e2e/suspend.e2e.test.ts index 79e898726..2c8639ad3 100644 --- a/nodejs/test/e2e/suspend.e2e.test.ts +++ b/nodejs/test/e2e/suspend.e2e.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { z } from "zod"; -import { approveAll, CopilotClient, defineTool, RuntimeConnection } from "../../src/index.js"; import type { PermissionRequest, PermissionRequestResult, SessionEvent } from "../../src/index.js"; +import { approveAll, CopilotClient, defineTool, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; const SUSPEND_TIMEOUT_MS = 60_000; @@ -47,10 +47,10 @@ async function waitWithTimeout( } } -function onTestFinishedForceStop(client: CopilotClient): void { +function onTestFinishedStop(client: CopilotClient): void { onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -71,7 +71,7 @@ describe("Suspend RPC", async () => { connectionToken: SHARED_TOKEN, }), }); - onTestFinishedForceStop(server); + onTestFinishedStop(server); return server; } @@ -79,7 +79,7 @@ describe("Suspend RPC", async () => { const connectedClient = new CopilotClient({ connection: RuntimeConnection.forUri(cliUrl, { connectionToken: SHARED_TOKEN }), }); - onTestFinishedForceStop(connectedClient); + onTestFinishedStop(connectedClient); return connectedClient; } diff --git a/nodejs/test/e2e/telemetry.e2e.test.ts b/nodejs/test/e2e/telemetry.e2e.test.ts index 9df6b7f88..4fe3612f7 100644 --- a/nodejs/test/e2e/telemetry.e2e.test.ts +++ b/nodejs/test/e2e/telemetry.e2e.test.ts @@ -7,7 +7,7 @@ import { join } from "path"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import { approveAll, defineTool } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage } from "./harness/sdkTestHelper.js"; interface TelemetryEntry { @@ -67,86 +67,94 @@ describe("Telemetry export", async () => { }, }); - it("should export file telemetry for sdk interactions", { timeout: 90_000 }, async () => { - const session = await client.createSession({ - onPermissionRequest: approveAll, - tools: [ - defineTool(toolName, { - description: "Echoes a marker string for telemetry validation.", - parameters: z.object({ value: z.string() }), - handler: ({ value }) => value, - }), - ], - }); - - await session.send({ prompt }); - const assistantMessage = await getFinalAssistantMessage(session); - expect(assistantMessage).toBeDefined(); - expect(assistantMessage.data.content ?? "").toContain("TELEMETRY_E2E_DONE"); - - await session.disconnect(); - await client.stop(); - - // Telemetry exporter writes to telemetryFileName resolved relative to the CLI cwd (workDir). - const telemetryPath = join(workDir, telemetryFileName); - const entries = await readTelemetryEntries(telemetryPath); - const spans = entries.filter((entry) => entry.type === "span"); - - expect(spans.length).toBeGreaterThan(0); - for (const span of spans) { - expect(span.instrumentationScope?.name).toBe(sourceName); - } - - // All spans for one SDK turn must share the same trace id and must not be in error state. - const traceIds = Array.from( - new Set(spans.map((span) => span.traceId).filter((id): id is string => Boolean(id))) - ); - expect(traceIds).toHaveLength(1); - for (const span of spans) { - expect(span.status?.code).not.toBe(2); - } - - const invokeAgentSpan = spans.find( - (span) => getStringAttribute(span, "gen_ai.operation.name") === "invoke_agent" - ); - expect(invokeAgentSpan).toBeDefined(); - expect(getStringAttribute(invokeAgentSpan!, "gen_ai.conversation.id")).toBe( - session.sessionId - ); - expect(isRootSpan(invokeAgentSpan!)).toBe(true); - const invokeAgentSpanId = invokeAgentSpan!.spanId; - expect(invokeAgentSpanId).toBeTruthy(); - - const chatSpans = spans.filter( - (span) => getStringAttribute(span, "gen_ai.operation.name") === "chat" - ); - expect(chatSpans.length).toBeGreaterThan(0); - for (const chat of chatSpans) { - expect(chat.parentSpanId).toBe(invokeAgentSpanId); - } - expect( - chatSpans.some((span) => - (getStringAttribute(span, "gen_ai.input.messages") ?? "").includes(prompt) - ) - ).toBe(true); - expect( - chatSpans.some((span) => - (getStringAttribute(span, "gen_ai.output.messages") ?? "").includes( - "TELEMETRY_E2E_DONE" + // Telemetry is configured via environment variables the runtime reads, which the + // in-process transport cannot carry per-client (the runtime runs in the shared host + // process); see https://github.com/github/copilot-sdk/issues/1934. Covered by the + // default (stdio) cell. + it.skipIf(isInProcessTransport)( + "should export file telemetry for sdk interactions", + { timeout: 90_000 }, + async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool(toolName, { + description: "Echoes a marker string for telemetry validation.", + parameters: z.object({ value: z.string() }), + handler: ({ value }) => value, + }), + ], + }); + + await session.send({ prompt }); + const assistantMessage = await getFinalAssistantMessage(session); + expect(assistantMessage).toBeDefined(); + expect(assistantMessage.data.content ?? "").toContain("TELEMETRY_E2E_DONE"); + + await session.disconnect(); + await client.stop(); + + // Telemetry exporter writes to telemetryFileName resolved relative to the CLI cwd (workDir). + const telemetryPath = join(workDir, telemetryFileName); + const entries = await readTelemetryEntries(telemetryPath); + const spans = entries.filter((entry) => entry.type === "span"); + + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.instrumentationScope?.name).toBe(sourceName); + } + + // All spans for one SDK turn must share the same trace id and must not be in error state. + const traceIds = Array.from( + new Set(spans.map((span) => span.traceId).filter((id): id is string => Boolean(id))) + ); + expect(traceIds).toHaveLength(1); + for (const span of spans) { + expect(span.status?.code).not.toBe(2); + } + + const invokeAgentSpan = spans.find( + (span) => getStringAttribute(span, "gen_ai.operation.name") === "invoke_agent" + ); + expect(invokeAgentSpan).toBeDefined(); + expect(getStringAttribute(invokeAgentSpan!, "gen_ai.conversation.id")).toBe( + session.sessionId + ); + expect(isRootSpan(invokeAgentSpan!)).toBe(true); + const invokeAgentSpanId = invokeAgentSpan!.spanId; + expect(invokeAgentSpanId).toBeTruthy(); + + const chatSpans = spans.filter( + (span) => getStringAttribute(span, "gen_ai.operation.name") === "chat" + ); + expect(chatSpans.length).toBeGreaterThan(0); + for (const chat of chatSpans) { + expect(chat.parentSpanId).toBe(invokeAgentSpanId); + } + expect( + chatSpans.some((span) => + (getStringAttribute(span, "gen_ai.input.messages") ?? "").includes(prompt) ) - ) - ).toBe(true); - - const toolSpan = spans.find( - (span) => getStringAttribute(span, "gen_ai.operation.name") === "execute_tool" - ); - expect(toolSpan).toBeDefined(); - expect(toolSpan!.parentSpanId).toBe(invokeAgentSpanId); - expect(getStringAttribute(toolSpan!, "gen_ai.tool.name")).toBe(toolName); - expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.id")).toBeTruthy(); - expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.arguments")).toBe( - `{"value":"${marker}"}` - ); - expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.result")).toBe(marker); - }); + ).toBe(true); + expect( + chatSpans.some((span) => + (getStringAttribute(span, "gen_ai.output.messages") ?? "").includes( + "TELEMETRY_E2E_DONE" + ) + ) + ).toBe(true); + + const toolSpan = spans.find( + (span) => getStringAttribute(span, "gen_ai.operation.name") === "execute_tool" + ); + expect(toolSpan).toBeDefined(); + expect(toolSpan!.parentSpanId).toBe(invokeAgentSpanId); + expect(getStringAttribute(toolSpan!, "gen_ai.tool.name")).toBe(toolName); + expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.id")).toBeTruthy(); + expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.arguments")).toBe( + `{"value":"${marker}"}` + ); + expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.result")).toBe(marker); + } + ); }); diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index 3bc9335a2..2e85dd5af 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -5,7 +5,7 @@ import { afterAll, describe, expect, it } from "vitest"; import { CopilotClient, approveAll, RuntimeConnection } from "../../src/index.js"; import type { SessionEvent } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; describe("UI Elicitation", async () => { const { copilotClient: client } = await createSdkTestContext(); @@ -116,7 +116,7 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { } ); - it( + it.skipIf(isInProcessTransport)( "capabilities.changed fires when elicitation provider disconnects", { timeout: 60_000 }, async () => {