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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,27 @@ npm --prefix java/scripts/codegen ci
npm run generate
```

A standalone checkout can also consume schemas exported by a separate runtime
checkout through the same facade:

```bash
npm run generate -- --runtime-source checkout --schema-dir /absolute/path/to/runtime/generated
```

Generate both `api.schema.json` and `session-events.schema.json` in the runtime
checkout first, using that revision's supported commands. Keep them from the
same immutable runtime revision and record the producer commit and both file
digests when handing off an unreleased API. The equivalent generator environment
is `COPILOT_RUNTIME_SOURCE=checkout` with `COPILOT_CLI_SCHEMA_DIR` pointing to
their shared directory. Missing or invalid schemas fail rather than falling
back to a published package.

This selects generation inputs only: it does not publish or install a runtime,
change the CLI release pin, or make a new RPC callable on an older runtime.
Use the matching runtime build for integration checks and retain capability
checks for unsupported runtimes. Do not replace installed package sources or
edit generated files to emulate an unreleased contract.

Do not replace runtime-checkout pins with a published version to make setup
work. If the shared CLI version is `0.0.0-dev`, it is a development placeholder:
local work still uses same-checkout artifacts. Release snapshot export, not
Expand Down
28 changes: 15 additions & 13 deletions nodejs/src/generated/rpc.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 51 additions & 1 deletion nodejs/test/client-api-codegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";

import { emitClientSessionApiRegistration as emitGoClientSessionApiRegistration } from "../../scripts/codegen/go.ts";
import { emitClientSessionApiRegistration as emitPythonClientSessionApiRegistration } from "../../scripts/codegen/python.ts";
import { emitClientSessionApiRegistration as emitTypeScriptClientSessionApiRegistration } from "../../scripts/codegen/typescript.ts";
import {
emitClientGlobalApiRegistration as emitTypeScriptClientGlobalApiRegistration,
emitClientSessionApiRegistration as emitTypeScriptClientSessionApiRegistration,
} from "../../scripts/codegen/typescript.ts";

const clientSessionSchema: Record<string, unknown> = {
mixed: {
Expand Down Expand Up @@ -73,6 +76,53 @@ describe("client-session API codegen", () => {
expect(allInternalCode).not.toContain("InternalOnlyHandler");
});

describe("client-global API codegen", () => {
it("preserves request cancellation without changing notification handlers", () => {
const code = emitTypeScriptClientGlobalApiRegistration({
callbacks: {
withParams: {
rpcMethod: "callbacks.withParams",
params: {
type: "object",
title: "CallbackRequest",
properties: { id: { type: "string" } },
},
result: { type: "object", title: "CallbackResult", properties: {} },
},
withoutParams: {
rpcMethod: "callbacks.withoutParams",
result: { type: "object", title: "CallbackResult", properties: {} },
},
notified: {
rpcMethod: "callbacks.notified",
notification: true,
params: {
type: "object",
title: "CallbackRequest",
properties: { id: { type: "string" } },
},
},
},
}).join("\n");

expect(code).toContain(
"withParams(params: CallbackRequest, token?: CancellationToken)"
);
expect(code).toContain("withoutParams(token?: CancellationToken)");
expect(code).toContain("return handler.withParams(params, token)");
expect(code).toContain("return handler.withoutParams(token)");
expect(code).toContain("notified(params: CallbackRequest): Promise<void>");
expect(code).toContain("await handler.notified(params)");
expect(code).not.toContain("handler.notified(params, token)");
});

it("keeps internal methods out of global registration", () => {
const code = emitTypeScriptClientGlobalApiRegistration(clientSessionSchema).join("\n");
expectOnlyPublicClientSessionHandlers(code);
expect(code).not.toContain("InternalOnlyHandler");
});
});

it("excludes internal methods from Go handlers", () => {
const lines: string[] = [];
emitGoClientSessionApiRegistration(lines, clientSessionSchema, (name) => name, new Map());
Expand Down
185 changes: 185 additions & 0 deletions nodejs/test/client-global-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { PassThrough } from "node:stream";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import {
CancellationTokenSource,
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
type CancellationToken,
} from "vscode-jsonrpc/node.js";
import {
registerClientGlobalApiHandlers,
type ClientGlobalApiHandlers,
type ExtensionLaunchProviderResolveRequest,
type ExtensionLaunchProviderResolveResult,
} from "../src/generated/rpc.js";

function connect(handlers: ClientGlobalApiHandlers) {
const outbound = new PassThrough();
const inbound = new PassThrough();
const client = createMessageConnection(
new StreamMessageReader(inbound),
new StreamMessageWriter(outbound)
);
const server = createMessageConnection(
new StreamMessageReader(outbound),
new StreamMessageWriter(inbound)
);
onTestFinished(() => {
client.dispose();
server.dispose();
inbound.destroy();
outbound.destroy();
});
registerClientGlobalApiHandlers(client, handlers);
client.listen();
server.listen();
return server;
}

const request: ExtensionLaunchProviderResolveRequest = {
id: "project:extension",
modulePath: "/extensions/example/index.js",
name: "Example",
source: "project",
};

describe("client-global API transport", () => {
it("awaits a handler and returns its result without a session", async () => {
let release!: (result: ExtensionLaunchProviderResolveResult) => void;
const result = new Promise<ExtensionLaunchProviderResolveResult>((resolve) => {
release = resolve;
});
const handler = vi.fn(async () => result);
const server = connect({ extensionLaunchProvider: { resolve: handler } });
const response = server.sendRequest("extensionLaunchProvider.resolve", request);
const completed = vi.fn();
void response.then(completed);

await vi.waitFor(() =>
expect(handler).toHaveBeenCalledWith(
request,
expect.objectContaining({ isCancellationRequested: false })
)
);
expect(completed).not.toHaveBeenCalled();
const launch = { executable: "/app/extension-host", args: ["example"], env: {} };
release({ launch });

await expect(response).resolves.toEqual({ launch });
expect(handler).toHaveBeenCalledTimes(1);
});

it("keeps global handlers and responses on their original connection", async () => {
const firstHandler = vi.fn(async () => ({
launch: { executable: "/first/host", args: [], env: {} },
}));
const secondHandler = vi.fn(async () => ({
launch: { executable: "/second/host", args: [], env: {} },
}));
const first = connect({ extensionLaunchProvider: { resolve: firstHandler } });
const second = connect({ extensionLaunchProvider: { resolve: secondHandler } });

const responses = await Promise.all([
first.sendRequest("extensionLaunchProvider.resolve", request),
second.sendRequest("extensionLaunchProvider.resolve", request),
]);

expect(responses).toEqual([
{ launch: { executable: "/first/host", args: [], env: {} } },
{ launch: { executable: "/second/host", args: [], env: {} } },
]);
expect(firstHandler).toHaveBeenCalledTimes(1);
expect(secondHandler).toHaveBeenCalledTimes(1);
});

it("continues dispatching requests while a global handler is pending", async () => {
let release!: (result: ExtensionLaunchProviderResolveResult) => void;
const result = new Promise<ExtensionLaunchProviderResolveResult>((resolve) => {
release = resolve;
});
const handler = vi.fn(async () => result);
const tokenHandler = vi.fn(async () => ({ kind: "cancelled" as const }));
const server = connect({
extensionLaunchProvider: { resolve: handler },
gitHubToken: { getToken: tokenHandler },
});
const pending = server.sendRequest("extensionLaunchProvider.resolve", request);
await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce());

await expect(
server.sendRequest("gitHubToken.getToken", {
registrationId: "registration",
host: "github.com",
reason: "initial",
})
).resolves.toEqual({ kind: "cancelled" });

release({});
await expect(pending).resolves.toEqual({});
});

it("returns an error when no global handler is registered", async () => {
const server = connect({});

await expect(
server.sendRequest("extensionLaunchProvider.resolve", request)
).rejects.toThrow("No extensionLaunchProvider client-global handler registered");
});

it("propagates handler failure without substituting a successful result", async () => {
const server = connect({
extensionLaunchProvider: {
resolve: async () => {
throw new Error("Host review unavailable");
},
},
});

await expect(
server.sendRequest("extensionLaunchProvider.resolve", request)
).rejects.toThrow("Host review unavailable");
});

it("forwards real request cancellation to the pending global handler", async () => {
const cancellation = new CancellationTokenSource();
onTestFinished(() => cancellation.dispose());
let observed: CancellationToken | undefined;
let started!: () => void;
const entered = new Promise<void>((resolve) => {
started = resolve;
});
const server = connect({
extensionLaunchProvider: {
resolve: async (_params, token?: CancellationToken) => {
observed = token;
started();
if (token && !token.isCancellationRequested) {
await new Promise<void>((resolve) => {
const subscription = token.onCancellationRequested(() => {
subscription.dispose();
resolve();
});
});
}
return {};
},
},
});
const response = server.sendRequest(
"extensionLaunchProvider.resolve",
request,
cancellation.token
);

try {
await entered;
expect(observed).toBeDefined();
expect(observed?.isCancellationRequested).toBe(false);
} finally {
cancellation.cancel();
await expect(response).resolves.toEqual({});
}
expect(observed?.isCancellationRequested).toBe(true);
});
});
Loading
Loading