Files
openclaw/extensions/copilot/src/runtime.test.ts
Ramrajprabu f3cfd752d3 feat(copilot): add GitHub Copilot agent runtime
Adds the opt-in bundled GitHub Copilot agent runtime, pinned SDK install path, docs/inventory, SDK/tool/sandbox/auth wiring, and replay/tool-safety fixes.

Verification:
- Local: git diff --check; fnm exec --using 24.15.0 pnpm tsgo:extensions; fnm exec --using 24.15.0 pnpm check:test-types; fnm exec --using 24.15.0 pnpm build.
- Autoreview local: clean for the replay-safety fix; branch autoreview engine returned empty output twice, so local autoreview plus local/Crabbox/CI proof was used.
- Crabbox focused Copilot: run_2c0db9f48a4a, 19 files / 485 tests passed.
- Crabbox additional boundary shard: run_26a246a1aa24, prompt snapshots and plugin SDK boundary/export checks passed.
- Crabbox live Copilot: run_d128e4048b4e, real gpt-4.1 turn with live_echo phase-1-green and clean session-file check.
- GitHub checks: green on head 7cc8657e0d, including Dependency Guard after exact-head approval.

Co-authored-by: Ramraj Balasubramanian <ramrajba@microsoft.com>
2026-05-29 05:15:22 +01:00

489 lines
16 KiB
TypeScript

