From 945ae3735ac7421d7b88d7a31446a333b2c3dcba Mon Sep 17 00:00:00 2001 From: vivganes Date: Tue, 27 Jan 2026 06:30:33 +0530 Subject: [PATCH 1/2] fix #136 --- nodejs/src/session.ts | 752 ++++++++++++++++++------------------ nodejs/test/session.test.ts | 79 ++++ 2 files changed, 456 insertions(+), 375 deletions(-) create mode 100644 nodejs/test/session.test.ts diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index e285e7ca..18d15c39 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -1,375 +1,377 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -/** - * Copilot Session - represents a single conversation session with the Copilot CLI. - * @module session - */ - -import type { MessageConnection } from "vscode-jsonrpc/node"; -import type { - MessageOptions, - PermissionHandler, - PermissionRequest, - PermissionRequestResult, - SessionEvent, - SessionEventHandler, - Tool, - ToolHandler, -} from "./types.js"; - -/** Assistant message event - the final response from the assistant. */ -export type AssistantMessageEvent = Extract; - -/** - * Represents a single conversation session with the Copilot CLI. - * - * A session maintains conversation state, handles events, and manages tool execution. - * Sessions are created via {@link CopilotClient.createSession} or resumed via - * {@link CopilotClient.resumeSession}. - * - * @example - * ```typescript - * const session = await client.createSession({ model: "gpt-4" }); - * - * // Subscribe to events - * session.on((event) => { - * if (event.type === "assistant.message") { - * console.log(event.data.content); - * } - * }); - * - * // Send a message and wait for completion - * await session.sendAndWait({ prompt: "Hello, world!" }); - * - * // Clean up - * await session.destroy(); - * ``` - */ -export class CopilotSession { - private eventHandlers: Set = new Set(); - private toolHandlers: Map = new Map(); - private permissionHandler?: PermissionHandler; - - /** - * Creates a new CopilotSession instance. - * - * @param sessionId - The unique identifier for this session - * @param connection - The JSON-RPC message connection to the Copilot CLI - * @param workspacePath - Path to the session workspace directory (when infinite sessions enabled) - * @internal This constructor is internal. Use {@link CopilotClient.createSession} to create sessions. - */ - constructor( - public readonly sessionId: string, - private connection: MessageConnection, - private readonly _workspacePath?: string - ) {} - - /** - * Path to the session workspace directory when infinite sessions are enabled. - * Contains checkpoints/, plan.md, and files/ subdirectories. - * Undefined if infinite sessions are disabled. - */ - get workspacePath(): string | undefined { - return this._workspacePath; - } - - /** - * Sends a message to this session and waits for the response. - * - * The message is processed asynchronously. Subscribe to events via {@link on} - * to receive streaming responses and other session events. - * - * @param options - The message options including the prompt and optional attachments - * @returns A promise that resolves with the message ID of the response - * @throws Error if the session has been destroyed or the connection fails - * - * @example - * ```typescript - * const messageId = await session.send({ - * prompt: "Explain this code", - * attachments: [{ type: "file", path: "./src/index.ts" }] - * }); - * ``` - */ - async send(options: MessageOptions): Promise { - const response = await this.connection.sendRequest("session.send", { - sessionId: this.sessionId, - prompt: options.prompt, - attachments: options.attachments, - mode: options.mode, - }); - - return (response as { messageId: string }).messageId; - } - - /** - * Sends a message to this session and waits until the session becomes idle. - * - * This is a convenience method that combines {@link send} with waiting for - * the `session.idle` event. Use this when you want to block until the - * assistant has finished processing the message. - * - * Events are still delivered to handlers registered via {@link on} while waiting. - * - * @param options - The message options including the prompt and optional attachments - * @param timeout - Timeout in milliseconds (default: 60000). Controls how long to wait; does not abort in-flight agent work. - * @returns A promise that resolves with the final assistant message when the session becomes idle, - * or undefined if no assistant message was received - * @throws Error if the timeout is reached before the session becomes idle - * @throws Error if the session has been destroyed or the connection fails - * - * @example - * ```typescript - * // Send and wait for completion with default 60s timeout - * const response = await session.sendAndWait({ prompt: "What is 2+2?" }); - * console.log(response?.data.content); // "4" - * ``` - */ - async sendAndWait( - options: MessageOptions, - timeout?: number - ): Promise { - const effectiveTimeout = timeout ?? 60_000; - - let resolveIdle: () => void; - let rejectWithError: (error: Error) => void; - const idlePromise = new Promise((resolve, reject) => { - resolveIdle = resolve; - rejectWithError = reject; - }); - - let lastAssistantMessage: AssistantMessageEvent | undefined; - - // Register event handler BEFORE calling send to avoid race condition - // where session.idle fires before we start listening - const unsubscribe = this.on((event) => { - if (event.type === "assistant.message") { - lastAssistantMessage = event; - } else if (event.type === "session.idle") { - resolveIdle(); - } else if (event.type === "session.error") { - const error = new Error(event.data.message); - error.stack = event.data.stack; - rejectWithError(error); - } - }); - - try { - await this.send(options); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout( - () => - reject( - new Error( - `Timeout after ${effectiveTimeout}ms waiting for session.idle` - ) - ), - effectiveTimeout - ); - }); - await Promise.race([idlePromise, timeoutPromise]); - - return lastAssistantMessage; - } finally { - unsubscribe(); - } - } - - /** - * Subscribes to events from this session. - * - * Events include assistant messages, tool executions, errors, and session state changes. - * Multiple handlers can be registered and will all receive events. - * - * @param handler - A callback function that receives session events - * @returns A function that, when called, unsubscribes the handler - * - * @example - * ```typescript - * const unsubscribe = session.on((event) => { - * switch (event.type) { - * case "assistant.message": - * console.log("Assistant:", event.data.content); - * break; - * case "session.error": - * console.error("Error:", event.data.message); - * break; - * } - * }); - * - * // Later, to stop receiving events: - * unsubscribe(); - * ``` - */ - on(handler: SessionEventHandler): () => void { - this.eventHandlers.add(handler); - return () => { - this.eventHandlers.delete(handler); - }; - } - - /** - * Dispatches an event to all registered handlers. - * - * @param event - The session event to dispatch - * @internal This method is for internal use by the SDK. - */ - _dispatchEvent(event: SessionEvent): void { - for (const handler of this.eventHandlers) { - try { - handler(event); - } catch (_error) { - // Handler error - } - } - } - - /** - * Registers custom tool handlers for this session. - * - * Tools allow the assistant to execute custom functions. When the assistant - * invokes a tool, the corresponding handler is called with the tool arguments. - * - * @param tools - An array of tool definitions with their handlers, or undefined to clear all tools - * @internal This method is typically called internally when creating a session with tools. - */ - registerTools(tools?: Tool[]): void { - this.toolHandlers.clear(); - if (!tools) { - return; - } - - for (const tool of tools) { - this.toolHandlers.set(tool.name, tool.handler); - } - } - - /** - * Retrieves a registered tool handler by name. - * - * @param name - The name of the tool to retrieve - * @returns The tool handler if found, or undefined - * @internal This method is for internal use by the SDK. - */ - getToolHandler(name: string): ToolHandler | undefined { - return this.toolHandlers.get(name); - } - - /** - * Registers a handler for permission requests. - * - * When the assistant needs permission to perform certain actions (e.g., file operations), - * this handler is called to approve or deny the request. - * - * @param handler - The permission handler function, or undefined to remove the handler - * @internal This method is typically called internally when creating a session. - */ - registerPermissionHandler(handler?: PermissionHandler): void { - this.permissionHandler = handler; - } - - /** - * Handles a permission request from the Copilot CLI. - * - * @param request - The permission request data from the CLI - * @returns A promise that resolves with the permission decision - * @internal This method is for internal use by the SDK. - */ - async _handlePermissionRequest(request: unknown): Promise { - if (!this.permissionHandler) { - // No handler registered, deny permission - return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; - } - - try { - const result = await this.permissionHandler(request as PermissionRequest, { - sessionId: this.sessionId, - }); - return result; - } catch (_error) { - // Handler failed, deny permission - return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; - } - } - - /** - * Retrieves all events and messages from this session's history. - * - * This returns the complete conversation history including user messages, - * assistant responses, tool executions, and other session events. - * - * @returns A promise that resolves with an array of all session events - * @throws Error if the session has been destroyed or the connection fails - * - * @example - * ```typescript - * const events = await session.getMessages(); - * for (const event of events) { - * if (event.type === "assistant.message") { - * console.log("Assistant:", event.data.content); - * } - * } - * ``` - */ - async getMessages(): Promise { - const response = await this.connection.sendRequest("session.getMessages", { - sessionId: this.sessionId, - }); - - return (response as { events: SessionEvent[] }).events; - } - - /** - * Destroys this session and releases all associated resources. - * - * After calling this method, the session can no longer be used. All event - * handlers and tool handlers are cleared. To continue the conversation, - * use {@link CopilotClient.resumeSession} with the session ID. - * - * @returns A promise that resolves when the session is destroyed - * @throws Error if the connection fails - * - * @example - * ```typescript - * // Clean up when done - * await session.destroy(); - * ``` - */ - async destroy(): Promise { - await this.connection.sendRequest("session.destroy", { - sessionId: this.sessionId, - }); - this.eventHandlers.clear(); - this.toolHandlers.clear(); - this.permissionHandler = undefined; - } - - /** - * Aborts the currently processing message in this session. - * - * Use this to cancel a long-running request. The session remains valid - * and can continue to be used for new messages. - * - * @returns A promise that resolves when the abort request is acknowledged - * @throws Error if the session has been destroyed or the connection fails - * - * @example - * ```typescript - * // Start a long-running request - * const messagePromise = session.send({ prompt: "Write a very long story..." }); - * - * // Abort after 5 seconds - * setTimeout(async () => { - * await session.abort(); - * }, 5000); - * ``` - */ - async abort(): Promise { - await this.connection.sendRequest("session.abort", { - sessionId: this.sessionId, - }); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Copilot Session - represents a single conversation session with the Copilot CLI. + * @module session + */ + +import type { MessageConnection } from "vscode-jsonrpc/node"; +import type { + MessageOptions, + PermissionHandler, + PermissionRequest, + PermissionRequestResult, + SessionEvent, + SessionEventHandler, + Tool, + ToolHandler, +} from "./types.js"; + +/** Assistant message event - the final response from the assistant. */ +export type AssistantMessageEvent = Extract; + +/** + * Represents a single conversation session with the Copilot CLI. + * + * A session maintains conversation state, handles events, and manages tool execution. + * Sessions are created via {@link CopilotClient.createSession} or resumed via + * {@link CopilotClient.resumeSession}. + * + * @example + * ```typescript + * const session = await client.createSession({ model: "gpt-4" }); + * + * // Subscribe to events + * session.on((event) => { + * if (event.type === "assistant.message") { + * console.log(event.data.content); + * } + * }); + * + * // Send a message and wait for completion + * await session.sendAndWait({ prompt: "Hello, world!" }); + * + * // Clean up + * await session.destroy(); + * ``` + */ +export class CopilotSession { + private eventHandlers: Set = new Set(); + private toolHandlers: Map = new Map(); + private permissionHandler?: PermissionHandler; + + /** + * Creates a new CopilotSession instance. + * + * @param sessionId - The unique identifier for this session + * @param connection - The JSON-RPC message connection to the Copilot CLI + * @param workspacePath - Path to the session workspace directory (when infinite sessions enabled) + * @internal This constructor is internal. Use {@link CopilotClient.createSession} to create sessions. + */ + constructor( + public readonly sessionId: string, + private connection: MessageConnection, + private readonly _workspacePath?: string + ) {} + + /** + * Path to the session workspace directory when infinite sessions are enabled. + * Contains checkpoints/, plan.md, and files/ subdirectories. + * Undefined if infinite sessions are disabled. + */ + get workspacePath(): string | undefined { + return this._workspacePath; + } + + /** + * Sends a message to this session and waits for the response. + * + * The message is processed asynchronously. Subscribe to events via {@link on} + * to receive streaming responses and other session events. + * + * @param options - The message options including the prompt and optional attachments + * @returns A promise that resolves with the message ID of the response + * @throws Error if the session has been destroyed or the connection fails + * + * @example + * ```typescript + * const messageId = await session.send({ + * prompt: "Explain this code", + * attachments: [{ type: "file", path: "./src/index.ts" }] + * }); + * ``` + */ + async send(options: MessageOptions): Promise { + const response = await this.connection.sendRequest("session.send", { + sessionId: this.sessionId, + prompt: options.prompt, + attachments: options.attachments, + mode: options.mode, + }); + + return (response as { messageId: string }).messageId; + } + + /** + * Sends a message to this session and waits until the session becomes idle. + * + * This is a convenience method that combines {@link send} with waiting for + * the `session.idle` event. Use this when you want to block until the + * assistant has finished processing the message. + * + * Events are still delivered to handlers registered via {@link on} while waiting. + * + * @param options - The message options including the prompt and optional attachments + * @param timeout - Timeout in milliseconds (default: 60000). Controls how long to wait; does not abort in-flight agent work. + * @returns A promise that resolves with the final assistant message when the session becomes idle, + * or undefined if no assistant message was received + * @throws Error if the timeout is reached before the session becomes idle + * @throws Error if the session has been destroyed or the connection fails + * + * @example + * ```typescript + * // Send and wait for completion with default 60s timeout + * const response = await session.sendAndWait({ prompt: "What is 2+2?" }); + * console.log(response?.data.content); // "4" + * ``` + */ + async sendAndWait( + options: MessageOptions, + timeout?: number + ): Promise { + const effectiveTimeout = timeout ?? 60_000; + + let resolveIdle: () => void; + let rejectWithError: (error: Error) => void; + const idlePromise = new Promise((resolve, reject) => { + resolveIdle = resolve; + rejectWithError = reject; + }); + + let lastAssistantMessage: AssistantMessageEvent | undefined; + let timeoutId: ReturnType; + + // Register event handler BEFORE calling send to avoid race condition + // where session.idle fires before we start listening + const unsubscribe = this.on((event) => { + if (event.type === "assistant.message") { + lastAssistantMessage = event; + } else if (event.type === "session.idle") { + resolveIdle(); + } else if (event.type === "session.error") { + const error = new Error(event.data.message); + error.stack = event.data.stack; + rejectWithError(error); + } + }); + + try { + await this.send(options); + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => + reject( + new Error( + `Timeout after ${effectiveTimeout}ms waiting for session.idle` + ) + ), + effectiveTimeout + ); + }); + await Promise.race([idlePromise, timeoutPromise]); + + return lastAssistantMessage; + } finally { + clearTimeout(timeoutId!); + unsubscribe(); + } + } + + /** + * Subscribes to events from this session. + * + * Events include assistant messages, tool executions, errors, and session state changes. + * Multiple handlers can be registered and will all receive events. + * + * @param handler - A callback function that receives session events + * @returns A function that, when called, unsubscribes the handler + * + * @example + * ```typescript + * const unsubscribe = session.on((event) => { + * switch (event.type) { + * case "assistant.message": + * console.log("Assistant:", event.data.content); + * break; + * case "session.error": + * console.error("Error:", event.data.message); + * break; + * } + * }); + * + * // Later, to stop receiving events: + * unsubscribe(); + * ``` + */ + on(handler: SessionEventHandler): () => void { + this.eventHandlers.add(handler); + return () => { + this.eventHandlers.delete(handler); + }; + } + + /** + * Dispatches an event to all registered handlers. + * + * @param event - The session event to dispatch + * @internal This method is for internal use by the SDK. + */ + _dispatchEvent(event: SessionEvent): void { + for (const handler of this.eventHandlers) { + try { + handler(event); + } catch (_error) { + // Handler error + } + } + } + + /** + * Registers custom tool handlers for this session. + * + * Tools allow the assistant to execute custom functions. When the assistant + * invokes a tool, the corresponding handler is called with the tool arguments. + * + * @param tools - An array of tool definitions with their handlers, or undefined to clear all tools + * @internal This method is typically called internally when creating a session with tools. + */ + registerTools(tools?: Tool[]): void { + this.toolHandlers.clear(); + if (!tools) { + return; + } + + for (const tool of tools) { + this.toolHandlers.set(tool.name, tool.handler); + } + } + + /** + * Retrieves a registered tool handler by name. + * + * @param name - The name of the tool to retrieve + * @returns The tool handler if found, or undefined + * @internal This method is for internal use by the SDK. + */ + getToolHandler(name: string): ToolHandler | undefined { + return this.toolHandlers.get(name); + } + + /** + * Registers a handler for permission requests. + * + * When the assistant needs permission to perform certain actions (e.g., file operations), + * this handler is called to approve or deny the request. + * + * @param handler - The permission handler function, or undefined to remove the handler + * @internal This method is typically called internally when creating a session. + */ + registerPermissionHandler(handler?: PermissionHandler): void { + this.permissionHandler = handler; + } + + /** + * Handles a permission request from the Copilot CLI. + * + * @param request - The permission request data from the CLI + * @returns A promise that resolves with the permission decision + * @internal This method is for internal use by the SDK. + */ + async _handlePermissionRequest(request: unknown): Promise { + if (!this.permissionHandler) { + // No handler registered, deny permission + return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; + } + + try { + const result = await this.permissionHandler(request as PermissionRequest, { + sessionId: this.sessionId, + }); + return result; + } catch (_error) { + // Handler failed, deny permission + return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; + } + } + + /** + * Retrieves all events and messages from this session's history. + * + * This returns the complete conversation history including user messages, + * assistant responses, tool executions, and other session events. + * + * @returns A promise that resolves with an array of all session events + * @throws Error if the session has been destroyed or the connection fails + * + * @example + * ```typescript + * const events = await session.getMessages(); + * for (const event of events) { + * if (event.type === "assistant.message") { + * console.log("Assistant:", event.data.content); + * } + * } + * ``` + */ + async getMessages(): Promise { + const response = await this.connection.sendRequest("session.getMessages", { + sessionId: this.sessionId, + }); + + return (response as { events: SessionEvent[] }).events; + } + + /** + * Destroys this session and releases all associated resources. + * + * After calling this method, the session can no longer be used. All event + * handlers and tool handlers are cleared. To continue the conversation, + * use {@link CopilotClient.resumeSession} with the session ID. + * + * @returns A promise that resolves when the session is destroyed + * @throws Error if the connection fails + * + * @example + * ```typescript + * // Clean up when done + * await session.destroy(); + * ``` + */ + async destroy(): Promise { + await this.connection.sendRequest("session.destroy", { + sessionId: this.sessionId, + }); + this.eventHandlers.clear(); + this.toolHandlers.clear(); + this.permissionHandler = undefined; + } + + /** + * Aborts the currently processing message in this session. + * + * Use this to cancel a long-running request. The session remains valid + * and can continue to be used for new messages. + * + * @returns A promise that resolves when the abort request is acknowledged + * @throws Error if the session has been destroyed or the connection fails + * + * @example + * ```typescript + * // Start a long-running request + * const messagePromise = session.send({ prompt: "Write a very long story..." }); + * + * // Abort after 5 seconds + * setTimeout(async () => { + * await session.abort(); + * }, 5000); + * ``` + */ + async abort(): Promise { + await this.connection.sendRequest("session.abort", { + sessionId: this.sessionId, + }); + } +} diff --git a/nodejs/test/session.test.ts b/nodejs/test/session.test.ts new file mode 100644 index 00000000..39e76baa --- /dev/null +++ b/nodejs/test/session.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MessageConnection } from "vscode-jsonrpc/node"; +import { CopilotSession } from "../src/session.js"; + +describe("CopilotSession", () => { + describe("sendAndWait", () => { + let mockConnection: MessageConnection; + let session: CopilotSession; + + beforeEach(() => { + mockConnection = { + sendRequest: vi.fn().mockResolvedValue({ messageId: "test-msg-id" }), + } as unknown as MessageConnection; + session = new CopilotSession("test-session-id", mockConnection); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should clear timeout timer when session.idle is received", async () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + + // Simulate session.idle event after a short delay + setTimeout(() => { + session._dispatchEvent({ type: "session.idle", data: {} } as any); + }, 10); + + await session.sendAndWait({ prompt: "test" }, 5000); + + // Verify clearTimeout was called (timer cleanup) + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it("should clear timeout timer when session.error is received", async () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + + // Simulate session.error event after a short delay + setTimeout(() => { + session._dispatchEvent({ + type: "session.error", + data: { message: "Test error", stack: "" }, + } as any); + }, 10); + + await expect(session.sendAndWait({ prompt: "test" }, 5000)).rejects.toThrow( + "Test error" + ); + + // Verify clearTimeout was called (timer cleanup) + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it("should return last assistant message when session.idle is received", async () => { + // Simulate assistant message followed by session.idle + setTimeout(() => { + session._dispatchEvent({ + type: "assistant.message", + data: { content: "Hello!" }, + } as any); + session._dispatchEvent({ type: "session.idle", data: {} } as any); + }, 10); + + const result = await session.sendAndWait({ prompt: "test" }, 5000); + + expect(result).toMatchObject({ + type: "assistant.message", + data: { content: "Hello!" }, + }); + }); + + it("should throw timeout error when timeout expires", async () => { + // Don't send any events, let it timeout + await expect(session.sendAndWait({ prompt: "test" }, 50)).rejects.toThrow( + /Timeout after 50ms waiting for session.idle/ + ); + }); + }); +}); From ceacab1ebb9a42880ea8b8d6b04d98fc6dd8cc2f Mon Sep 17 00:00:00 2001 From: vivganes Date: Tue, 27 Jan 2026 06:38:00 +0530 Subject: [PATCH 2/2] fix review comment --- nodejs/src/session.ts | 6 ++++-- nodejs/test/session.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 18d15c39..0b91b9d2 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -141,7 +141,7 @@ export class CopilotSession { }); let lastAssistantMessage: AssistantMessageEvent | undefined; - let timeoutId: ReturnType; + let timeoutId: ReturnType | undefined; // Register event handler BEFORE calling send to avoid race condition // where session.idle fires before we start listening @@ -175,7 +175,9 @@ export class CopilotSession { return lastAssistantMessage; } finally { - clearTimeout(timeoutId!); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } unsubscribe(); } } diff --git a/nodejs/test/session.test.ts b/nodejs/test/session.test.ts index 39e76baa..de849c07 100644 --- a/nodejs/test/session.test.ts +++ b/nodejs/test/session.test.ts @@ -75,5 +75,19 @@ describe("CopilotSession", () => { /Timeout after 50ms waiting for session.idle/ ); }); + + it("should handle send() throwing before timeout is created", async () => { + // Make send() throw an error + mockConnection.sendRequest = vi.fn().mockRejectedValue(new Error("Send failed")); + + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + + await expect(session.sendAndWait({ prompt: "test" }, 5000)).rejects.toThrow( + "Send failed" + ); + + // clearTimeout should not be called since timeout was never created + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + }); }); });