/* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, it, onTestFinished, vi } from "vitest"; import { approveAll, CopilotClient, RuntimeConnection, type ModelInfo } from "../src/index.js"; import { CopilotSession } from "../src/session.js"; import { defaultJoinSessionPermissionHandler } from "../src/types.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead describe("CopilotClient", () => { it("does not respond to v3 permission requests when handler returns no-result", async () => { const session = new CopilotSession("session-1", {} as any); session.registerPermissionHandler(() => ({ kind: "no-result" })); const spy = vi.spyOn(session.rpc.permissions, "handlePendingPermissionRequest"); await (session as any)._executePermissionAndRespond("request-1", { kind: "write" }); expect(spy).not.toHaveBeenCalled(); }); it("forwards clientName in session.create request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ clientName: "my-app", onPermissionRequest: approveAll }); expect(spy).toHaveBeenCalledWith( "session.create", expect.objectContaining({ clientName: "my-app" }) ); }); it("forwards cloud options in session.create request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockResolvedValue({ sessionId: "cloud-session" }); await client.createSession({ onPermissionRequest: approveAll, cloud: { repository: { owner: "github", name: "copilot-sdk", branch: "main" }, }, }); expect(spy).toHaveBeenCalledWith( "session.create", expect.objectContaining({ cloud: { repository: { owner: "github", name: "copilot-sdk", branch: "main" }, }, }) ); }); it("forwards clientName in session.resume request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); // Mock sendRequest to capture the call without hitting the runtime const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { clientName: "my-app", onPermissionRequest: approveAll, }); expect(spy).toHaveBeenCalledWith( "session.resume", expect.objectContaining({ clientName: "my-app", sessionId: session.sessionId }) ); spy.mockRestore(); }); it("forwards enableSessionTelemetry in session.create request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ enableSessionTelemetry: false, onPermissionRequest: approveAll, }); expect(spy).toHaveBeenCalledWith( "session.create", expect.objectContaining({ enableSessionTelemetry: false }) ); }); it("forwards enableSessionTelemetry in session.resume request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { enableSessionTelemetry: false, onPermissionRequest: approveAll, }); expect(spy).toHaveBeenCalledWith( "session.resume", expect.objectContaining({ enableSessionTelemetry: false, sessionId: session.sessionId }) ); spy.mockRestore(); }); it("defaults includeSubAgentStreamingEvents to true in session.create when not specified", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; expect(payload.includeSubAgentStreamingEvents).toBe(true); }); it("forwards explicit false for includeSubAgentStreamingEvents in session.create", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll, includeSubAgentStreamingEvents: false, }); const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; expect(payload.includeSubAgentStreamingEvents).toBe(false); }); it("defaults includeSubAgentStreamingEvents to true in session.resume when not specified", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll }); const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; expect(payload.includeSubAgentStreamingEvents).toBe(true); spy.mockRestore(); }); it("forwards explicit false for includeSubAgentStreamingEvents in session.resume", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, includeSubAgentStreamingEvents: false, }); const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; expect(payload.includeSubAgentStreamingEvents).toBe(false); spy.mockRestore(); }); it("forwards continuePendingWork in session.resume request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, continuePendingWork: true, }); const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; expect(payload.continuePendingWork).toBe(true); spy.mockRestore(); }); it("omits continuePendingWork from session.resume payload when not specified", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll }); const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; expect(payload.continuePendingWork).toBeUndefined(); spy.mockRestore(); }); it("forwards provider headers in session.create request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.create") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.createSession({ onPermissionRequest: approveAll, provider: { baseUrl: "https://example.com/provider", headers: { Authorization: "Bearer provider-token" }, modelId: "gpt-4o", wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, }, }); const payload = spy.mock.calls.find(([method]) => method === "session.create")![1] as any; expect(payload.provider).toEqual( expect.objectContaining({ baseUrl: "https://example.com/provider", headers: { Authorization: "Bearer provider-token" }, modelId: "gpt-4o", wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, }) ); spy.mockRestore(); }); it("forwards provider headers in session.resume request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, provider: { baseUrl: "https://example.com/provider", headers: { Authorization: "Bearer resume-token" }, modelId: "gpt-4o", wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, }, }); const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; expect(payload.provider).toEqual( expect.objectContaining({ baseUrl: "https://example.com/provider", headers: { Authorization: "Bearer resume-token" }, modelId: "gpt-4o", wireModel: "my-finetune-v3", maxPromptTokens: 100_000, maxOutputTokens: 4096, }) ); spy.mockRestore(); }); it("forwards defaultAgent in session.create request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ defaultAgent: { excludedTools: ["heavy-tool"] }, onPermissionRequest: approveAll, }); expect(spy).toHaveBeenCalledWith( "session.create", expect.objectContaining({ defaultAgent: { excludedTools: ["heavy-tool"] }, }) ); }); it("forwards defaultAgent in session.resume request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.resumeSession(session.sessionId, { defaultAgent: { excludedTools: ["heavy-tool"] }, onPermissionRequest: approveAll, }); expect(spy).toHaveBeenCalledWith( "session.resume", expect.objectContaining({ defaultAgent: { excludedTools: ["heavy-tool"] }, }) ); }); it("forwards instructionDirectories in session.create request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const instructionDirectories = ["C:\\extra-instructions", "C:\\more-instructions"]; const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ instructionDirectories, onPermissionRequest: approveAll, }); expect(spy).toHaveBeenCalledWith( "session.create", expect.objectContaining({ instructionDirectories }) ); }); it("forwards instructionDirectories in session.resume request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const instructionDirectories = ["C:\\resume-instructions"]; const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { instructionDirectories, onPermissionRequest: approveAll, }); expect(spy).toHaveBeenCalledWith( "session.resume", expect.objectContaining({ instructionDirectories, sessionId: session.sessionId, }) ); spy.mockRestore(); }); 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()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: defaultJoinSessionPermissionHandler, }); expect(spy).toHaveBeenCalledWith( "session.resume", expect.objectContaining({ sessionId: session.sessionId, requestPermission: false, }) ); spy.mockRestore(); }); it("requests permissions on session.resume when using an explicit handler", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, }); expect(spy).toHaveBeenCalledWith( "session.resume", expect.objectContaining({ sessionId: session.sessionId, requestPermission: true, }) ); spy.mockRestore(); }); it("forwards mode callback request flags in session.resume request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, onExitPlanModeRequest: () => ({ approved: true }), onAutoModeSwitchRequest: () => "yes", }); expect(spy).toHaveBeenCalledWith( "session.resume", expect.objectContaining({ sessionId: session.sessionId, requestExitPlanMode: true, requestAutoModeSwitch: true, }) ); spy.mockRestore(); }); it("sends session.model.switchTo RPC with correct params", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); // Mock sendRequest to capture the call without hitting the runtime const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, _params: any) => { if (method === "session.model.switchTo") return {}; // Fall through for other methods (shouldn't be called) throw new Error(`Unexpected method: ${method}`); }); await session.setModel("gpt-4.1"); expect(spy).toHaveBeenCalledWith("session.model.switchTo", { sessionId: session.sessionId, modelId: "gpt-4.1", }); spy.mockRestore(); }); it("sends reasoningEffort with session.model.switchTo when provided", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, _params: any) => { if (method === "session.model.switchTo") return {}; throw new Error(`Unexpected method: ${method}`); }); await session.setModel("claude-sonnet-4.6", { reasoningEffort: "high" }); expect(spy).toHaveBeenCalledWith("session.model.switchTo", { sessionId: session.sessionId, modelId: "claude-sonnet-4.6", reasoningEffort: "high", }); spy.mockRestore(); }); describe("URL parsing", () => { it("should parse port-only URL format", () => { const client = new CopilotClient({ connection: RuntimeConnection.forUri("8080"), logLevel: "error", }); expect((client as any).runtimePort).toBe(8080); expect((client as any).actualHost).toBe("localhost"); expect((client as any).isExternalServer).toBe(true); }); it("should parse host:port URL format", () => { const client = new CopilotClient({ connection: RuntimeConnection.forUri("127.0.0.1:9000"), logLevel: "error", }); expect((client as any).runtimePort).toBe(9000); expect((client as any).actualHost).toBe("127.0.0.1"); expect((client as any).isExternalServer).toBe(true); }); it("should parse http://host:port URL format", () => { const client = new CopilotClient({ connection: RuntimeConnection.forUri("http://localhost:7000"), logLevel: "error", }); expect((client as any).runtimePort).toBe(7000); expect((client as any).actualHost).toBe("localhost"); expect((client as any).isExternalServer).toBe(true); }); it("should parse https://host:port URL format", () => { const client = new CopilotClient({ connection: RuntimeConnection.forUri("https://example.com:443"), logLevel: "error", }); expect((client as any).runtimePort).toBe(443); expect((client as any).actualHost).toBe("example.com"); expect((client as any).isExternalServer).toBe(true); }); it("should throw error for invalid URL format", () => { expect(() => { new CopilotClient({ connection: RuntimeConnection.forUri("invalid-url"), logLevel: "error", }); }).toThrow(/Invalid cliUrl format/); }); it("should throw error for invalid port - too high", () => { expect(() => { new CopilotClient({ connection: RuntimeConnection.forUri("localhost:99999"), logLevel: "error", }); }).toThrow(/Invalid port in cliUrl/); }); it("should throw error for invalid port - zero", () => { expect(() => { new CopilotClient({ connection: RuntimeConnection.forUri("localhost:0"), logLevel: "error", }); }).toThrow(/Invalid port in cliUrl/); }); it("should throw error for invalid port - negative", () => { expect(() => { new CopilotClient({ connection: RuntimeConnection.forUri("localhost:-1"), logLevel: "error", }); }).toThrow(/Invalid port in cliUrl/); }); it("should mark client as using external server", () => { const client = new CopilotClient({ connection: RuntimeConnection.forUri("localhost:8080"), logLevel: "error", }); expect((client as any).isExternalServer).toBe(true); }); it("should not resolve a CLI path when forUri is used", () => { const client = new CopilotClient({ connection: RuntimeConnection.forUri("localhost:8080"), logLevel: "error", }); expect((client as any).resolvedCliPath).toBeUndefined(); }); }); describe("SessionFs config", () => { it("throws when initialCwd is missing", () => { expect(() => { new CopilotClient({ sessionFs: { initialCwd: "", sessionStatePath: "/session-state", conventions: "posix", }, logLevel: "error", }); }).toThrow(/sessionFs\.initialCwd is required/); }); it("throws when sessionStatePath is missing", () => { expect(() => { new CopilotClient({ sessionFs: { initialCwd: "/", sessionStatePath: "", conventions: "posix", }, logLevel: "error", }); }).toThrow(/sessionFs\.sessionStatePath is required/); }); }); describe("Auth options", () => { it("should accept gitHubToken option", () => { const client = new CopilotClient({ gitHubToken: "gho_test_token", logLevel: "error", }); expect((client as any).options.gitHubToken).toBe("gho_test_token"); }); it("should default useLoggedInUser to true when no gitHubToken", () => { const client = new CopilotClient({ logLevel: "error", }); expect((client as any).options.useLoggedInUser).toBe(true); }); it("should default useLoggedInUser to false when gitHubToken is provided", () => { const client = new CopilotClient({ gitHubToken: "gho_test_token", logLevel: "error", }); expect((client as any).options.useLoggedInUser).toBe(false); }); it("should allow explicit useLoggedInUser: true with gitHubToken", () => { const client = new CopilotClient({ gitHubToken: "gho_test_token", useLoggedInUser: true, logLevel: "error", }); expect((client as any).options.useLoggedInUser).toBe(true); }); it("should allow explicit useLoggedInUser: false without gitHubToken", () => { const client = new CopilotClient({ useLoggedInUser: false, logLevel: "error", }); expect((client as any).options.useLoggedInUser).toBe(false); }); it("should accept baseDirectory option", () => { const client = new CopilotClient({ baseDirectory: "/custom/copilot/home", logLevel: "error", }); expect((client as any).options.baseDirectory).toBe("/custom/copilot/home"); }); it("should leave baseDirectory undefined when not provided", () => { const client = new CopilotClient({ logLevel: "error", }); expect((client as any).options.baseDirectory).toBeUndefined(); }); it("should throw error when gitHubToken is used with forUri", () => { expect(() => { new CopilotClient({ connection: RuntimeConnection.forUri("localhost:8080"), gitHubToken: "gho_test_token", logLevel: "error", }); }).toThrow( /gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/ ); }); it("should throw error when useLoggedInUser is used with forUri", () => { expect(() => { new CopilotClient({ connection: RuntimeConnection.forUri("localhost:8080"), useLoggedInUser: false, logLevel: "error", }); }).toThrow( /gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/ ); }); }); describe("overridesBuiltInTool in tool definitions", () => { it("sends overridesBuiltInTool in tool definition on session.create", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll, tools: [ { name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true, }, ], }); const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; expect(payload.tools).toEqual([ expect.objectContaining({ name: "grep", overridesBuiltInTool: true }), ]); }); it("sends overridesBuiltInTool in tool definition on session.resume", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); // Mock sendRequest to capture the call without hitting the runtime const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, tools: [ { name: "grep", description: "custom grep", handler: async () => "ok", overridesBuiltInTool: true, }, ], }); const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; expect(payload.tools).toEqual([ expect.objectContaining({ name: "grep", overridesBuiltInTool: true }), ]); spy.mockRestore(); }); }); describe("agent parameter in session creation", () => { it("forwards agent in session.create request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll, customAgents: [ { name: "test-agent", prompt: "You are a test agent.", }, ], agent: "test-agent", }); const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; expect(payload.agent).toBe("test-agent"); expect(payload.customAgents).toEqual([expect.objectContaining({ name: "test-agent" })]); }); it("forwards custom agent model in session.create request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll, customAgents: [ { name: "model-agent", prompt: "You are a model agent.", model: "claude-haiku-4.5", }, ], }); const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; expect(payload.customAgents).toEqual([ expect.objectContaining({ name: "model-agent", model: "claude-haiku-4.5" }), ]); }); it("forwards agent in session.resume request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, customAgents: [ { name: "test-agent", prompt: "You are a test agent.", }, ], agent: "test-agent", }); const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; expect(payload.agent).toBe("test-agent"); spy.mockRestore(); }); }); describe("onListModels", () => { it("calls onListModels handler instead of RPC when provided", async () => { const customModels: ModelInfo[] = [ { id: "my-custom-model", name: "My Custom Model", capabilities: { supports: { vision: false, reasoningEffort: false }, limits: { max_context_window_tokens: 128000 }, }, }, ]; const handler = vi.fn().mockReturnValue(customModels); const client = new CopilotClient({ onListModels: handler }); await client.start(); onTestFinished(() => client.forceStop()); const models = await client.listModels(); expect(handler).toHaveBeenCalledTimes(1); expect(models).toEqual(customModels); }); it("caches onListModels results on subsequent calls", async () => { const customModels: ModelInfo[] = [ { id: "cached-model", name: "Cached Model", capabilities: { supports: { vision: false, reasoningEffort: false }, limits: { max_context_window_tokens: 128000 }, }, }, ]; const handler = vi.fn().mockReturnValue(customModels); const client = new CopilotClient({ onListModels: handler }); await client.start(); onTestFinished(() => client.forceStop()); await client.listModels(); await client.listModels(); expect(handler).toHaveBeenCalledTimes(1); // Only called once due to caching }); it("supports async onListModels handler", async () => { const customModels: ModelInfo[] = [ { id: "async-model", name: "Async Model", capabilities: { supports: { vision: false, reasoningEffort: false }, limits: { max_context_window_tokens: 128000 }, }, }, ]; const handler = vi.fn().mockResolvedValue(customModels); const client = new CopilotClient({ onListModels: handler }); await client.start(); onTestFinished(() => client.forceStop()); const models = await client.listModels(); expect(models).toEqual(customModels); }); it("does not require client.start when onListModels is provided", async () => { const customModels: ModelInfo[] = [ { id: "no-start-model", name: "No Start Model", capabilities: { supports: { vision: false, reasoningEffort: false }, limits: { max_context_window_tokens: 128000 }, }, }, ]; const handler = vi.fn().mockReturnValue(customModels); const client = new CopilotClient({ onListModels: handler }); const models = await client.listModels(); expect(handler).toHaveBeenCalledTimes(1); expect(models).toEqual(customModels); }); }); 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"); }); }); }); describe("onGetTraceContext", () => { it("includes trace context from callback in session.create request", async () => { const traceContext = { traceparent: "00-abcdef1234567890abcdef1234567890-1234567890abcdef-01", tracestate: "vendor=opaque", }; const provider = vi.fn().mockReturnValue(traceContext); const client = new CopilotClient({ onGetTraceContext: provider }); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); expect(provider).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith( "session.create", expect.objectContaining({ traceparent: "00-abcdef1234567890abcdef1234567890-1234567890abcdef-01", tracestate: "vendor=opaque", }) ); }); it("includes trace context from callback in session.resume request", async () => { const traceContext = { traceparent: "00-abcdef1234567890abcdef1234567890-1234567890abcdef-01", }; const provider = vi.fn().mockReturnValue(traceContext); const client = new CopilotClient({ onGetTraceContext: provider }); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll }); expect(spy).toHaveBeenCalledWith( "session.resume", expect.objectContaining({ traceparent: "00-abcdef1234567890abcdef1234567890-1234567890abcdef-01", }) ); }); it("includes trace context from callback in session.send request", async () => { const traceContext = { traceparent: "00-fedcba0987654321fedcba0987654321-abcdef1234567890-01", }; const provider = vi.fn().mockReturnValue(traceContext); const client = new CopilotClient({ onGetTraceContext: provider }); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string) => { if (method === "session.send") return { responseId: "r1" }; throw new Error(`Unexpected method: ${method}`); }); await session.send({ prompt: "hello" }); expect(spy).toHaveBeenCalledWith( "session.send", expect.objectContaining({ traceparent: "00-fedcba0987654321fedcba0987654321-abcdef1234567890-01", }) ); }); it("forwards requestHeaders in session.send request", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string) => { if (method === "session.send") return { messageId: "m1" }; throw new Error(`Unexpected method: ${method}`); }); await session.send({ prompt: "hello", requestHeaders: { Authorization: "Bearer turn-token" }, }); expect(spy).toHaveBeenCalledWith( "session.send", expect.objectContaining({ prompt: "hello", requestHeaders: { Authorization: "Bearer turn-token" }, }) ); }); it("does not include trace context when no callback is provided", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); const [, params] = spy.mock.calls.find(([method]) => method === "session.create")!; expect(params.traceparent).toBeUndefined(); expect(params.tracestate).toBeUndefined(); }); }); describe("commands", () => { it("forwards commands in session.create RPC", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll, commands: [ { name: "deploy", description: "Deploy the app", handler: async () => {} }, { name: "rollback", handler: async () => {} }, ], }); const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; expect(payload.commands).toEqual([ { name: "deploy", description: "Deploy the app" }, { name: "rollback", description: undefined }, ]); }); it("forwards commands in session.resume RPC", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { if (method === "session.resume") return { sessionId: params.sessionId }; throw new Error(`Unexpected method: ${method}`); }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, commands: [{ name: "deploy", description: "Deploy", handler: async () => {} }], }); const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; expect(payload.commands).toEqual([{ name: "deploy", description: "Deploy" }]); spy.mockRestore(); }); it("routes command.execute event to the correct handler", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const handler = vi.fn(); const session = await client.createSession({ onPermissionRequest: approveAll, commands: [{ name: "deploy", handler }], }); // Mock the RPC response so handlePendingCommand doesn't fail const rpcSpy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string) => { if (method === "session.commands.handlePendingCommand") return { success: true }; throw new Error(`Unexpected method: ${method}`); }); // Simulate a command.execute event (session as any)._dispatchEvent({ id: "evt-1", timestamp: new Date().toISOString(), parentId: null, ephemeral: true, type: "command.execute", data: { requestId: "req-1", command: "/deploy production", commandName: "deploy", args: "production", }, }); // Wait for the async handler to complete await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ sessionId: session.sessionId, command: "/deploy production", commandName: "deploy", args: "production", }) ); // Verify handlePendingCommand was called with the requestId expect(rpcSpy).toHaveBeenCalledWith( "session.commands.handlePendingCommand", expect.objectContaining({ requestId: "req-1" }) ); rpcSpy.mockRestore(); }); it("sends error when command handler throws", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll, commands: [ { name: "fail", handler: () => { throw new Error("deploy failed"); }, }, ], }); const rpcSpy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string) => { if (method === "session.commands.handlePendingCommand") return { success: true }; throw new Error(`Unexpected method: ${method}`); }); (session as any)._dispatchEvent({ id: "evt-2", timestamp: new Date().toISOString(), parentId: null, ephemeral: true, type: "command.execute", data: { requestId: "req-2", command: "/fail", commandName: "fail", args: "", }, }); await vi.waitFor(() => expect(rpcSpy).toHaveBeenCalledWith( "session.commands.handlePendingCommand", expect.objectContaining({ requestId: "req-2", error: "deploy failed" }) ) ); rpcSpy.mockRestore(); }); it("sends error for unknown command", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll, commands: [{ name: "deploy", handler: async () => {} }], }); const rpcSpy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string) => { if (method === "session.commands.handlePendingCommand") return { success: true }; throw new Error(`Unexpected method: ${method}`); }); (session as any)._dispatchEvent({ id: "evt-3", timestamp: new Date().toISOString(), parentId: null, ephemeral: true, type: "command.execute", data: { requestId: "req-3", command: "/unknown", commandName: "unknown", args: "", }, }); await vi.waitFor(() => expect(rpcSpy).toHaveBeenCalledWith( "session.commands.handlePendingCommand", expect.objectContaining({ requestId: "req-3", error: expect.stringContaining("Unknown command"), }) ) ); rpcSpy.mockRestore(); }); }); describe("ui elicitation", () => { it("reads capabilities from session.create response", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); // Intercept session.create to inject capabilities const origSendRequest = (client as any).connection!.sendRequest.bind( (client as any).connection ); vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( async (method: string, params: any) => { if (method === "session.create") { const result = await origSendRequest(method, params); return { ...result, capabilities: { ui: { elicitation: true } }, }; } return origSendRequest(method, params); } ); const session = await client.createSession({ onPermissionRequest: approveAll }); expect(session.capabilities).toEqual({ ui: { elicitation: true } }); }); it("defaults capabilities when not injected", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); // CLI returns actual capabilities (elicitation false in headless mode) expect(session.capabilities.ui?.elicitation).toBe(false); }); it("elicitation throws when capability is missing", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); await expect( session.ui.elicitation({ message: "Enter name", requestedSchema: { type: "object", properties: { name: { type: "string", minLength: 1 } }, required: ["name"], }, }) ).rejects.toThrow(/not supported/); }); it("sends requestElicitation flag when onElicitationRequest is provided", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); const session = await client.createSession({ onPermissionRequest: approveAll, onElicitationRequest: async () => ({ action: "accept" as const, content: {}, }), }); expect(session).toBeDefined(); const createCall = rpcSpy.mock.calls.find((c) => c[0] === "session.create"); expect(createCall).toBeDefined(); expect(createCall![1]).toEqual( expect.objectContaining({ requestElicitation: true, }) ); rpcSpy.mockRestore(); }); it("does not send requestElicitation when no handler provided", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); const session = await client.createSession({ onPermissionRequest: approveAll, }); expect(session).toBeDefined(); const createCall = rpcSpy.mock.calls.find((c) => c[0] === "session.create"); expect(createCall).toBeDefined(); expect(createCall![1]).toEqual( expect.objectContaining({ requestElicitation: false, }) ); rpcSpy.mockRestore(); }); it("sends mode callback request flags based on handler presence", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll, onExitPlanModeRequest: () => ({ approved: true }), onAutoModeSwitchRequest: () => "yes_always", }); const createCallWithHandlers = rpcSpy.mock.calls.find((c) => c[0] === "session.create"); expect(createCallWithHandlers![1]).toEqual( expect.objectContaining({ requestExitPlanMode: true, requestAutoModeSwitch: true, }) ); rpcSpy.mockClear(); await client.createSession({ onPermissionRequest: approveAll }); const createCallWithoutHandlers = rpcSpy.mock.calls.find( (c) => c[0] === "session.create" ); expect(createCallWithoutHandlers![1]).toEqual( expect.objectContaining({ requestExitPlanMode: false, requestAutoModeSwitch: false, }) ); rpcSpy.mockRestore(); }); it("dispatches mode callback requests to registered handlers", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll, onExitPlanModeRequest: (request, invocation) => { expect(invocation.sessionId).toBeDefined(); expect(request.summary).toBe("Review the plan"); expect(request.planContent).toBe("Plan body"); expect(request.actions).toEqual(["interactive", "autopilot"]); expect(request.recommendedAction).toBe("autopilot"); return { approved: true, selectedAction: "interactive", feedback: "Looks good", }; }, onAutoModeSwitchRequest: (request, invocation) => { expect(invocation.sessionId).toBeDefined(); expect(request.errorCode).toBe("user_weekly_rate_limited"); expect(request.retryAfterSeconds).toBe(3600); return "yes_always"; }, }); const exitResult = await (client as any).handleExitPlanModeRequest({ sessionId: session.sessionId, summary: "Review the plan", planContent: "Plan body", actions: ["interactive", "autopilot"], recommendedAction: "autopilot", }); expect(exitResult).toEqual({ approved: true, selectedAction: "interactive", feedback: "Looks good", }); const autoResult = await (client as any).handleAutoModeSwitchRequest({ sessionId: session.sessionId, errorCode: "user_weekly_rate_limited", retryAfterSeconds: 3600, }); expect(autoResult).toEqual({ response: "yes_always" }); }); it("sends cancel when elicitation handler throws", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll, onElicitationRequest: async () => { throw new Error("handler exploded"); }, }); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); await session._handleElicitationRequest( { sessionId: session.sessionId, message: "Pick a color" }, "req-123" ); const cancelCall = rpcSpy.mock.calls.find( (c) => c[0] === "session.ui.handlePendingElicitation" && (c[1] as any)?.result?.action === "cancel" ); expect(cancelCall).toBeDefined(); expect(cancelCall![1]).toEqual( expect.objectContaining({ requestId: "req-123", result: { action: "cancel" }, }) ); rpcSpy.mockRestore(); }); }); describe("sessionIdleTimeoutSeconds", () => { it("should default to 0 when not specified", () => { const client = new CopilotClient({ logLevel: "error", }); expect((client as any).options.sessionIdleTimeoutSeconds).toBe(0); }); it("should store a custom value", () => { const client = new CopilotClient({ sessionIdleTimeoutSeconds: 600, logLevel: "error", }); expect((client as any).options.sessionIdleTimeoutSeconds).toBe(600); }); }); });