diff --git a/nodejs/README.md b/nodejs/README.md index 7effb81e95..aab0f5ea40 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -1201,6 +1201,26 @@ npm ci npm test ``` +### Strict consumer compatibility + +TypeScript consumers need export-aware module resolution, such as `NodeNext`, +`Node16`, or `bundler`. The JSON-RPC dependency uses package exports and requires +TypeScript 5.4 or newer. + +To check an already built SDK tarball through its public package exports: + +```powershell +npm run test:consumer-types -- --package C:\packages\copilot-sdk.tgz --node-types 22.20.2 +npm run test:consumer-types -- --package C:\packages\copilot-sdk.tgz --node-types 24.13.4 +``` + +Use `--node C:\path\to\node.exe` to select a Node runtime without changing PATH. +The verifier installs native TypeScript 7.0.2 in an isolated consumer, compiles +with strict optional-property checks and `skipLibCheck: false`, checks emitted +declarations in a second consumer, and exercises the packed ESM/CJS exports and +an extension against a deterministic JSON-RPC 8 peer. It does not start a model +session or publish a package. The compile-only fixtures are never executed. + ## License MIT diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 0de83bc883..f59bb8bd2f 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "koffi": "^3.2.1", - "vscode-jsonrpc": "^8.2.1", + "vscode-jsonrpc": "^9.0.2", "zod": "^4.3.6" }, "devDependencies": { @@ -32,6 +32,7 @@ "tsx": "^4.20.6", "typescript": "^5.0.0", "vitest": "^4.0.18", + "vscode-jsonrpc-8": "npm:vscode-jsonrpc@8.2.1", "ws": "^8.21.0" }, "engines": { @@ -3796,8 +3797,18 @@ } }, "node_modules/vscode-jsonrpc": { + "version": "9.0.2", + "integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-jsonrpc-8": { + "name": "vscode-jsonrpc", "version": "8.2.1", "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" diff --git a/nodejs/package.json b/nodejs/package.json index 8805d0d4d8..2043c403f3 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -40,6 +40,7 @@ "verify:release-packages": "tsx scripts/verify-release-packages.ts", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "test": "vitest run", + "test:consumer-types": "node scripts/verify-consumer-types.mjs", "test:watch": "vitest", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" --ignore-path .prettierignore", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" --ignore-path .prettierignore", @@ -63,7 +64,7 @@ "license": "MIT", "dependencies": { "koffi": "^3.2.1", - "vscode-jsonrpc": "^8.2.1", + "vscode-jsonrpc": "^9.0.2", "zod": "^4.3.6" }, "devDependencies": { @@ -85,6 +86,7 @@ "tsx": "^4.20.6", "typescript": "^5.0.0", "vitest": "^4.0.18", + "vscode-jsonrpc-8": "npm:vscode-jsonrpc@8.2.1", "ws": "^8.21.0" }, "engines": { diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 6c29f98383..365fbec67b 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -19,7 +19,7 @@ "license": "MIT", "dependencies": { "koffi": "^3.2.1", - "vscode-jsonrpc": "^8.2.1", + "vscode-jsonrpc": "^9.0.2", "zod": "^4.3.6" }, "devDependencies": { @@ -41,6 +41,7 @@ "tsx": "^4.20.6", "typescript": "^5.0.0", "vitest": "^4.0.18", + "vscode-jsonrpc-8": "npm:vscode-jsonrpc@8.2.1", "ws": "^8.21.0" }, "engines": { diff --git a/nodejs/scripts/verify-consumer-types.mjs b/nodejs/scripts/verify-consumer-types.mjs new file mode 100644 index 0000000000..5764875979 --- /dev/null +++ b/nodejs/scripts/verify-consumer-types.mjs @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc-8/node.js"; + +const { values } = parseArgs({ + options: { + package: { type: "string" }, + node: { type: "string", default: process.execPath }, + "node-types": { type: "string" }, + }, +}); +assert.ok(values.package, "--package must identify an already built SDK tarball"); +assert.ok(values["node-types"], "--node-types must specify an exact version"); +assert.match(values["node-types"], /^\d+\.\d+\.\d+$/); +assert.ok(process.env.npm_execpath, "Run this verifier through npm run test:consumer-types"); + +const tarball = resolve(values.package); +const node = values.node; +const fixtures = join(dirname(fileURLToPath(import.meta.url)), "..", "test", "consumer-types"); +const consumer = mkdtempSync(join(tmpdir(), "copilot-sdk-consumer-")); +const compilerVersion = "7.0.2"; + +function run(args, timeout = 120_000) { + const result = spawnSync(node, args, { + cwd: consumer, + encoding: "utf8", + timeout, + maxBuffer: 10 * 1024 * 1024, + }); + if (result.error) throw result.error; + assert.equal( + result.status, + 0, + `${node} ${args.join(" ")} failed (signal ${result.signal})\n${result.stdout}\n${result.stderr}` + ); + return result.stdout.trim(); +} + +async function verifyExtensionRuntime() { + const child = spawn(node, [join(consumer, "runtime-extension.mjs")], { + cwd: consumer, + env: { ...process.env, SESSION_ID: "consumer-session" }, + stdio: ["pipe", "pipe", "pipe"], + }); + const exited = once(child, "exit"); + const connection = createMessageConnection( + new StreamMessageReader(child.stdout), + new StreamMessageWriter(child.stdin) + ); + let stderr = ""; + const ready = new Promise((resolveReady) => { + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + if (stderr.includes("consumer-ready\n")) resolveReady(); + }); + }); + const resume = new Promise((resolveResume) => { + connection.onRequest("session.resume", (params) => { + resolveResume(params); + return { sessionId: params.sessionId }; + }); + }); + const toolResult = new Promise((resolveResult) => { + connection.onRequest("session.tools.handlePendingToolCall", (params) => { + resolveResult(params); + return { success: true }; + }); + }); + connection.onRequest("connect", () => ({ protocolVersion: 3 })); + connection.listen(); + let timer; + try { + await Promise.race([ + (async () => { + const params = await resume; + assert.equal(params.sessionId, "consumer-session"); + assert.equal(params.requestPermission, true); + assert.equal(params.hooks, true); + assert.equal(params.disableResume, true); + assert.deepEqual(params.tools, [ + { + name: "consumer_probe", + description: "Return a deterministic result.", + parameters: { type: "object", properties: {} }, + }, + ]); + await ready; + assert.deepEqual( + await connection.sendRequest("hooks.invoke", { + sessionId: "consumer-session", + hookType: "sessionStart", + input: { source: "resume", timestamp: 0, cwd: consumer }, + }), + { output: { additionalContext: "resume:consumer-session" } } + ); + await connection.sendNotification("session.event", { + sessionId: "consumer-session", + event: { + id: "consumer-event", + timestamp: new Date(0).toISOString(), + parentId: null, + type: "external_tool.requested", + data: { + sessionId: "consumer-session", + requestId: "consumer-request", + toolCallId: "consumer-call", + toolName: "consumer_probe", + arguments: {}, + }, + }, + }); + assert.deepEqual(await toolResult, { + sessionId: "consumer-session", + requestId: "consumer-request", + result: "consumer-result", + }); + assert.deepEqual( + await connection.sendRequest("hooks.invoke", { + sessionId: "consumer-session", + hookType: "sessionEnd", + input: { reason: "complete", timestamp: 0, cwd: consumer }, + }), + { output: { sessionSummary: "complete" } } + ); + })(), + exited.then(([code, signal]) => { + throw new Error( + `Extension exited before verification: ${code}, ${signal}\n${stderr}` + ); + }), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Extension verification timed out\n${stderr}`)), + 30_000 + ); + }), + ]); + } finally { + clearTimeout(timer); + connection.dispose(); + if (child.exitCode === null && child.signalCode === null) child.kill(); + await exited; + } +} + +try { + cpSync(fixtures, consumer, { recursive: true }); + writeFileSync( + join(consumer, "package.json"), + JSON.stringify({ + name: "copilot-sdk-strict-consumer", + private: true, + type: "module", + dependencies: { + "@github/copilot-sdk": `file:${tarball.replaceAll("\\", "/")}`, + "@types/node": values["node-types"], + typescript: compilerVersion, + }, + }) + ); + run( + [process.env.npm_execpath, "install", "--ignore-scripts", "--no-audit", "--no-fund"], + 180_000 + ); + const compiler = join(consumer, "node_modules", "typescript", "bin", "tsc"); + assert.equal(run([compiler, "--version"]), `Version ${compilerVersion}`); + assert.equal( + JSON.parse( + readFileSync(join(consumer, "node_modules", "@types", "node", "package.json"), "utf8") + ).version, + values["node-types"] + ); + run([compiler, "-p", "tsconfig.json", "--pretty", "false"]); + + const declaration = readFileSync(join(consumer, "declarations", "extension.d.mts"), "utf8"); + for (const match of declaration.matchAll(/(?:from\s+|import\()["']([^"']+)["']/g)) { + assert.equal(match[1], "@github/copilot-sdk", `Non-public declaration import: ${match[1]}`); + } + // Prove the emitted declarations work without their originating source. + rmSync(join(consumer, "extension.mts")); + rmSync(join(consumer, "lifecycle.mts")); + run([compiler, "-p", "tsconfig.consumer.json", "--pretty", "false"]); + run([ + "--input-type=module", + "--eval", + ` + import assert from "node:assert/strict"; + import { CopilotClient } from "@github/copilot-sdk"; + import { joinSession } from "@github/copilot-sdk/extension"; + assert.equal(typeof CopilotClient, "function"); + assert.equal(typeof joinSession, "function"); + `, + ]); + run([ + "--input-type=commonjs", + "--eval", + ` + const assert = require("node:assert/strict"); + assert.equal(typeof require("@github/copilot-sdk").CopilotClient, "function"); + assert.equal(typeof require("@github/copilot-sdk/extension").joinSession, "function"); + `, + ]); + await verifyExtensionRuntime(); + console.log( + JSON.stringify( + { + node: run(["--version"]), + compiler: compilerVersion, + nodeTypes: values["node-types"], + sdk: JSON.parse( + readFileSync( + join(consumer, "node_modules", "@github", "copilot-sdk", "package.json"), + "utf8" + ) + ).version, + sha256: createHash("sha256").update(readFileSync(tarball)).digest("hex"), + strictDeclarations: "passed", + declarationConsumer: "passed", + publicEsmAndCjs: "passed", + extensionAgainstJsonRpc8: "passed", + }, + null, + 2 + ) + ); +} finally { + rmSync(consumer, { recursive: true, force: true }); +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6e4b4fb5b4..0b65292e25 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -24,7 +24,7 @@ import { ResponseError, StreamMessageReader, StreamMessageWriter, -} from "vscode-jsonrpc/node.js"; +} from "vscode-jsonrpc/node"; import { createServerRpc, createInternalServerRpc, diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 09bfc8ffa7..eefc060554 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -3,7 +3,7 @@ * Generated from: api.schema.json */ -import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import type { MessageConnection } from "vscode-jsonrpc/node"; import type { AbortReason, AgentModelPolicy, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerMetadata, McpServerSource, McpServerStatus, ModelChangeSource, PermissionDecisionSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, RemediationAction, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UserToolSessionApproval, Verbosity } from "./session-events.js"; diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 4c2be14299..e7509507c1 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -8,8 +8,8 @@ */ import { AsyncLocalStorage } from "node:async_hooks"; -import type { MessageConnection } from "vscode-jsonrpc/node.js"; -import { ConnectionError, ErrorCodes, ResponseError } from "vscode-jsonrpc/node.js"; +import type { MessageConnection } from "vscode-jsonrpc/node"; +import { ConnectionError, ErrorCodes, ResponseError } from "vscode-jsonrpc/node"; import { createSessionRpc } from "./generated/rpc.js"; import type { ClientSessionApiHandlers, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9c4258d9c9..b653951f55 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -3621,8 +3621,6 @@ export interface SessionLifecycleEventMetadata { interface SessionLifecycleEventBase { /** ID of the session this event relates to. */ sessionId: string; - /** Session metadata (not included for `session.deleted`). */ - metadata?: SessionLifecycleEventMetadata; } /** Emitted when a new session is created. */ @@ -3631,7 +3629,7 @@ export interface SessionCreatedEvent extends SessionLifecycleEventBase { metadata: SessionLifecycleEventMetadata; } -/** Emitted when a session is deleted. The metadata field is omitted. */ +/** Emitted when a session is deleted. Session metadata is unavailable. */ export interface SessionDeletedEvent extends SessionLifecycleEventBase { type: "session.deleted"; metadata?: undefined; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 24adb5cb6e..d46c234fb3 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1243,7 +1243,7 @@ describe("CopilotClient", () => { it("dispatches a real gitHubTelemetry.event wire message to the handler", async () => { const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = - await import("vscode-jsonrpc/node.js"); + await import("vscode-jsonrpc/node"); const { registerClientGlobalApiHandlers } = await import("../src/generated/rpc.js"); const clientToServer = new PassThrough(); diff --git a/nodejs/test/consumer-types/consumer.mts b/nodejs/test/consumer-types/consumer.mts new file mode 100644 index 0000000000..27e93ae50e --- /dev/null +++ b/nodejs/test/consumer-types/consumer.mts @@ -0,0 +1,10 @@ +import type { CopilotSession } from "@github/copilot-sdk"; +import type { joinSession } from "@github/copilot-sdk/extension"; +import type { session } from "./declarations/extension.mjs"; + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Assert = T; +export type ExactSession = Assert>; +export type ExactReturn = Assert>>>; +export type ExactPromise = Assert, Promise>>; diff --git a/nodejs/test/consumer-types/extension.mts b/nodejs/test/consumer-types/extension.mts new file mode 100644 index 0000000000..acc66ff045 --- /dev/null +++ b/nodejs/test/consumer-types/extension.mts @@ -0,0 +1,30 @@ +import { approveAll } from "@github/copilot-sdk"; +import { joinSession } from "@github/copilot-sdk/extension"; + +// Compile only. Runtime extension coverage uses runtime-extension.mjs. +const options = { + onPermissionRequest: approveAll, + tools: [ + { + name: "consumer_probe", + description: "Return a deterministic result.", + parameters: { type: "object", properties: {} }, + handler: async () => "consumer-result", + }, + ], + hooks: { + onSessionStart: async (input, context) => ({ + additionalContext: `${input.source}:${context.sessionId}`, + }), + onSessionEnd: async (input, context) => { + const reason: string = input.reason; + const sessionId: string = context.sessionId; + void reason; + void sessionId; + }, + }, +} satisfies Parameters[0]; + +export const session = await joinSession(options); +const pending: Promise>> = joinSession(options); +void pending; diff --git a/nodejs/test/consumer-types/lifecycle.mts b/nodejs/test/consumer-types/lifecycle.mts new file mode 100644 index 0000000000..7a2dbbbb3b --- /dev/null +++ b/nodejs/test/consumer-types/lifecycle.mts @@ -0,0 +1,103 @@ +import type { + CopilotClient, + SessionBackgroundEvent, + SessionCreatedEvent, + SessionDeletedEvent, + SessionForegroundEvent, + SessionLifecycleEvent, + SessionLifecycleEventMetadata, + SessionUpdatedEvent, +} from "@github/copilot-sdk"; + +const metadata: SessionLifecycleEventMetadata = { + startTime: new Date(), + modifiedTime: new Date(), +}; +const deleted: SessionDeletedEvent = { type: "session.deleted", sessionId: "test" }; +const deletedWithUndefined: SessionDeletedEvent = { ...deleted, metadata: undefined }; +const created: SessionCreatedEvent = { type: "session.created", sessionId: "test", metadata }; +const updated: SessionUpdatedEvent = { type: "session.updated", sessionId: "test", metadata }; +const foreground: SessionForegroundEvent = { + type: "session.foreground", + sessionId: "test", + metadata, +}; +const background: SessionBackgroundEvent = { + type: "session.background", + sessionId: "test", + metadata, +}; +const events: SessionLifecycleEvent[] = [ + deleted, + deletedWithUndefined, + created, + updated, + foreground, + background, +]; +for (const event of events) { + switch (event.type) { + case "session.deleted": { + const absent: undefined = event.metadata; + void absent; + break; + } + case "session.created": + case "session.updated": + case "session.foreground": + case "session.background": { + const present: SessionLifecycleEventMetadata = event.metadata; + const timestamp: Date = present.startTime; + void timestamp; + break; + } + default: { + const exhaustive: never = event; + void exhaustive; + } + } +} + +// These errors are required regression assertions, not library diagnostic suppressions. +// @ts-expect-error Deleted events cannot carry metadata objects. +const invalidDeleted: SessionDeletedEvent = { ...deleted, metadata }; +// @ts-expect-error Created events require metadata. +const missingCreated: SessionCreatedEvent = { type: "session.created", sessionId: "test" }; +// @ts-expect-error Updated events require metadata. +const missingUpdated: SessionUpdatedEvent = { type: "session.updated", sessionId: "test" }; +// @ts-expect-error Foreground events require metadata. +const missingForeground: SessionForegroundEvent = { type: "session.foreground", sessionId: "test" }; +// @ts-expect-error Background events require metadata. +const missingBackground: SessionBackgroundEvent = { type: "session.background", sessionId: "test" }; +// @ts-expect-error Created metadata cannot be explicitly undefined. +const undefinedCreated: SessionCreatedEvent = { ...created, metadata: undefined }; +// @ts-expect-error Updated metadata cannot be explicitly undefined. +const undefinedUpdated: SessionUpdatedEvent = { ...updated, metadata: undefined }; +// @ts-expect-error Foreground metadata cannot be explicitly undefined. +const undefinedForeground: SessionForegroundEvent = { ...foreground, metadata: undefined }; +// @ts-expect-error Background metadata cannot be explicitly undefined. +const undefinedBackground: SessionBackgroundEvent = { ...background, metadata: undefined }; +// @ts-expect-error Unknown discriminants are not lifecycle events. +const invalidType: SessionLifecycleEvent = { type: "session.unknown", sessionId: "test" }; +void [ + invalidDeleted, + missingCreated, + missingUpdated, + missingForeground, + missingBackground, + undefinedCreated, + undefinedUpdated, + undefinedForeground, + undefinedBackground, + invalidType, +]; + +declare const client: CopilotClient; +client.onLifecycle("session.deleted", (event) => { + const absent: undefined = event.metadata; + void absent; +}); +client.onLifecycle("session.created", (event) => { + const present: SessionLifecycleEventMetadata = event.metadata; + void present; +}); diff --git a/nodejs/test/consumer-types/runtime-extension.mjs b/nodejs/test/consumer-types/runtime-extension.mjs new file mode 100644 index 0000000000..415b71fdf5 --- /dev/null +++ b/nodejs/test/consumer-types/runtime-extension.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { approveAll, CopilotSession } from "@github/copilot-sdk"; +import { joinSession } from "@github/copilot-sdk/extension"; + +const session = await joinSession({ + onPermissionRequest: approveAll, + tools: [ + { + name: "consumer_probe", + description: "Return a deterministic result.", + parameters: { type: "object", properties: {} }, + handler: async () => "consumer-result", + }, + ], + hooks: { + onSessionStart: async (input, context) => ({ + additionalContext: `${input.source}:${context.sessionId}`, + }), + onSessionEnd: async (input) => ({ sessionSummary: input.reason }), + }, +}); +assert.ok(session instanceof CopilotSession); +assert.equal(session.sessionId, "consumer-session"); +process.stderr.write("consumer-ready\n"); diff --git a/nodejs/test/consumer-types/tsconfig.consumer.json b/nodejs/test/consumer-types/tsconfig.consumer.json new file mode 100644 index 0000000000..6b5379b3b0 --- /dev/null +++ b/nodejs/test/consumer-types/tsconfig.consumer.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "emitDeclarationOnly": false + }, + "files": ["consumer.mts"] +} diff --git a/nodejs/test/consumer-types/tsconfig.json b/nodejs/test/consumer-types/tsconfig.json new file mode 100644 index 0000000000..aa2cdd1332 --- /dev/null +++ b/nodejs/test/consumer-types/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noImplicitAny": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "skipLibCheck": false, + "declaration": true, + "emitDeclarationOnly": true, + "noEmitOnError": true, + "outDir": "declarations" + }, + "files": ["extension.mts", "lifecycle.mts"] +} diff --git a/nodejs/test/e2e/extension_env_access.e2e.test.ts b/nodejs/test/e2e/extension_env_access.e2e.test.ts index f0f84a35ed..3027375338 100644 --- a/nodejs/test/e2e/extension_env_access.e2e.test.ts +++ b/nodejs/test/e2e/extension_env_access.e2e.test.ts @@ -13,7 +13,7 @@ import { createMessageConnection, StreamMessageReader, StreamMessageWriter, -} from "vscode-jsonrpc/node.js"; +} from "vscode-jsonrpc/node"; import { approveAll, RuntimeConnection } from "../../src/index.js"; import { getSdkProtocolVersion } from "../../src/sdkProtocolVersion.js"; import { createSdkTestContext, getLegacyCliPathForTests } from "./harness/sdkTestContext.js"; diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index c9b8f65074..d6ff725e3e 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; -import { ResponseError } from "vscode-jsonrpc/node.js"; +import { ResponseError } from "vscode-jsonrpc/node"; import { CopilotClient } from "../src/client.js"; import { joinSession } from "../src/extension.js"; import { CopilotSession } from "../src/session.js"; diff --git a/nodejs/test/jsonrpc-compat.test.ts b/nodejs/test/jsonrpc-compat.test.ts new file mode 100644 index 0000000000..28176d4a5d --- /dev/null +++ b/nodejs/test/jsonrpc-compat.test.ts @@ -0,0 +1,452 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { createServer, type Socket } from "node:net"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, onTestFinished } from "vitest"; +import * as rpc9 from "vscode-jsonrpc/node"; +import * as rpc8 from "vscode-jsonrpc-8/node.js"; +import { + CopilotClient, + CopilotSession, + RuntimeConnection, + type SessionLifecycleEvent, + type SessionLifecycleEventType, +} from "../src/index.js"; + +// A union of the full 8/9 overload sets cannot resolve named-method registration. +interface PeerReceiver { + onRequest(method: string, handler: rpc9.RequestHandler): rpc9.Disposable; + onNotification

(method: string, handler: rpc9.NotificationHandler

): rpc9.Disposable; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function within(promise: Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("JSON-RPC test timed out")), 5000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +function streamPeers() { + const toEight = new PassThrough(); + const toNine = new PassThrough(); + const connections: { dispose(): void }[] = []; + const errors: unknown[] = []; + onTestFinished(() => { + for (const connection of connections) connection.dispose(); + toEight.destroy(); + toNine.destroy(); + }); + + const nine = rpc9.createMessageConnection( + new rpc9.StreamMessageReader(toNine), + new rpc9.StreamMessageWriter(toEight) + ); + connections.push(nine); + const eight = rpc8.createMessageConnection( + new rpc8.StreamMessageReader(toEight), + new rpc8.StreamMessageWriter(toNine) + ); + connections.push(eight); + nine.onError((error) => errors.push(error)); + eight.onError((error) => errors.push(error)); + nine.onUnhandledNotification((notification) => errors.push(notification)); + eight.onUnhandledNotification((notification) => errors.push(notification)); + nine.listen(); + eight.listen(); + return { nine, eight, errors }; +} + +describe("JSON-RPC 9 compatibility with an 8.2.1 peer", () => { + it("delivers SDK lifecycle events over TCP and unsubscribes typed and wildcard handlers", async () => { + const accepted = deferred(); + const sockets = new Set(); + const connections: rpc8.MessageConnection[] = []; + const requests: { method: string; params: unknown }[] = []; + const errors: unknown[] = []; + let client: CopilotClient | undefined; + const timestamp = "2026-01-02T03:04:05.000Z"; + const server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.on("error", (error) => errors.push(error)); + const peer = rpc8.createMessageConnection( + new rpc8.StreamMessageReader(socket), + new rpc8.StreamMessageWriter(socket) + ); + connections.push(peer); + peer.onError((error) => errors.push(error)); + peer.onUnhandledNotification((notification) => errors.push(notification)); + peer.onRequest("connect", (params: { supportedTaskKinds: string[] }) => { + requests.push({ method: "connect", params }); + return { protocolVersion: 3 }; + }); + peer.onRequest("ping", (params: { message: string }) => { + requests.push({ method: "ping", params }); + return { message: params.message, timestamp, protocolVersion: 3 }; + }); + peer.listen(); + accepted.resolve(peer); + }); + onTestFinished(async () => { + try { + if (client) await within(client.forceStop()); + } finally { + for (const connection of connections) connection.dispose(); + for (const socket of sockets) socket.destroy(); + if (server.listening) { + await within( + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }) + ); + } + } + }); + await within( + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }) + ); + server.on("error", (error) => errors.push(error)); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("Expected a loopback TCP address"); + } + const sdk = new CopilotClient({ + connection: RuntimeConnection.forUri(`127.0.0.1:${address.port}`), + }); + client = sdk; + await within(sdk.start()); + const peer = await within(accepted.promise); + expect(requests).toStrictEqual([ + { method: "connect", params: { supportedTaskKinds: ["agent", "client", "shell"] } }, + ]); + + const typed: { + [K in SessionLifecycleEventType]: Extract[]; + } = { + "session.created": [], + "session.deleted": [], + "session.updated": [], + "session.foreground": [], + "session.background": [], + }; + const wildcard: SessionLifecycleEvent[] = []; + const unsubscribe = { + created: sdk.onLifecycle("session.created", (event) => + typed["session.created"].push(event) + ), + deleted: sdk.onLifecycle("session.deleted", (event) => + typed["session.deleted"].push(event) + ), + updated: sdk.onLifecycle("session.updated", (event) => + typed["session.updated"].push(event) + ), + foreground: sdk.onLifecycle("session.foreground", (event) => + typed["session.foreground"].push(event) + ), + background: sdk.onLifecycle("session.background", (event) => + typed["session.background"].push(event) + ), + wildcard: sdk.onLifecycle((event) => wildcard.push(event)), + }; + const wireMetadata = { + startTime: timestamp, + modifiedTime: "2026-01-02T04:05:06.000Z", + summary: "Lifecycle compatibility", + }; + const metadata = { + startTime: new Date(wireMetadata.startTime), + modifiedTime: new Date(wireMetadata.modifiedTime), + summary: wireMetadata.summary, + }; + const sessionId = "compat-lifecycle"; + const deleted = { type: "session.deleted", sessionId }; + const created = { type: "session.created", sessionId, metadata: wireMetadata }; + const wireEvents = [ + created, + deleted, + { type: "session.updated", sessionId, metadata: wireMetadata }, + { type: "session.foreground", sessionId, metadata: wireMetadata }, + { type: "session.background", sessionId, metadata: wireMetadata }, + ]; + const expected: SessionLifecycleEvent[] = [ + { type: "session.created", sessionId, metadata }, + { type: "session.deleted", sessionId, metadata: undefined }, + { type: "session.updated", sessionId, metadata }, + { type: "session.foreground", sessionId, metadata }, + { type: "session.background", sessionId, metadata }, + ]; + const barriers: string[] = []; + async function barrier(message: string): Promise { + barriers.push(message); + // The ping response follows the lifecycle notifications on the same stream. + await expect(within(sdk.ping(message))).resolves.toStrictEqual({ + message, + timestamp, + protocolVersion: 3, + }); + } + + for (const event of wireEvents) { + await within(peer.sendNotification("session.lifecycle", event)); + } + await barrier("initial delivery"); + expect(wildcard).toStrictEqual(expected); + for (const event of expected) { + expect(typed[event.type]).toStrictEqual([event]); + if (event.metadata !== undefined) { + expect(typed[event.type][0].metadata?.startTime).toBeInstanceOf(Date); + expect(typed[event.type][0].metadata?.modifiedTime).toBeInstanceOf(Date); + } + } + expect(Object.hasOwn(deleted, "metadata")).toBe(false); + expect(Object.hasOwn(typed["session.deleted"][0], "metadata")).toBe(true); + expect(typed["session.deleted"][0].metadata).toBeUndefined(); + + unsubscribe.deleted(); + unsubscribe.deleted(); + await within(peer.sendNotification("session.lifecycle", deleted)); + await within(peer.sendNotification("session.lifecycle", created)); + await barrier("typed unsubscribe"); + expect(typed["session.deleted"]).toStrictEqual([expected[1]]); + expect(typed["session.created"]).toStrictEqual([expected[0], expected[0]]); + expect(wildcard).toStrictEqual([...expected, expected[1], expected[0]]); + + unsubscribe.wildcard(); + unsubscribe.wildcard(); + await within(peer.sendNotification("session.lifecycle", deleted)); + await within(peer.sendNotification("session.lifecycle", created)); + await barrier("wildcard unsubscribe"); + expect(typed["session.deleted"]).toStrictEqual([expected[1]]); + expect(typed["session.created"]).toStrictEqual([expected[0], expected[0], expected[0]]); + expect(wildcard).toStrictEqual([...expected, expected[1], expected[0]]); + + for (const dispose of Object.values(unsubscribe)) dispose(); + for (const event of wireEvents) { + await within(peer.sendNotification("session.lifecycle", event)); + } + await barrier("all unsubscribed"); + expect(typed).toStrictEqual({ + "session.created": [expected[0], expected[0], expected[0]], + "session.deleted": [expected[1]], + "session.updated": [expected[2]], + "session.foreground": [expected[3]], + "session.background": [expected[4]], + }); + expect(wildcard).toStrictEqual([...expected, expected[1], expected[0]]); + await expect(within(sdk.stop())).resolves.toStrictEqual([]); + await expect(within(sdk.ping("after stop"))).rejects.toThrow("Client not connected"); + expect(connections).toHaveLength(1); + expect(requests).toStrictEqual([ + { method: "connect", params: { supportedTaskKinds: ["agent", "client", "shell"] } }, + ...barriers.map((message) => ({ method: "ping", params: { message } })), + ]); + expect(errors).toStrictEqual([]); + }, 15000); + + it("sends and disconnects a public SDK session through the 8.2.1 peer", async () => { + const { nine, eight, errors } = streamPeers(); + const received: { method: string; params: unknown }[] = []; + eight.onRequest( + "session.send", + (params: { sessionId: string; prompt: string; displayPrompt: string }) => { + received.push({ method: "session.send", params }); + return { messageId: "compat-message" }; + } + ); + eight.onRequest("session.detach", (params: { sessionId: string }) => { + received.push({ method: "session.detach", params }); + return { success: true }; + }); + const session = new CopilotSession("compat-session", nine); + await expect( + within(session.send({ prompt: "wire prompt", displayPrompt: "display prompt" })) + ).resolves.toBe("compat-message"); + await within(session.disconnect()); + expect(received).toStrictEqual([ + { + method: "session.send", + params: { + sessionId: "compat-session", + prompt: "wire prompt", + displayPrompt: "display prompt", + }, + }, + { method: "session.detach", params: { sessionId: "compat-session" } }, + ]); + expect(errors).toStrictEqual([]); + }, 15000); + + describe.each(["9 to 8", "8 to 9"])("%s", (direction) => { + function peers() { + const { nine, eight, errors } = streamPeers(); + const receiver: PeerReceiver = direction === "9 to 8" ? eight : nine; + return { + sender: direction === "9 to 8" ? nine : eight, + receiver, + sending: direction === "9 to 8" ? rpc9 : rpc8, + receiving: direction === "9 to 8" ? rpc8 : rpc9, + errors, + }; + } + + it("preserves object requests, notifications and structured response errors", async () => { + const { sender, receiver, sending, receiving, errors } = peers(); + const params = { text: "caf\u00e9", nested: { enabled: false }, values: [0, null, ""] }; + const received: unknown[] = []; + const notified = deferred(); + receiver.onRequest("compat.echo", (value: typeof params) => { + received.push(value); + return { echoed: value, count: 3 }; + }); + receiver.onNotification("compat.notice", (value: typeof params) => { + notified.resolve(value); + }); + receiver.onRequest("compat.fail", (value: { operation: string }) => { + received.push(value); + throw new receiving.ResponseError(-32042, "compatibility failure", { + operation: value.operation, + retryable: false, + }); + }); + + await expect(within(sender.sendRequest("compat.echo", params))).resolves.toStrictEqual({ + echoed: params, + count: 3, + }); + await within(sender.sendNotification("compat.notice", params)); + await expect(within(notified.promise)).resolves.toStrictEqual(params); + const failure = within(sender.sendRequest("compat.fail", { operation: "read" })); + await expect(failure).rejects.toBeInstanceOf(sending.ResponseError); + await expect(failure).rejects.toStrictEqual( + new sending.ResponseError(-32042, "compatibility failure", { + operation: "read", + retryable: false, + }) + ); + await expect(within(sender.sendRequest("compat.missing", {}))).rejects.toStrictEqual( + new sending.ResponseError(-32601, "Unhandled method compat.missing") + ); + expect(received).toStrictEqual([params, { operation: "read" }]); + expect(errors).toStrictEqual([]); + }, 15000); + + it("delivers cancellation to the remote request token and preserves its error response", async () => { + const { sender, receiver, sending, receiving, errors } = peers(); + const source = new sending.CancellationTokenSource(); + const started = deferred<{ operation: string; initiallyCancelled: boolean }>(); + const cancelled = deferred(); + const release = deferred(); + onTestFinished(() => { + release.resolve(); + source.dispose(); + }); + receiver.onRequest( + "compat.cancel", + async (params: { operation: string }, token: rpc9.CancellationToken) => { + const subscription = token.onCancellationRequested(() => { + cancelled.resolve(token.isCancellationRequested); + }); + try { + started.resolve({ + operation: params.operation, + initiallyCancelled: token.isCancellationRequested, + }); + await release.promise; + return new receiving.ResponseError(-32800, "cancelled by peer", params); + } finally { + subscription.dispose(); + } + } + ); + const response = sender + .sendRequest("compat.cancel", { operation: "wait" }, source.token) + .then( + (value: unknown) => ({ value }), + (error: unknown) => ({ error }) + ); + let outcome: { value: unknown } | { error: unknown }; + try { + await expect(within(started.promise)).resolves.toStrictEqual({ + operation: "wait", + initiallyCancelled: false, + }); + source.cancel(); + await expect(within(cancelled.promise)).resolves.toBe(true); + } finally { + release.resolve(); + outcome = await within(response); + } + expect(outcome).toStrictEqual({ + error: new sending.ResponseError(-32800, "cancelled by peer", { + operation: "wait", + }), + }); + if (!("error" in outcome)) throw new Error("Expected a cancellation error response"); + expect(outcome.error).toBeInstanceOf(sending.ResponseError); + expect(errors).toStrictEqual([]); + }, 15000); + + it("does not let a pending async notification block the following request by default", async () => { + const { sender, receiver, errors } = peers(); + const started = deferred<{ label: string }>(); + const release = deferred(); + const finished = deferred(); + const order: string[] = []; + const requests: unknown[] = []; + onTestFinished(() => release.resolve()); + receiver.onNotification("compat.slow", async (params: { label: string }) => { + order.push("notification started"); + started.resolve(params); + await release.promise; + order.push("notification finished"); + finished.resolve(); + }); + receiver.onRequest("compat.after", (params: { label: string }) => { + requests.push(params); + order.push("request handled"); + return { label: params.label, notificationPending: order.length === 2 }; + }); + try { + await within(sender.sendNotification("compat.slow", { label: "slow" })); + await expect(within(started.promise)).resolves.toStrictEqual({ label: "slow" }); + await expect( + within(sender.sendRequest("compat.after", { label: "following" })) + ).resolves.toStrictEqual({ label: "following", notificationPending: true }); + expect(order).toStrictEqual(["notification started", "request handled"]); + expect(requests).toStrictEqual([{ label: "following" }]); + } finally { + release.resolve(); + await within(finished.promise); + } + expect(order).toStrictEqual([ + "notification started", + "request handled", + "notification finished", + ]); + expect(errors).toStrictEqual([]); + }, 15000); + }); +}); diff --git a/nodejs/test/message-source.test.ts b/nodejs/test/message-source.test.ts index 9c2460aa2a..51342b955f 100644 --- a/nodejs/test/message-source.test.ts +++ b/nodejs/test/message-source.test.ts @@ -9,7 +9,7 @@ import { ResponseError, StreamMessageReader, StreamMessageWriter, -} from "vscode-jsonrpc/node.js"; +} from "vscode-jsonrpc/node"; import type { MessageOptions, MessageSource, SessionEvent } from "../src/index.js"; import { CopilotSession } from "../src/session.js"; diff --git a/nodejs/test/session-send-and-wait.test.ts b/nodejs/test/session-send-and-wait.test.ts index 4ee2e8eebc..e499eb0d67 100644 --- a/nodejs/test/session-send-and-wait.test.ts +++ b/nodejs/test/session-send-and-wait.test.ts @@ -3,7 +3,7 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, it, onTestFinished } from "vitest"; -import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import type { MessageConnection } from "vscode-jsonrpc/node"; import { CopilotSession } from "../src/session.js"; import type { SessionEvent } from "../src/generated/session-events.js"; diff --git a/nodejs/tsconfig.json b/nodejs/tsconfig.json index 4ec4c2121f..962c8c2cd1 100644 --- a/nodejs/tsconfig.json +++ b/nodejs/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ES2022", "lib": ["ES2022"], - "moduleResolution": "node", + "moduleResolution": "bundler", "outDir": "./dist", "declaration": true, "declarationMap": false, diff --git a/nodejs/tsconfig.test.json b/nodejs/tsconfig.test.json index cc659e9a3a..89247e696a 100644 --- a/nodejs/tsconfig.test.json +++ b/nodejs/tsconfig.test.json @@ -8,6 +8,7 @@ "include": [ "src/**/*", "test/ffiRuntimeHost.test.ts", + "test/jsonrpc-compat.test.ts", "test/session-event-types.test.ts", "test/message-source.test.ts" ], diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index f5e8acb146..a569ef73a1 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -717,7 +717,7 @@ async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema * Generated from: api.schema.json */ -import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import type { MessageConnection } from "vscode-jsonrpc/node"; `); const externalSchemaRefs = collectExternalSchemaRefNames(schema);