import { normalize, resolve, sep } from "node:path";
import type { CopilotClient, CopilotClientOptions } from "@github/copilot-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ClientCreateOptions, PoolKey } from "./runtime.js";
import { createCopilotClientPool } from "./runtime.js";
interface FakeClient {
readonly id: number;
readonly copilotHome: string;
readonly start: ReturnType<typeof vi.fn>;
readonly stop: ReturnType<typeof vi.fn>;
readonly createSession: ReturnType<typeof vi.fn>;
readonly disconnect: ReturnType<typeof vi.fn>;
}
interface FakeFactoryOptions {
readonly create?: (
opts: CopilotClientOptions,
id: number,
) => CopilotClient | Promise<CopilotClient>;
readonly stop?: (client: FakeClient) => Promise<Error[]> | Error[];
}
function createDeferred<T>() {
let resolveValue: ((value: T | PromiseLike<T>) => void) | undefined;
let rejectValue: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolveValue = resolvePromise;
rejectValue = rejectPromise;
});
return {
promise,
resolve(value: T) {
resolveValue?.(value);
},
reject(reason: unknown) {
rejectValue?.(reason);
},
};
}
function normalizeHomeForTest(copilotHome: string): string {
let normalizedHome = resolve(copilotHome);
normalizedHome = normalize(normalizedHome);
if (normalizedHome.endsWith(sep) && normalizedHome.length > 1) {
normalizedHome = normalizedHome.slice(0, -1);
}
if (process.platform === "win32") {
normalizedHome = normalizedHome.toLowerCase();
}
return normalizedHome;
}
function makeKey(overrides: Partial<PoolKey> = {}): PoolKey {
return {
agentId: overrides.agentId ?? "agent-1",
copilotHome: overrides.copilotHome ?? "copilot-home",
authMode: overrides.authMode ?? "useLoggedInUser",
authProfileId: overrides.authProfileId,
authProfileVersion: overrides.authProfileVersion,
};
}
function makeOptions(overrides: Partial<ClientCreateOptions> = {}): ClientCreateOptions {
return {
copilotHome: overrides.copilotHome ?? "copilot-home",
useLoggedInUser: overrides.useLoggedInUser ?? true,
gitHubToken: overrides.gitHubToken,
cwd: overrides.cwd,
};
}
function makeFake(options: FakeFactoryOptions = {}) {
const stops: number[] = [];
const ctorCalls: CopilotClientOptions[] = [];
const instances: FakeClient[] = [];
let nextId = 0;
const fake = async (clientOptions: CopilotClientOptions) => {
ctorCalls.push(clientOptions);
const id = ++nextId;
if (options.create) {
return options.create(clientOptions, id);
}
const client: FakeClient = {
id,
copilotHome: clientOptions.copilotHome ?? "",
start: vi.fn(async () => undefined),
stop: vi.fn(async () => {
stops.push(id);
if (options.stop) {
return options.stop(client);
}
return [];
}),
createSession: vi.fn(async () => ({})),
disconnect: vi.fn(),
};
instances.push(client);
return client as unknown as CopilotClient;
};
return { fake, stops, ctorCalls, instances };
}
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("createCopilotClientPool", () => {
it("same key reuses client", async () => {
const sdk = makeFake();
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
const key = makeKey();
const options = makeOptions();
const first = await pool.acquire(key, options);
const second = await pool.acquire(key, options);
expect(first.client).toBe(second.client);
expect(first.key).toEqual(second.key);
expect(sdk.ctorCalls.length).toBe(1);
});
it("different agentId same copilotHome creates distinct clients", async () => {
const sdk = makeFake();
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
const options = makeOptions();
const first = await pool.acquire(makeKey({ agentId: "agent-a" }), options);
const second = await pool.acquire(makeKey({ agentId: "agent-b" }), options);
expect(first.client).not.toBe(second.client);
expect(sdk.ctorCalls.length).toBe(2);
});
it("different authProfileVersion creates distinct clients", async () => {
const sdk = makeFake();
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
const options = makeOptions({ gitHubToken: "token-a", useLoggedInUser: false });
const first = await pool.acquire(
makeKey({ authMode: "gitHubToken", authProfileId: "profile", authProfileVersion: "v1" }),
options,
);
const second = await pool.acquire(
makeKey({ authMode: "gitHubToken", authProfileId: "profile", authProfileVersion: "v2" }),
options,
);
expect(first.client).not.toBe(second.client);
expect(sdk.ctorCalls.length).toBe(2);
});
it("release decrements; non-zero refcount keeps client alive", async () => {
const sdk = makeFake();
const pool = createCopilotClientPool({ idleTtlMs: 100, sdkFactory: sdk.fake });
const key = makeKey();
const options = makeOptions();
const first = await pool.acquire(key, options);
const second = await pool.acquire(key, options);
await pool.release(first);
expect(first.client).toBe(second.client);
expect(sdk.stops).toEqual([]);
expect(pool.size()).toBe(1);
});
it("release to zero schedules idle teardown; teardown fires after idleTtlMs and calls stop() exactly once", async () => {
vi.useFakeTimers();
const sdk = makeFake();
const pool = createCopilotClientPool({ idleTtlMs: 50, sdkFactory: sdk.fake });
const handle = await pool.acquire(makeKey(), makeOptions());
await pool.release(handle);
await vi.advanceTimersByTimeAsync(49);
expect(sdk.stops).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(sdk.stops).toEqual([1]);
expect(pool.size()).toBe(0);
expect(sdk.instances[0]?.start.mock.calls.length).toBe(0);
await vi.advanceTimersByTimeAsync(50);
expect(sdk.stops).toEqual([1]);
});
it("acquire during idle window cancels teardown and reuses", async () => {
vi.useFakeTimers();
const sdk = makeFake();
const pool = createCopilotClientPool({ idleTtlMs: 50, sdkFactory: sdk.fake });
const key = makeKey();
const options = makeOptions();
const first = await pool.acquire(key, options);
await pool.release(first);
await vi.advanceTimersByTimeAsync(25);
const second = await pool.acquire(key, options);
expect(second.client).toBe(first.client);
expect(sdk.ctorCalls.length).toBe(1);
expect(sdk.stops).toEqual([]);
await vi.advanceTimersByTimeAsync(50);
expect(sdk.stops).toEqual([]);
await pool.release(second);
await vi.advanceTimersByTimeAsync(50);
expect(sdk.stops).toEqual([1]);
});
it("acquire during stopping awaits stop(), then creates fresh client", async () => {
vi.useFakeTimers();
const stopDeferred = createDeferred<Error[]>();
const sdk = makeFake({
stop: async () => stopDeferred.promise,
});
const pool = createCopilotClientPool({ idleTtlMs: 10, sdkFactory: sdk.fake });
const key = makeKey();
const options = makeOptions();
const first = await pool.acquire(key, options);
await pool.release(first);
await vi.advanceTimersByTimeAsync(10);
let settled = false;
const secondPromise = pool.acquire(key, options).then((value) => {
settled = true;
return value;
});
await Promise.resolve();
expect(settled).toBe(false);
expect(sdk.stops).toEqual([1]);
stopDeferred.resolve([]);
const second = await secondPromise;
expect(settled).toBe(true);
expect(second.client).not.toBe(first.client);
expect(sdk.ctorCalls.length).toBe(2);
});
it("concurrent acquire dedupes", async () => {
const clientDeferred = createDeferred<CopilotClient>();
const sdkFactory = vi.fn(async () => clientDeferred.promise);
const pool = createCopilotClientPool({ sdkFactory });
const key = makeKey();
const options = makeOptions();
const firstPromise = pool.acquire(key, options);
const secondPromise = pool.acquire(key, options);
await Promise.resolve();
expect(sdkFactory.mock.calls.length).toBe(1);
const client = {
id: 1,
copilotHome: "copilot-home",
start: vi.fn(async () => undefined),
stop: vi.fn(async () => []),
createSession: vi.fn(async () => ({})),
disconnect: vi.fn(),
} as unknown as CopilotClient;
clientDeferred.resolve(client);
const [first, second] = await Promise.all([firstPromise, secondPromise]);
expect(first.client).toBe(second.client);
expect(sdkFactory.mock.calls.length).toBe(1);
});
it("constructor failure is not cached", async () => {
let attempt = 0;
const sdkFactory = async (clientOptions: CopilotClientOptions) => {
attempt += 1;
if (attempt === 1) {
throw new Error(`constructor failed for ${String(clientOptions.copilotHome)}`);
}
return {
id: attempt,
copilotHome: clientOptions.copilotHome,
start: vi.fn(async () => undefined),
stop: vi.fn(async () => []),
createSession: vi.fn(async () => ({})),
disconnect: vi.fn(),
} as unknown as CopilotClient;
};
const pool = createCopilotClientPool({ sdkFactory });
await expect(pool.acquire(makeKey(), makeOptions())).rejects.toThrow("constructor failed for");
const second = await pool.acquire(makeKey(), makeOptions());
expect(attempt).toBe(2);
expect(second.key.agentId).toBe("agent-1");
});
it("double release is a no-op", async () => {
vi.useFakeTimers();
const sdk = makeFake();
const pool = createCopilotClientPool({ idleTtlMs: 100, sdkFactory: sdk.fake });
const handle = await pool.acquire(makeKey(), makeOptions());
await pool.release(handle);
await pool.release(handle);
await vi.advanceTimersByTimeAsync(99);
expect(sdk.stops).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(sdk.stops).toEqual([1]);
});
it("dispose stops all clients exactly once, aggregates errors, clears the map", async () => {
const sdk = makeFake({
stop: (client) => [new Error(`stop-${client.id}-a`), new Error(`stop-${client.id}-b`)],
});
const pool = createCopilotClientPool({ idleTtlMs: 1000, sdkFactory: sdk.fake });
const first = await pool.acquire(
makeKey({ agentId: "agent-a", copilotHome: "home-a" }),
makeOptions({ copilotHome: "home-a" }),
);
const second = await pool.acquire(
makeKey({ agentId: "agent-b", copilotHome: "home-b" }),
makeOptions({ copilotHome: "home-b" }),
);
await pool.acquire(
makeKey({ agentId: "agent-c", copilotHome: "home-c" }),
makeOptions({ copilotHome: "home-c" }),
);
await pool.release(second);
const errors = await pool.dispose();
expect(errors.map((error) => error.message)).toEqual([
"stop-1-a",
"stop-1-b",
"stop-2-a",
"stop-2-b",
"stop-3-a",
"stop-3-b",
]);
expect(sdk.stops).toEqual([1, 2, 3]);
expect(pool.size()).toBe(0);
const secondDispose = await pool.dispose();
expect(secondDispose).toEqual([]);
expect(sdk.stops).toEqual([1, 2, 3]);
await pool.release(first);
});
it("dispose during in-flight acquire", async () => {
const clientDeferred = createDeferred<CopilotClient>();
const stopped: number[] = [];
const sdkFactory = async () => {
const client = {
id: 1,
copilotHome: "copilot-home",
start: vi.fn(async () => undefined),
stop: vi.fn(async () => {
stopped.push(1);
return [];
}),
createSession: vi.fn(async () => ({})),
disconnect: vi.fn(),
} as unknown as CopilotClient;
await clientDeferred.promise;
return client;
};
const pool = createCopilotClientPool({ sdkFactory });
const acquirePromise = pool.acquire(makeKey(), makeOptions());
const disposePromise = pool.dispose();
const client = {
id: 1,
copilotHome: "copilot-home",
start: vi.fn(async () => undefined),
stop: vi.fn(async () => []),
createSession: vi.fn(async () => ({})),
disconnect: vi.fn(),
} as unknown as CopilotClient;
clientDeferred.resolve(client);
await expect(acquirePromise).rejects.toThrow("[copilot-pool] pool disposed");
expect(await disposePromise).toEqual([]);
expect(stopped).toEqual([1]);
await expect(pool.acquire(makeKey(), makeOptions())).rejects.toThrow(
"[copilot-pool] pool disposed",
);
});
it("concurrent dispose waits for the in-flight shutdown and does not duplicate errors", async () => {
const stopDeferred = createDeferred<Error[]>();
const sdk = makeFake({
stop: async () => stopDeferred.promise,
});
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
await pool.acquire(makeKey(), makeOptions());
const firstDisposePromise = pool.dispose();
const secondDisposePromise = pool.dispose();
await Promise.resolve();
expect(sdk.stops).toEqual([1]);
stopDeferred.resolve([new Error("stop failed")]);
const firstErrors = await firstDisposePromise;
const secondErrors = await secondDisposePromise;
expect(firstErrors.map((error) => error.message)).toEqual(["stop failed"]);
expect(secondErrors).toEqual([]);
});
it("normalizes non-Error stop failures during dispose", async () => {
const sdk = makeFake({
stop: () => {
throw "stop-string";
},
});
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
await pool.acquire(makeKey(), makeOptions());
const errors = await pool.dispose();
expect(errors.map((error) => error.message)).toEqual(["stop-string"]);
});
it("treats Windows copilotHome paths as case-insensitive when keying the pool", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
const sdk = makeFake();
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
const firstHome = "C:/Users/Tester/CopilotHome/";
const secondHome = "c:/users/tester/copilothome";
const first = await pool.acquire(
makeKey({ copilotHome: firstHome }),
makeOptions({ copilotHome: firstHome }),
);
const second = await pool.acquire(
makeKey({ copilotHome: secondHome }),
makeOptions({ copilotHome: secondHome }),
);
const normalizedHome = normalizeHomeForTest(firstHome);
expect(first.client).toBe(second.client);
expect(first.key.copilotHome).toBe(normalizedHome);
expect(second.key.copilotHome).toBe(normalizedHome);
expect(String(sdk.ctorCalls[0]?.copilotHome)).toBe(normalizedHome);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
}
});
it("path normalization", async () => {
const sdk = makeFake();
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
const firstHome =
process.platform === "win32" ? "C:\\Users\\Tester\\CopilotHome\\" : "copilot-home/";
const secondHome =
process.platform === "win32" ? "c:\\users\\tester\\copilothome" : "copilot-home";
const first = await pool.acquire(
makeKey({ copilotHome: firstHome }),
makeOptions({ copilotHome: firstHome }),
);
const second = await pool.acquire(
makeKey({ copilotHome: secondHome }),
makeOptions({ copilotHome: secondHome }),
);
const normalizedHome = normalizeHomeForTest(firstHome);
expect(first.client).toBe(second.client);
expect(first.key.copilotHome).toBe(normalizedHome);
expect(second.key.copilotHome).toBe(normalizedHome);
expect(sdk.ctorCalls.length).toBe(1);
expect(String(sdk.ctorCalls[0]?.copilotHome)).toBe(normalizedHome);
});
});