From 3d11cffe8c874495e121a99cf6f1f3dffd64b5db Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 25 Mar 2026 15:28:55 +0000 Subject: [PATCH 01/10] feat: add session data store support to TypeScript SDK - Add sessionDataStore option to CopilotClientOptions - Extend codegen to generate client API handler types (SessionDataStoreHandler) - Register as session data storage provider on connection via sessionDataStore.setDataStore RPC - Add E2E tests for persist, resume, list, delete, and reject scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 25 +- nodejs/src/generated/rpc.ts | 127 +++++++++ nodejs/src/index.ts | 3 + nodejs/src/types.ts | 37 +++ nodejs/test/e2e/session_store.test.ts | 251 ++++++++++++++++++ package-lock.json | 6 + scripts/codegen/typescript.ts | 130 ++++++++- scripts/codegen/utils.ts | 3 +- ..._support_multiple_concurrent_sessions.yaml | 8 +- ...call_ondelete_when_deleting_a_session.yaml | 10 + ...uld_list_sessions_from_the_data_store.yaml | 10 + ...st_sessions_from_the_storage_provider.yaml | 10 + ...rom_a_client_supplied_store_on_resume.yaml | 14 + ...ould_load_events_from_store_on_resume.yaml | 14 + ...ist_events_to_a_client_supplied_store.yaml | 10 + ...datastore_when_sessions_already_exist.yaml | 19 ++ ...etstorageprovider_when_sessions_exist.yaml | 34 +++ ...lient_supplied_store_for_listsessions.yaml | 10 + 18 files changed, 705 insertions(+), 16 deletions(-) create mode 100644 nodejs/test/e2e/session_store.test.ts create mode 100644 package-lock.json create mode 100644 test/snapshots/session_store/should_call_ondelete_when_deleting_a_session.yaml create mode 100644 test/snapshots/session_store/should_list_sessions_from_the_data_store.yaml create mode 100644 test/snapshots/session_store/should_list_sessions_from_the_storage_provider.yaml create mode 100644 test/snapshots/session_store/should_load_events_from_a_client_supplied_store_on_resume.yaml create mode 100644 test/snapshots/session_store/should_load_events_from_store_on_resume.yaml create mode 100644 test/snapshots/session_store/should_persist_events_to_a_client_supplied_store.yaml create mode 100644 test/snapshots/session_store/should_reject_sessiondatastore_when_sessions_already_exist.yaml create mode 100644 test/snapshots/session_store/should_reject_setstorageprovider_when_sessions_exist.yaml create mode 100644 test/snapshots/session_store/should_use_client_supplied_store_for_listsessions.yaml diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index f18b70f42..b81136d90 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -24,7 +24,7 @@ import { StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; -import { createServerRpc } from "./generated/rpc.js"; +import { createServerRpc, registerClientApiHandlers } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js"; import { getTraceContext } from "./telemetry.js"; @@ -46,6 +46,7 @@ import type { SessionListFilter, SessionMetadata, SystemMessageCustomizeConfig, + SessionDataStoreConfig, TelemetryConfig, Tool, ToolCallRequestPayload, @@ -216,6 +217,7 @@ export class CopilotClient { | "onListModels" | "telemetry" | "onGetTraceContext" + | "sessionDataStore" > > & { cliPath?: string; @@ -238,6 +240,8 @@ export class CopilotClient { private _rpc: ReturnType | null = null; private processExitPromise: Promise | null = null; // Rejects when CLI process exits private negotiatedProtocolVersion: number | null = null; + /** Connection-level session data store config, set via constructor option. */ + private sessionDataStoreConfig: SessionDataStoreConfig | null = null; /** * Typed server-scoped RPC methods. @@ -307,6 +311,7 @@ export class CopilotClient { this.onListModels = options.onListModels; this.onGetTraceContext = options.onGetTraceContext; + this.sessionDataStoreConfig = options.sessionDataStore ?? null; const effectiveEnv = options.env ?? process.env; this.options = { @@ -399,6 +404,13 @@ export class CopilotClient { // Verify protocol version compatibility await this.verifyProtocolVersion(); + // If a session data store was configured, register as the storage provider + if (this.sessionDataStoreConfig) { + await this.connection!.sendRequest("sessionDataStore.setDataStore", { + descriptor: this.sessionDataStoreConfig.descriptor, + }); + } + this.state = "connected"; } catch (error) { this.state = "error"; @@ -1069,7 +1081,9 @@ export class CopilotClient { throw new Error("Client not connected"); } - const response = await this.connection.sendRequest("session.list", { filter }); + const response = await this.connection.sendRequest("session.list", { + filter, + }); const { sessions } = response as { sessions: Array<{ sessionId: string; @@ -1562,6 +1576,13 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); + // Register session data store RPC handlers if configured. + if (this.sessionDataStoreConfig) { + registerClientApiHandlers(this.connection, { + sessionDataStore: this.sessionDataStoreConfig, + }); + } + this.connection.onClose(() => { this.state = "disconnected"; }); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index dadb9e79d..ebfee9bfb 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -179,6 +179,20 @@ export interface AccountGetQuotaResult { }; } +export interface SessionDataStoreSetDataStoreResult { + /** + * Whether the data store was set successfully + */ + success: boolean; +} + +export interface SessionDataStoreSetDataStoreParams { + /** + * Opaque descriptor identifying the storage backend (e.g., 'redis://localhost/sessions') + */ + descriptor: string; +} + export interface SessionModelGetCurrentResult { /** * Currently active model identifier @@ -1050,6 +1064,78 @@ export interface SessionShellKillParams { signal?: "SIGTERM" | "SIGKILL" | "SIGINT"; } +export interface SessionDataStoreLoadResult { + /** + * All persisted events for the session, in order + */ + events: { + [k: string]: unknown; + }[]; +} + +export interface SessionDataStoreLoadParams { + /** + * The session to load events for + */ + sessionId: string; +} + +export interface SessionDataStoreAppendParams { + /** + * The session to append events to + */ + sessionId: string; + /** + * Events to append, in order + */ + events: { + [k: string]: unknown; + }[]; +} + +export interface SessionDataStoreTruncateResult { + /** + * Number of events removed + */ + eventsRemoved: number; + /** + * Number of events kept + */ + eventsKept: number; +} + +export interface SessionDataStoreTruncateParams { + /** + * The session to truncate + */ + sessionId: string; + /** + * Event ID marking the truncation boundary (excluded) + */ + upToEventId: string; +} + +export interface SessionDataStoreListResult { + sessions: { + sessionId: string; + /** + * ISO 8601 timestamp of last modification + */ + mtime: string; + /** + * ISO 8601 timestamp of creation + */ + birthtime: string; + }[]; +} + +export interface SessionDataStoreDeleteParams { + /** + * The session to delete + */ + sessionId: string; +} + /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { @@ -1067,6 +1153,10 @@ export function createServerRpc(connection: MessageConnection) { getQuota: async (): Promise => connection.sendRequest("account.getQuota", {}), }, + sessionDataStore: { + setDataStore: async (params: SessionDataStoreSetDataStoreParams): Promise => + connection.sendRequest("sessionDataStore.setDataStore", params), + }, }; } @@ -1188,3 +1278,40 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin }, }; } + +/** + * Handler interface for the `sessionDataStore` client API group. + * Implement this to provide a custom sessionDataStore backend. + */ +export interface SessionDataStoreHandler { + load(params: SessionDataStoreLoadParams): Promise; + append(params: SessionDataStoreAppendParams): Promise; + truncate(params: SessionDataStoreTruncateParams): Promise; + list(): Promise; + delete(params: SessionDataStoreDeleteParams): Promise; +} + +/** All client API handler groups. Each group is optional. */ +export interface ClientApiHandlers { + sessionDataStore?: SessionDataStoreHandler; +} + +/** + * Register client API handlers on a JSON-RPC connection. + * The server calls these methods to delegate work to the client. + * Methods for unregistered groups will respond with a standard JSON-RPC + * method-not-found error. + */ +export function registerClientApiHandlers( + connection: MessageConnection, + handlers: ClientApiHandlers, +): void { + if (handlers.sessionDataStore) { + const h = handlers.sessionDataStore!; + connection.onRequest("sessionDataStore.load", (params: SessionDataStoreLoadParams) => h.load(params)); + connection.onRequest("sessionDataStore.append", (params: SessionDataStoreAppendParams) => h.append(params)); + connection.onRequest("sessionDataStore.truncate", (params: SessionDataStoreTruncateParams) => h.truncate(params)); + connection.onRequest("sessionDataStore.list", () => h.list()); + connection.onRequest("sessionDataStore.delete", (params: SessionDataStoreDeleteParams) => h.delete(params)); + } +} diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index c42935a26..5bc273356 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -56,6 +56,9 @@ export type { SessionListFilter, SessionMetadata, SessionUiApi, + SessionDataStoreConfig, + SessionDataStoreHandler, + ClientApiHandlers, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageCustomizeConfig, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 96694137d..84d30470b 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -10,6 +10,21 @@ import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; export type SessionEvent = GeneratedSessionEvent; +// Re-export generated client API types +export type { + SessionDataStoreHandler, + SessionDataStoreLoadParams, + SessionDataStoreLoadResult, + SessionDataStoreAppendParams, + SessionDataStoreTruncateParams, + SessionDataStoreTruncateResult, + SessionDataStoreListResult, + SessionDataStoreDeleteParams, + ClientApiHandlers, +} from "./generated/rpc.js"; + +import type { SessionDataStoreHandler } from "./generated/rpc.js"; + /** * Options for creating a CopilotClient */ @@ -171,6 +186,14 @@ export interface CopilotClientOptions { * ``` */ onGetTraceContext?: TraceContextProvider; + + /** + * Custom session data storage backend. + * When provided, the client registers as the session data storage provider + * on connection, routing all event persistence through these callbacks + * instead of the server's default file-based storage. + */ + sessionDataStore?: SessionDataStoreConfig; } /** @@ -1318,6 +1341,20 @@ export interface SessionContext { branch?: string; } +/** + * Configuration for a custom session data store backend. + * + * Extends the generated {@link SessionDataStoreHandler} with a `descriptor` + * that identifies the storage backend for display purposes. + */ +export interface SessionDataStoreConfig extends SessionDataStoreHandler { + /** + * Opaque descriptor identifying this storage backend. + * Used for UI display (e.g., `"redis://localhost/sessions"`). + */ + descriptor: string; +} + /** * Filter options for listing sessions */ diff --git a/nodejs/test/e2e/session_store.test.ts b/nodejs/test/e2e/session_store.test.ts new file mode 100644 index 000000000..b79db0033 --- /dev/null +++ b/nodejs/test/e2e/session_store.test.ts @@ -0,0 +1,251 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { CopilotClient } from "../../src/client.js"; +import { approveAll, type SessionEvent, type SessionDataStoreConfig } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +/** + * In-memory session event store for testing. + * Stores events in a Map keyed by sessionId, and tracks call counts + * for each operation so tests can assert they were invoked. + */ +class InMemorySessionStore { + private sessions = new Map(); + readonly calls = { + load: 0, + append: 0, + truncate: 0, + listSessions: 0, + delete: 0, + }; + + toConfig(descriptor: string): SessionDataStoreConfig { + return { + descriptor, + load: async ({ sessionId }) => { + this.calls.load++; + const events = this.sessions.get(sessionId) ?? []; + return { events: events as Record[] }; + }, + append: async ({ sessionId, events }) => { + this.calls.append++; + const existing = this.sessions.get(sessionId) ?? []; + existing.push(...(events as unknown as SessionEvent[])); + this.sessions.set(sessionId, existing); + }, + truncate: async ({ sessionId, upToEventId }) => { + this.calls.truncate++; + const existing = this.sessions.get(sessionId) ?? []; + const idx = existing.findIndex((e) => e.id === upToEventId); + if (idx === -1) { + return { eventsRemoved: 0, eventsKept: existing.length }; + } + const kept = existing.slice(idx + 1); + this.sessions.set(sessionId, kept); + return { eventsRemoved: idx + 1, eventsKept: kept.length }; + }, + list: async () => { + this.calls.listSessions++; + const now = new Date().toISOString(); + return { + sessions: Array.from(this.sessions.keys()).map((sessionId) => ({ + sessionId, + mtime: now, + birthtime: now, + })), + }; + }, + delete: async ({ sessionId }) => { + this.calls.delete++; + this.sessions.delete(sessionId); + }, + }; + } + + getEvents(sessionId: string): SessionEvent[] { + return this.sessions.get(sessionId) ?? []; + } + + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + get sessionCount(): number { + return this.sessions.size; + } +} + +// These tests require a runtime built with sessionDataStore support. +// Skip when COPILOT_CLI_PATH is not set (CI uses the published CLI which +// doesn't include this feature yet). +const runTests = process.env.COPILOT_CLI_PATH ? describe : describe.skip; + +runTests("Session Data Store", async () => { + const { env } = await createSdkTestContext(); + + it("should persist events to a client-supplied store", async () => { + const store = new InMemorySessionStore(); + const client1 = new CopilotClient({ + env, + logLevel: "error", + cliPath: process.env.COPILOT_CLI_PATH, + sessionDataStore: store.toConfig("memory://test-persist"), + }); + onTestFinished(() => client1.forceStop()); + + const session = await client1.createSession({ + onPermissionRequest: approveAll, + }); + + // Send a message and wait for the response + const msg = await session.sendAndWait({ prompt: "What is 100 + 200?" }); + expect(msg?.data.content).toContain("300"); + + // Verify onAppend was called — events should have been routed to our store. + // The SessionWriter uses debounced flushing, so poll until events arrive. + await vi.waitFor( + () => { + const events = store.getEvents(session.sessionId); + const eventTypes = events.map((e) => e.type); + expect(eventTypes).toContain("session.start"); + expect(eventTypes).toContain("user.message"); + expect(eventTypes).toContain("assistant.message"); + }, + { timeout: 10_000, interval: 200 } + ); + expect(store.calls.append).toBeGreaterThan(0); + }); + + it("should load events from store on resume", async () => { + const store = new InMemorySessionStore(); + + const client2 = new CopilotClient({ + env, + logLevel: "error", + cliPath: process.env.COPILOT_CLI_PATH, + sessionDataStore: store.toConfig("memory://test-resume"), + }); + onTestFinished(() => client2.forceStop()); + + // Create a session and send a message + const session1 = await client2.createSession({ + onPermissionRequest: approveAll, + }); + const sessionId = session1.sessionId; + + const msg1 = await session1.sendAndWait({ prompt: "What is 50 + 50?" }); + expect(msg1?.data.content).toContain("100"); + await session1.disconnect(); + + // Verify onLoad is called when resuming + const loadCountBefore = store.calls.load; + const session2 = await client2.resumeSession(sessionId, { + onPermissionRequest: approveAll, + }); + + expect(store.calls.load).toBeGreaterThan(loadCountBefore); + + // Send another message to verify the session is functional + const msg2 = await session2.sendAndWait({ prompt: "What is that times 3?" }); + expect(msg2?.data.content).toContain("300"); + }); + + it("should list sessions from the data store", async () => { + const store = new InMemorySessionStore(); + + const client3 = new CopilotClient({ + env, + logLevel: "error", + cliPath: process.env.COPILOT_CLI_PATH, + sessionDataStore: store.toConfig("memory://test-list"), + }); + onTestFinished(() => client3.forceStop()); + + // Create a session and send a message to trigger event flushing + const session = await client3.createSession({ + onPermissionRequest: approveAll, + }); + await session.sendAndWait({ prompt: "What is 10 + 10?" }); + + // Wait for events to be flushed (debounced) + await vi.waitFor(() => expect(store.hasSession(session.sessionId)).toBe(true), { + timeout: 10_000, + interval: 200, + }); + + // List sessions — should come from our store + const sessions = await client3.listSessions(); + expect(store.calls.listSessions).toBeGreaterThan(0); + expect(sessions.some((s) => s.sessionId === session.sessionId)).toBe(true); + }); + + it("should call onDelete when deleting a session", async () => { + const store = new InMemorySessionStore(); + + const client4 = new CopilotClient({ + env, + logLevel: "error", + cliPath: process.env.COPILOT_CLI_PATH, + sessionDataStore: store.toConfig("memory://test-delete"), + }); + onTestFinished(() => client4.forceStop()); + + const session = await client4.createSession({ + onPermissionRequest: approveAll, + }); + const sessionId = session.sessionId; + + // Send a message to create some events + await session.sendAndWait({ prompt: "What is 7 + 7?" }); + + // Wait for events to flush + await vi.waitFor(() => expect(store.hasSession(sessionId)).toBe(true), { + timeout: 10_000, + interval: 200, + }); + + expect(store.calls.delete).toBe(0); + + // Delete the session + await client4.deleteSession(sessionId); + + // Verify onDelete was called and the session was removed from our store + expect(store.calls.delete).toBeGreaterThan(0); + expect(store.hasSession(sessionId)).toBe(false); + }); + + it("should reject sessionDataStore when sessions already exist", async () => { + // First client uses TCP so a second client can connect to the same runtime + const client5 = new CopilotClient({ + env, + logLevel: "error", + cliPath: process.env.COPILOT_CLI_PATH, + useStdio: false, + }); + onTestFinished(() => client5.forceStop()); + + const session = await client5.createSession({ + onPermissionRequest: approveAll, + }); + await session.sendAndWait({ prompt: "Hello" }); + + // Get the port the first client's runtime is listening on + const port = (client5 as unknown as { actualPort: number }).actualPort; + + // Second client tries to connect with a data store — should fail + // because sessions already exist on the runtime. + const store = new InMemorySessionStore(); + const client6 = new CopilotClient({ + env, + logLevel: "error", + cliUrl: `localhost:${port}`, + sessionDataStore: store.toConfig("memory://too-late"), + }); + onTestFinished(() => client6.forceStop()); + + await expect(client6.start()).rejects.toThrow(); + }); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..c3f458113 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "azure-otter", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 8d23b428f..c8f831c4e 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -86,17 +86,20 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; `); const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; + const clientMethods = collectRpcMethods(schema.client || {}); - for (const method of allMethods) { - const compiled = await compile(method.result, resultTypeName(method.rpcMethod), { - bannerComment: "", - additionalProperties: false, - }); - if (method.stability === "experimental") { - lines.push("/** @experimental */"); + for (const method of [...allMethods, ...clientMethods]) { + if (method.result) { + const compiled = await compile(method.result, resultTypeName(method.rpcMethod), { + bannerComment: "", + additionalProperties: false, + }); + if (method.stability === "experimental") { + lines.push("/** @experimental */"); + } + lines.push(compiled.trim()); + lines.push(""); } - lines.push(compiled.trim()); - lines.push(""); if (method.params?.properties && Object.keys(method.params.properties).length > 0) { const paramsCompiled = await compile(method.params, paramsTypeName(method.rpcMethod), { @@ -132,6 +135,11 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; lines.push(""); } + // Generate client API handler interfaces and registration function + if (schema.client) { + lines.push(...emitClientApiHandlers(schema.client)); + } + const outPath = await writeGeneratedFile("nodejs/src/generated/rpc.ts", lines.join("\n")); console.log(` ✓ ${outPath}`); } @@ -185,6 +193,110 @@ function emitGroup(node: Record, indent: string, isSession: boo return lines; } +// ── Client API Handler Generation ─────────────────────────────────────────── + +/** + * Collect client API methods grouped by their top-level namespace. + * Returns a map like: { sessionStore: [{ rpcMethod, params, result }, ...] } + */ +function collectClientGroups(node: Record): Map { + const groups = new Map(); + for (const [groupName, groupNode] of Object.entries(node)) { + if (typeof groupNode === "object" && groupNode !== null) { + groups.set(groupName, collectRpcMethods(groupNode as Record)); + } + } + return groups; +} + +/** + * Derive the handler method name from the full RPC method name. + * e.g., "sessionStore.load" → "load" + */ +function handlerMethodName(rpcMethod: string): string { + const parts = rpcMethod.split("."); + return parts[parts.length - 1]; +} + +/** + * Generate handler interfaces and a registration function for client API groups. + */ +function emitClientApiHandlers(clientSchema: Record): string[] { + const lines: string[] = []; + const groups = collectClientGroups(clientSchema); + + // Emit a handler interface per group + for (const [groupName, methods] of groups) { + const interfaceName = toPascalCase(groupName) + "Handler"; + lines.push(`/**`); + lines.push(` * Handler interface for the \`${groupName}\` client API group.`); + lines.push(` * Implement this to provide a custom ${groupName} backend.`); + lines.push(` */`); + lines.push(`export interface ${interfaceName} {`); + + for (const method of methods) { + const name = handlerMethodName(method.rpcMethod); + const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; + const pType = hasParams ? paramsTypeName(method.rpcMethod) : ""; + const rType = method.result ? resultTypeName(method.rpcMethod) : "void"; + + const sig = hasParams + ? ` ${name}(params: ${pType}): Promise<${rType}>;` + : ` ${name}(): Promise<${rType}>;`; + lines.push(sig); + } + + lines.push(`}`); + lines.push(""); + } + + // Emit combined ClientApiHandlers type + lines.push(`/** All client API handler groups. Each group is optional. */`); + lines.push(`export interface ClientApiHandlers {`); + for (const [groupName] of groups) { + const interfaceName = toPascalCase(groupName) + "Handler"; + lines.push(` ${groupName}?: ${interfaceName};`); + } + lines.push(`}`); + lines.push(""); + + // Emit registration function + lines.push(`/**`); + lines.push(` * Register client API handlers on a JSON-RPC connection.`); + lines.push(` * The server calls these methods to delegate work to the client.`); + lines.push(` * Methods for unregistered groups will respond with a standard JSON-RPC`); + lines.push(` * method-not-found error.`); + lines.push(` */`); + lines.push(`export function registerClientApiHandlers(`); + lines.push(` connection: MessageConnection,`); + lines.push(` handlers: ClientApiHandlers,`); + lines.push(`): void {`); + + for (const [groupName, methods] of groups) { + lines.push(` if (handlers.${groupName}) {`); + lines.push(` const h = handlers.${groupName}!;`); + + for (const method of methods) { + const name = handlerMethodName(method.rpcMethod); + const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; + const pType = hasParams ? paramsTypeName(method.rpcMethod) : ""; + + if (hasParams) { + lines.push(` connection.onRequest("${method.rpcMethod}", (params: ${pType}) => h.${name}(params));`); + } else { + lines.push(` connection.onRequest("${method.rpcMethod}", () => h.${name}());`); + } + } + + lines.push(` }`); + } + + lines.push(`}`); + lines.push(""); + + return lines; +} + // ── Main ──────────────────────────────────────────────────────────────────── async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 2c13b1d96..bc508e240 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -125,13 +125,14 @@ export async function writeGeneratedFile(relativePath: string, content: string): export interface RpcMethod { rpcMethod: string; params: JSONSchema7 | null; - result: JSONSchema7; + result: JSONSchema7 | null; stability?: string; } export interface ApiSchema { server?: Record; session?: Record; + client?: Record; } export function isRpcMethod(node: unknown): node is RpcMethod { diff --git a/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml b/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml index cf55fcc17..fdb7ebca0 100644 --- a/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml +++ b/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml @@ -5,13 +5,13 @@ conversations: - role: system content: ${system} - role: user - content: What is 3+3? Reply with just the number. + content: What is 1+1? Reply with just the number. - role: assistant - content: "6" + content: "2" - messages: - role: system content: ${system} - role: user - content: What is 1+1? Reply with just the number. + content: What is 3+3? Reply with just the number. - role: assistant - content: "2" + content: "6" diff --git a/test/snapshots/session_store/should_call_ondelete_when_deleting_a_session.yaml b/test/snapshots/session_store/should_call_ondelete_when_deleting_a_session.yaml new file mode 100644 index 000000000..2081e76aa --- /dev/null +++ b/test/snapshots/session_store/should_call_ondelete_when_deleting_a_session.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 7 + 7? + - role: assistant + content: 7 + 7 = 14 diff --git a/test/snapshots/session_store/should_list_sessions_from_the_data_store.yaml b/test/snapshots/session_store/should_list_sessions_from_the_data_store.yaml new file mode 100644 index 000000000..3461d8aee --- /dev/null +++ b/test/snapshots/session_store/should_list_sessions_from_the_data_store.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 10 + 10? + - role: assistant + content: 10 + 10 = 20 diff --git a/test/snapshots/session_store/should_list_sessions_from_the_storage_provider.yaml b/test/snapshots/session_store/should_list_sessions_from_the_storage_provider.yaml new file mode 100644 index 000000000..3461d8aee --- /dev/null +++ b/test/snapshots/session_store/should_list_sessions_from_the_storage_provider.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 10 + 10? + - role: assistant + content: 10 + 10 = 20 diff --git a/test/snapshots/session_store/should_load_events_from_a_client_supplied_store_on_resume.yaml b/test/snapshots/session_store/should_load_events_from_a_client_supplied_store_on_resume.yaml new file mode 100644 index 000000000..4744667cd --- /dev/null +++ b/test/snapshots/session_store/should_load_events_from_a_client_supplied_store_on_resume.yaml @@ -0,0 +1,14 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 50 + 50? + - role: assistant + content: 50 + 50 = 100 + - role: user + content: What is that times 3? + - role: assistant + content: 100 × 3 = 300 diff --git a/test/snapshots/session_store/should_load_events_from_store_on_resume.yaml b/test/snapshots/session_store/should_load_events_from_store_on_resume.yaml new file mode 100644 index 000000000..4744667cd --- /dev/null +++ b/test/snapshots/session_store/should_load_events_from_store_on_resume.yaml @@ -0,0 +1,14 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 50 + 50? + - role: assistant + content: 50 + 50 = 100 + - role: user + content: What is that times 3? + - role: assistant + content: 100 × 3 = 300 diff --git a/test/snapshots/session_store/should_persist_events_to_a_client_supplied_store.yaml b/test/snapshots/session_store/should_persist_events_to_a_client_supplied_store.yaml new file mode 100644 index 000000000..455652bfd --- /dev/null +++ b/test/snapshots/session_store/should_persist_events_to_a_client_supplied_store.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 100 + 200? + - role: assistant + content: 100 + 200 = 300 diff --git a/test/snapshots/session_store/should_reject_sessiondatastore_when_sessions_already_exist.yaml b/test/snapshots/session_store/should_reject_sessiondatastore_when_sessions_already_exist.yaml new file mode 100644 index 000000000..fad18cf6f --- /dev/null +++ b/test/snapshots/session_store/should_reject_sessiondatastore_when_sessions_already_exist.yaml @@ -0,0 +1,19 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Hello + - role: assistant + content: |- + Hello! I'm GitHub Copilot CLI, ready to help you with the GitHub Copilot SDK repository. + + I can assist you with: + - Building, testing, and linting across all language SDKs (Node.js, Python, Go, .NET) + - Understanding the codebase architecture and JSON-RPC client implementation + - Adding new SDK features or E2E tests + - Running language-specific tasks or investigating issues + + What would you like to work on today? diff --git a/test/snapshots/session_store/should_reject_setstorageprovider_when_sessions_exist.yaml b/test/snapshots/session_store/should_reject_setstorageprovider_when_sessions_exist.yaml new file mode 100644 index 000000000..09d01531f --- /dev/null +++ b/test/snapshots/session_store/should_reject_setstorageprovider_when_sessions_exist.yaml @@ -0,0 +1,34 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Hello + - role: assistant + content: >- + Hello! I'm GitHub Copilot CLI, powered by claude-sonnet-4.5. I'm here to help you with software engineering + tasks in this repository. + + + I can see you're working in the **copilot-sdk/nodejs** directory, which is part of a monorepo that implements + language SDKs for connecting to the Copilot CLI via JSON-RPC. + + + How can I help you today? I can: + + - Build, test, or lint the codebase + + - Add new SDK features or E2E tests + + - Debug issues or investigate bugs + + - Explore the codebase structure + + - Generate types or run other scripts + + - And more! + + + What would you like to work on? diff --git a/test/snapshots/session_store/should_use_client_supplied_store_for_listsessions.yaml b/test/snapshots/session_store/should_use_client_supplied_store_for_listsessions.yaml new file mode 100644 index 000000000..3461d8aee --- /dev/null +++ b/test/snapshots/session_store/should_use_client_supplied_store_for_listsessions.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 10 + 10? + - role: assistant + content: 10 + 10 = 20 From 503dc805ba31a219010007f1994a2e1747a6313b Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 27 Mar 2026 17:22:28 +0000 Subject: [PATCH 02/10] feat: replace sessionDataStore with SessionFs virtual filesystem Migrate the TypeScript SDK from the event-level sessionDataStore abstraction to the general-purpose SessionFs virtual filesystem, matching the runtime's new design (copilot-agent-runtime#5432). Key changes: - Regenerate RPC types from runtime schema with sessionFs.* methods - Replace SessionDataStoreConfig with SessionFsConfig (initialCwd, sessionStatePath, conventions + 9 filesystem handler callbacks) - Client calls sessionFs.setProvider on connect (was setDataStore) - Client registers sessionFs.* RPC handlers (readFile, writeFile, appendFile, exists, stat, mkdir, readdir, rm, rename) - New E2E tests with InMemorySessionFs (filesystem-level, not events) - Remove old session_store tests and snapshots --- nodejs/src/client.ts | 26 +- nodejs/src/generated/rpc.ts | 203 ++++++++---- nodejs/src/generated/session-events.ts | 4 + nodejs/src/index.ts | 4 +- nodejs/src/types.ts | 58 ++-- nodejs/test/e2e/session_fs.test.ts | 311 ++++++++++++++++++ nodejs/test/e2e/session_store.test.ts | 251 -------------- ...sion_data_from_fs_provider_on_resume.yaml} | 0 ...provider_when_sessions_already_exist.yaml} | 4 +- ...ions_through_the_session_fs_provider.yaml} | 0 ...call_ondelete_when_deleting_a_session.yaml | 10 - ...uld_list_sessions_from_the_data_store.yaml | 10 - ...st_sessions_from_the_storage_provider.yaml | 10 - ...ould_load_events_from_store_on_resume.yaml | 14 - ...datastore_when_sessions_already_exist.yaml | 19 -- ...etstorageprovider_when_sessions_exist.yaml | 34 -- 16 files changed, 513 insertions(+), 445 deletions(-) create mode 100644 nodejs/test/e2e/session_fs.test.ts delete mode 100644 nodejs/test/e2e/session_store.test.ts rename test/snapshots/{session_store/should_load_events_from_a_client_supplied_store_on_resume.yaml => session_fs/should_load_session_data_from_fs_provider_on_resume.yaml} (100%) rename test/snapshots/{session_store/should_use_client_supplied_store_for_listsessions.yaml => session_fs/should_reject_setprovider_when_sessions_already_exist.yaml} (67%) rename test/snapshots/{session_store/should_persist_events_to_a_client_supplied_store.yaml => session_fs/should_route_file_operations_through_the_session_fs_provider.yaml} (100%) delete mode 100644 test/snapshots/session_store/should_call_ondelete_when_deleting_a_session.yaml delete mode 100644 test/snapshots/session_store/should_list_sessions_from_the_data_store.yaml delete mode 100644 test/snapshots/session_store/should_list_sessions_from_the_storage_provider.yaml delete mode 100644 test/snapshots/session_store/should_load_events_from_store_on_resume.yaml delete mode 100644 test/snapshots/session_store/should_reject_sessiondatastore_when_sessions_already_exist.yaml delete mode 100644 test/snapshots/session_store/should_reject_setstorageprovider_when_sessions_exist.yaml diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index b81136d90..06dc60540 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -46,7 +46,7 @@ import type { SessionListFilter, SessionMetadata, SystemMessageCustomizeConfig, - SessionDataStoreConfig, + SessionFsConfig, TelemetryConfig, Tool, ToolCallRequestPayload, @@ -217,7 +217,7 @@ export class CopilotClient { | "onListModels" | "telemetry" | "onGetTraceContext" - | "sessionDataStore" + | "sessionFs" > > & { cliPath?: string; @@ -240,8 +240,8 @@ export class CopilotClient { private _rpc: ReturnType | null = null; private processExitPromise: Promise | null = null; // Rejects when CLI process exits private negotiatedProtocolVersion: number | null = null; - /** Connection-level session data store config, set via constructor option. */ - private sessionDataStoreConfig: SessionDataStoreConfig | null = null; + /** Connection-level session filesystem config, set via constructor option. */ + private sessionFsConfig: SessionFsConfig | null = null; /** * Typed server-scoped RPC methods. @@ -311,7 +311,7 @@ export class CopilotClient { this.onListModels = options.onListModels; this.onGetTraceContext = options.onGetTraceContext; - this.sessionDataStoreConfig = options.sessionDataStore ?? null; + this.sessionFsConfig = options.sessionFs ?? null; const effectiveEnv = options.env ?? process.env; this.options = { @@ -404,10 +404,12 @@ export class CopilotClient { // Verify protocol version compatibility await this.verifyProtocolVersion(); - // If a session data store was configured, register as the storage provider - if (this.sessionDataStoreConfig) { - await this.connection!.sendRequest("sessionDataStore.setDataStore", { - descriptor: this.sessionDataStoreConfig.descriptor, + // If a session filesystem provider was configured, register it + if (this.sessionFsConfig) { + await this.connection!.sendRequest("sessionFs.setProvider", { + initialCwd: this.sessionFsConfig.initialCwd, + sessionStatePath: this.sessionFsConfig.sessionStatePath, + conventions: this.sessionFsConfig.conventions, }); } @@ -1576,10 +1578,10 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); - // Register session data store RPC handlers if configured. - if (this.sessionDataStoreConfig) { + // Register session filesystem RPC handlers if configured. + if (this.sessionFsConfig) { registerClientApiHandlers(this.connection, { - sessionDataStore: this.sessionDataStoreConfig, + sessionFs: this.sessionFsConfig, }); } diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index ebfee9bfb..8e1c7029a 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -179,18 +179,26 @@ export interface AccountGetQuotaResult { }; } -export interface SessionDataStoreSetDataStoreResult { +export interface SessionFsSetProviderResult { /** - * Whether the data store was set successfully + * Whether the provider was set successfully */ success: boolean; } -export interface SessionDataStoreSetDataStoreParams { +export interface SessionFsSetProviderParams { /** - * Opaque descriptor identifying the storage backend (e.g., 'redis://localhost/sessions') + * Initial working directory for sessions */ - descriptor: string; + initialCwd: string; + /** + * Path within each session's SessionFs where the runtime stores files for that session + */ + sessionStatePath: string; + /** + * Path conventions used by this filesystem + */ + conventions: "windows" | "linux"; } export interface SessionModelGetCurrentResult { @@ -1064,76 +1072,143 @@ export interface SessionShellKillParams { signal?: "SIGTERM" | "SIGKILL" | "SIGINT"; } -export interface SessionDataStoreLoadResult { +export interface SessionFsReadFileResult { /** - * All persisted events for the session, in order + * File content as UTF-8 string */ - events: { - [k: string]: unknown; - }[]; + content: string; } -export interface SessionDataStoreLoadParams { +export interface SessionFsReadFileParams { /** - * The session to load events for + * Target session identifier */ sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; } -export interface SessionDataStoreAppendParams { +export interface SessionFsWriteFileParams { /** - * The session to append events to + * Target session identifier */ sessionId: string; /** - * Events to append, in order + * Path using SessionFs conventions */ - events: { - [k: string]: unknown; - }[]; + path: string; + /** + * Content to write + */ + content: string; + /** + * Optional POSIX-style mode for newly created files + */ + mode?: number; } -export interface SessionDataStoreTruncateResult { +export interface SessionFsAppendFileParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; /** - * Number of events removed + * Content to append */ - eventsRemoved: number; + content: string; /** - * Number of events kept + * Optional POSIX-style mode for newly created files */ - eventsKept: number; + mode?: number; +} + +export interface SessionFsExistsResult { + exists: boolean; } -export interface SessionDataStoreTruncateParams { +export interface SessionFsExistsParams { /** - * The session to truncate + * Target session identifier */ sessionId: string; /** - * Event ID marking the truncation boundary (excluded) + * Path using SessionFs conventions */ - upToEventId: string; + path: string; } -export interface SessionDataStoreListResult { - sessions: { - sessionId: string; - /** - * ISO 8601 timestamp of last modification - */ - mtime: string; - /** - * ISO 8601 timestamp of creation - */ - birthtime: string; - }[]; +export interface SessionFsStatResult { + isFile: boolean; + isDirectory: boolean; + size: number; + /** + * ISO 8601 timestamp of last modification + */ + mtime: string; + /** + * ISO 8601 timestamp of creation + */ + birthtime: string; } -export interface SessionDataStoreDeleteParams { +export interface SessionFsStatParams { + /** + * Target session identifier + */ + sessionId: string; /** - * The session to delete + * Path using SessionFs conventions + */ + path: string; +} + +export interface SessionFsMkdirParams { + /** + * Target session identifier + */ + sessionId: string; + path: string; + recursive?: boolean; +} + +export interface SessionFsReaddirResult { + /** + * Entry names in the directory + */ + entries: string[]; +} + +export interface SessionFsReaddirParams { + /** + * Target session identifier + */ + sessionId: string; + path: string; +} + +export interface SessionFsRmParams { + /** + * Target session identifier + */ + sessionId: string; + path: string; + recursive?: boolean; + force?: boolean; +} + +export interface SessionFsRenameParams { + /** + * Target session identifier */ sessionId: string; + src: string; + dest: string; } /** Create typed server-scoped RPC methods (no session required). */ @@ -1153,9 +1228,9 @@ export function createServerRpc(connection: MessageConnection) { getQuota: async (): Promise => connection.sendRequest("account.getQuota", {}), }, - sessionDataStore: { - setDataStore: async (params: SessionDataStoreSetDataStoreParams): Promise => - connection.sendRequest("sessionDataStore.setDataStore", params), + sessionFs: { + setProvider: async (params: SessionFsSetProviderParams): Promise => + connection.sendRequest("sessionFs.setProvider", params), }, }; } @@ -1280,20 +1355,24 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin } /** - * Handler interface for the `sessionDataStore` client API group. - * Implement this to provide a custom sessionDataStore backend. + * Handler interface for the `sessionFs` client API group. + * Implement this to provide a custom sessionFs backend. */ -export interface SessionDataStoreHandler { - load(params: SessionDataStoreLoadParams): Promise; - append(params: SessionDataStoreAppendParams): Promise; - truncate(params: SessionDataStoreTruncateParams): Promise; - list(): Promise; - delete(params: SessionDataStoreDeleteParams): Promise; +export interface SessionFsHandler { + readFile(params: SessionFsReadFileParams): Promise; + writeFile(params: SessionFsWriteFileParams): Promise; + appendFile(params: SessionFsAppendFileParams): Promise; + exists(params: SessionFsExistsParams): Promise; + stat(params: SessionFsStatParams): Promise; + mkdir(params: SessionFsMkdirParams): Promise; + readdir(params: SessionFsReaddirParams): Promise; + rm(params: SessionFsRmParams): Promise; + rename(params: SessionFsRenameParams): Promise; } /** All client API handler groups. Each group is optional. */ export interface ClientApiHandlers { - sessionDataStore?: SessionDataStoreHandler; + sessionFs?: SessionFsHandler; } /** @@ -1306,12 +1385,16 @@ export function registerClientApiHandlers( connection: MessageConnection, handlers: ClientApiHandlers, ): void { - if (handlers.sessionDataStore) { - const h = handlers.sessionDataStore!; - connection.onRequest("sessionDataStore.load", (params: SessionDataStoreLoadParams) => h.load(params)); - connection.onRequest("sessionDataStore.append", (params: SessionDataStoreAppendParams) => h.append(params)); - connection.onRequest("sessionDataStore.truncate", (params: SessionDataStoreTruncateParams) => h.truncate(params)); - connection.onRequest("sessionDataStore.list", () => h.list()); - connection.onRequest("sessionDataStore.delete", (params: SessionDataStoreDeleteParams) => h.delete(params)); + if (handlers.sessionFs) { + const h = handlers.sessionFs!; + connection.onRequest("sessionFs.readFile", (params: SessionFsReadFileParams) => h.readFile(params)); + connection.onRequest("sessionFs.writeFile", (params: SessionFsWriteFileParams) => h.writeFile(params)); + connection.onRequest("sessionFs.appendFile", (params: SessionFsAppendFileParams) => h.appendFile(params)); + connection.onRequest("sessionFs.exists", (params: SessionFsExistsParams) => h.exists(params)); + connection.onRequest("sessionFs.stat", (params: SessionFsStatParams) => h.stat(params)); + connection.onRequest("sessionFs.mkdir", (params: SessionFsMkdirParams) => h.mkdir(params)); + connection.onRequest("sessionFs.readdir", (params: SessionFsReaddirParams) => h.readdir(params)); + connection.onRequest("sessionFs.rm", (params: SessionFsRmParams) => h.rm(params)); + connection.onRequest("sessionFs.rename", (params: SessionFsRenameParams) => h.rename(params)); } } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 91dc023e9..8a6bec680 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -91,6 +91,10 @@ export type SessionEvent = * Whether the session was already in use by another client at start time */ alreadyInUse?: boolean; + /** + * Whether this session supports remote steering via Mission Control + */ + steerable?: boolean; }; } | { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 5bc273356..f9c38045e 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -56,8 +56,8 @@ export type { SessionListFilter, SessionMetadata, SessionUiApi, - SessionDataStoreConfig, - SessionDataStoreHandler, + SessionFsConfig, + SessionFsHandler, ClientApiHandlers, SystemMessageAppendConfig, SystemMessageConfig, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 84d30470b..299459115 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -12,18 +12,24 @@ export type SessionEvent = GeneratedSessionEvent; // Re-export generated client API types export type { - SessionDataStoreHandler, - SessionDataStoreLoadParams, - SessionDataStoreLoadResult, - SessionDataStoreAppendParams, - SessionDataStoreTruncateParams, - SessionDataStoreTruncateResult, - SessionDataStoreListResult, - SessionDataStoreDeleteParams, + SessionFsHandler, + SessionFsReadFileParams, + SessionFsReadFileResult, + SessionFsWriteFileParams, + SessionFsAppendFileParams, + SessionFsExistsParams, + SessionFsExistsResult, + SessionFsStatParams, + SessionFsStatResult, + SessionFsMkdirParams, + SessionFsReaddirParams, + SessionFsReaddirResult, + SessionFsRmParams, + SessionFsRenameParams, ClientApiHandlers, } from "./generated/rpc.js"; -import type { SessionDataStoreHandler } from "./generated/rpc.js"; +import type { SessionFsHandler } from "./generated/rpc.js"; /** * Options for creating a CopilotClient @@ -188,12 +194,12 @@ export interface CopilotClientOptions { onGetTraceContext?: TraceContextProvider; /** - * Custom session data storage backend. - * When provided, the client registers as the session data storage provider - * on connection, routing all event persistence through these callbacks - * instead of the server's default file-based storage. + * Custom session filesystem provider. + * When provided, the client registers as the session filesystem provider + * on connection, routing all session-scoped file I/O through these callbacks + * instead of the server's default local filesystem storage. */ - sessionDataStore?: SessionDataStoreConfig; + sessionFs?: SessionFsConfig; } /** @@ -1342,17 +1348,27 @@ export interface SessionContext { } /** - * Configuration for a custom session data store backend. + * Configuration for a custom session filesystem provider. * - * Extends the generated {@link SessionDataStoreHandler} with a `descriptor` - * that identifies the storage backend for display purposes. + * Extends the generated {@link SessionFsHandler} with registration + * parameters sent to the server's `sessionFs.setProvider` call. */ -export interface SessionDataStoreConfig extends SessionDataStoreHandler { +export interface SessionFsConfig extends SessionFsHandler { /** - * Opaque descriptor identifying this storage backend. - * Used for UI display (e.g., `"redis://localhost/sessions"`). + * Initial working directory for sessions (user's project directory). */ - descriptor: string; + initialCwd: string; + + /** + * Path within each session's SessionFs where the runtime stores + * session-scoped files (events, workspace, checkpoints, etc.). + */ + sessionStatePath: string; + + /** + * Path conventions used by this filesystem provider. + */ + conventions: "windows" | "linux"; } /** diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts new file mode 100644 index 000000000..fee1e51b2 --- /dev/null +++ b/nodejs/test/e2e/session_fs.test.ts @@ -0,0 +1,311 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { CopilotClient } from "../../src/client.js"; +import { approveAll, type SessionFsConfig } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +/** + * In-memory session filesystem for testing. + * Implements the SessionFs handler interface by storing file contents + * in a nested Map structure (sessionId → path → content). + * Tracks call counts per operation for test assertions. + */ +class InMemorySessionFs { + // sessionId → path → content + private files = new Map>(); + // sessionId → Set + private dirs = new Map>(); + readonly calls = { + readFile: 0, + writeFile: 0, + appendFile: 0, + exists: 0, + stat: 0, + mkdir: 0, + readdir: 0, + rm: 0, + rename: 0, + }; + + private getSessionFiles(sessionId: string): Map { + let m = this.files.get(sessionId); + if (!m) { + m = new Map(); + this.files.set(sessionId, m); + } + return m; + } + + private getSessionDirs(sessionId: string): Set { + let s = this.dirs.get(sessionId); + if (!s) { + s = new Set(); + this.dirs.set(sessionId, s); + } + return s; + } + + /** Derive parent directory from a path (using linux conventions). */ + private parentDir(p: string): string { + const i = p.lastIndexOf("/"); + return i > 0 ? p.substring(0, i) : "/"; + } + + /** List all entry names directly under a directory path. */ + private entriesUnder(sessionId: string, dirPath: string): string[] { + const prefix = dirPath.endsWith("/") ? dirPath : dirPath + "/"; + const entries = new Set(); + + for (const p of this.getSessionFiles(sessionId).keys()) { + if (p.startsWith(prefix)) { + const rest = p.substring(prefix.length); + const name = rest.split("/")[0]; + if (name) entries.add(name); + } + } + for (const d of this.getSessionDirs(sessionId)) { + if (d.startsWith(prefix)) { + const rest = d.substring(prefix.length); + const name = rest.split("/")[0]; + if (name) entries.add(name); + } + } + return [...entries]; + } + + toConfig(initialCwd: string, sessionStatePath: string): SessionFsConfig { + return { + initialCwd, + sessionStatePath, + conventions: "linux", + readFile: async ({ sessionId, path }) => { + this.calls.readFile++; + const content = this.getSessionFiles(sessionId).get(path); + if (content === undefined) { + throw new Error(`ENOENT: ${path}`); + } + return { content }; + }, + writeFile: async ({ sessionId, path, content }) => { + this.calls.writeFile++; + this.getSessionFiles(sessionId).set(path, content); + }, + appendFile: async ({ sessionId, path, content }) => { + this.calls.appendFile++; + const files = this.getSessionFiles(sessionId); + files.set(path, (files.get(path) ?? "") + content); + }, + exists: async ({ sessionId, path }) => { + this.calls.exists++; + const files = this.getSessionFiles(sessionId); + const dirs = this.getSessionDirs(sessionId); + return { exists: files.has(path) || dirs.has(path) }; + }, + stat: async ({ sessionId, path }) => { + this.calls.stat++; + const files = this.getSessionFiles(sessionId); + const dirs = this.getSessionDirs(sessionId); + const now = new Date().toISOString(); + + if (files.has(path)) { + return { + isFile: true, + isDirectory: false, + size: Buffer.byteLength(files.get(path)!), + mtime: now, + birthtime: now, + }; + } + if (dirs.has(path)) { + return { + isFile: false, + isDirectory: true, + size: 0, + mtime: now, + birthtime: now, + }; + } + throw new Error(`ENOENT: ${path}`); + }, + mkdir: async ({ sessionId, path, recursive }) => { + this.calls.mkdir++; + const dirs = this.getSessionDirs(sessionId); + if (recursive) { + // Create all ancestors + let current = path; + while (current && current !== "/") { + dirs.add(current); + current = this.parentDir(current); + } + } else { + dirs.add(path); + } + }, + readdir: async ({ sessionId, path }) => { + this.calls.readdir++; + return { entries: this.entriesUnder(sessionId, path) }; + }, + rm: async ({ sessionId, path, recursive }) => { + this.calls.rm++; + const files = this.getSessionFiles(sessionId); + const dirs = this.getSessionDirs(sessionId); + if (recursive) { + const prefix = path.endsWith("/") ? path : path + "/"; + for (const p of [...files.keys()]) { + if (p === path || p.startsWith(prefix)) files.delete(p); + } + for (const d of [...dirs]) { + if (d === path || d.startsWith(prefix)) dirs.delete(d); + } + } else { + files.delete(path); + dirs.delete(path); + } + }, + rename: async ({ sessionId, src, dest }) => { + this.calls.rename++; + const files = this.getSessionFiles(sessionId); + const content = files.get(src); + if (content !== undefined) { + files.delete(src); + files.set(dest, content); + } + }, + }; + } + + /** Get all file paths for a session. */ + getFilePaths(sessionId: string): string[] { + return [...(this.files.get(sessionId)?.keys() ?? [])]; + } + + /** Get content of a specific file. */ + getFileContent(sessionId: string, path: string): string | undefined { + return this.files.get(sessionId)?.get(path); + } + + /** Check whether any files exist for a given session. */ + hasSession(sessionId: string): boolean { + const files = this.files.get(sessionId); + return files !== undefined && files.size > 0; + } + + /** Get the number of sessions with files. */ + get sessionCount(): number { + let count = 0; + for (const files of this.files.values()) { + if (files.size > 0) count++; + } + return count; + } +} + +// These tests require a runtime built with SessionFs support. +// Skip when COPILOT_CLI_PATH is not set (CI uses the published CLI which +// doesn't include this feature yet). +const runTests = process.env.COPILOT_CLI_PATH ? describe : describe.skip; + +runTests("Session Fs", async () => { + const { env } = await createSdkTestContext(); + + it("should route file operations through the session fs provider", async () => { + const fs = new InMemorySessionFs(); + const client1 = new CopilotClient({ + env, + logLevel: "error", + cliPath: process.env.COPILOT_CLI_PATH, + sessionFs: fs.toConfig("/projects/test", "/session-state"), + }); + onTestFinished(() => client1.forceStop()); + + const session = await client1.createSession({ + onPermissionRequest: approveAll, + }); + + // Send a message and wait for the response + const msg = await session.sendAndWait({ prompt: "What is 100 + 200?" }); + expect(msg?.data.content).toContain("300"); + + // Verify file operations were routed through our fs provider. + // The runtime writes events as JSONL through appendFile/writeFile. + await vi.waitFor( + () => { + const paths = fs.getFilePaths(session.sessionId); + const hasEvents = paths.some((p) => p.includes("events")); + expect(hasEvents).toBe(true); + }, + { timeout: 10_000, interval: 200 }, + ); + expect(fs.calls.writeFile + fs.calls.appendFile).toBeGreaterThan(0); + expect(fs.calls.mkdir).toBeGreaterThan(0); + }); + + it("should load session data from fs provider on resume", async () => { + const sessionFs = new InMemorySessionFs(); + + const client2 = new CopilotClient({ + env, + logLevel: "error", + cliPath: process.env.COPILOT_CLI_PATH, + sessionFs: sessionFs.toConfig("/projects/test", "/session-state"), + }); + onTestFinished(() => client2.forceStop()); + + // Create a session and send a message + const session1 = await client2.createSession({ + onPermissionRequest: approveAll, + }); + const sessionId = session1.sessionId; + + const msg1 = await session1.sendAndWait({ prompt: "What is 50 + 50?" }); + expect(msg1?.data.content).toContain("100"); + await session1.disconnect(); + + // Verify readFile is called when resuming (to load events) + const readCountBefore = sessionFs.calls.readFile; + const session2 = await client2.resumeSession(sessionId, { + onPermissionRequest: approveAll, + }); + + expect(sessionFs.calls.readFile).toBeGreaterThan(readCountBefore); + + // Send another message to verify the session is functional + const msg2 = await session2.sendAndWait({ prompt: "What is that times 3?" }); + expect(msg2?.data.content).toContain("300"); + }); + + it("should reject setProvider when sessions already exist", async () => { + // First client uses TCP so a second client can connect to the same runtime + const client5 = new CopilotClient({ + env, + logLevel: "error", + cliPath: process.env.COPILOT_CLI_PATH, + useStdio: false, + }); + onTestFinished(() => client5.forceStop()); + + const session = await client5.createSession({ + onPermissionRequest: approveAll, + }); + await session.sendAndWait({ prompt: "Hello" }); + + // Get the port the first client's runtime is listening on + const port = (client5 as unknown as { actualPort: number }).actualPort; + + // Second client tries to connect with a session fs — should fail + // because sessions already exist on the runtime. + const sessionFs = new InMemorySessionFs(); + const client6 = new CopilotClient({ + env, + logLevel: "error", + cliUrl: `localhost:${port}`, + sessionFs: sessionFs.toConfig("/projects/test", "/session-state"), + }); + onTestFinished(() => client6.forceStop()); + + await expect(client6.start()).rejects.toThrow(); + }); +}); diff --git a/nodejs/test/e2e/session_store.test.ts b/nodejs/test/e2e/session_store.test.ts deleted file mode 100644 index b79db0033..000000000 --- a/nodejs/test/e2e/session_store.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -import { describe, expect, it, onTestFinished, vi } from "vitest"; -import { CopilotClient } from "../../src/client.js"; -import { approveAll, type SessionEvent, type SessionDataStoreConfig } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; - -/** - * In-memory session event store for testing. - * Stores events in a Map keyed by sessionId, and tracks call counts - * for each operation so tests can assert they were invoked. - */ -class InMemorySessionStore { - private sessions = new Map(); - readonly calls = { - load: 0, - append: 0, - truncate: 0, - listSessions: 0, - delete: 0, - }; - - toConfig(descriptor: string): SessionDataStoreConfig { - return { - descriptor, - load: async ({ sessionId }) => { - this.calls.load++; - const events = this.sessions.get(sessionId) ?? []; - return { events: events as Record[] }; - }, - append: async ({ sessionId, events }) => { - this.calls.append++; - const existing = this.sessions.get(sessionId) ?? []; - existing.push(...(events as unknown as SessionEvent[])); - this.sessions.set(sessionId, existing); - }, - truncate: async ({ sessionId, upToEventId }) => { - this.calls.truncate++; - const existing = this.sessions.get(sessionId) ?? []; - const idx = existing.findIndex((e) => e.id === upToEventId); - if (idx === -1) { - return { eventsRemoved: 0, eventsKept: existing.length }; - } - const kept = existing.slice(idx + 1); - this.sessions.set(sessionId, kept); - return { eventsRemoved: idx + 1, eventsKept: kept.length }; - }, - list: async () => { - this.calls.listSessions++; - const now = new Date().toISOString(); - return { - sessions: Array.from(this.sessions.keys()).map((sessionId) => ({ - sessionId, - mtime: now, - birthtime: now, - })), - }; - }, - delete: async ({ sessionId }) => { - this.calls.delete++; - this.sessions.delete(sessionId); - }, - }; - } - - getEvents(sessionId: string): SessionEvent[] { - return this.sessions.get(sessionId) ?? []; - } - - hasSession(sessionId: string): boolean { - return this.sessions.has(sessionId); - } - - get sessionCount(): number { - return this.sessions.size; - } -} - -// These tests require a runtime built with sessionDataStore support. -// Skip when COPILOT_CLI_PATH is not set (CI uses the published CLI which -// doesn't include this feature yet). -const runTests = process.env.COPILOT_CLI_PATH ? describe : describe.skip; - -runTests("Session Data Store", async () => { - const { env } = await createSdkTestContext(); - - it("should persist events to a client-supplied store", async () => { - const store = new InMemorySessionStore(); - const client1 = new CopilotClient({ - env, - logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, - sessionDataStore: store.toConfig("memory://test-persist"), - }); - onTestFinished(() => client1.forceStop()); - - const session = await client1.createSession({ - onPermissionRequest: approveAll, - }); - - // Send a message and wait for the response - const msg = await session.sendAndWait({ prompt: "What is 100 + 200?" }); - expect(msg?.data.content).toContain("300"); - - // Verify onAppend was called — events should have been routed to our store. - // The SessionWriter uses debounced flushing, so poll until events arrive. - await vi.waitFor( - () => { - const events = store.getEvents(session.sessionId); - const eventTypes = events.map((e) => e.type); - expect(eventTypes).toContain("session.start"); - expect(eventTypes).toContain("user.message"); - expect(eventTypes).toContain("assistant.message"); - }, - { timeout: 10_000, interval: 200 } - ); - expect(store.calls.append).toBeGreaterThan(0); - }); - - it("should load events from store on resume", async () => { - const store = new InMemorySessionStore(); - - const client2 = new CopilotClient({ - env, - logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, - sessionDataStore: store.toConfig("memory://test-resume"), - }); - onTestFinished(() => client2.forceStop()); - - // Create a session and send a message - const session1 = await client2.createSession({ - onPermissionRequest: approveAll, - }); - const sessionId = session1.sessionId; - - const msg1 = await session1.sendAndWait({ prompt: "What is 50 + 50?" }); - expect(msg1?.data.content).toContain("100"); - await session1.disconnect(); - - // Verify onLoad is called when resuming - const loadCountBefore = store.calls.load; - const session2 = await client2.resumeSession(sessionId, { - onPermissionRequest: approveAll, - }); - - expect(store.calls.load).toBeGreaterThan(loadCountBefore); - - // Send another message to verify the session is functional - const msg2 = await session2.sendAndWait({ prompt: "What is that times 3?" }); - expect(msg2?.data.content).toContain("300"); - }); - - it("should list sessions from the data store", async () => { - const store = new InMemorySessionStore(); - - const client3 = new CopilotClient({ - env, - logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, - sessionDataStore: store.toConfig("memory://test-list"), - }); - onTestFinished(() => client3.forceStop()); - - // Create a session and send a message to trigger event flushing - const session = await client3.createSession({ - onPermissionRequest: approveAll, - }); - await session.sendAndWait({ prompt: "What is 10 + 10?" }); - - // Wait for events to be flushed (debounced) - await vi.waitFor(() => expect(store.hasSession(session.sessionId)).toBe(true), { - timeout: 10_000, - interval: 200, - }); - - // List sessions — should come from our store - const sessions = await client3.listSessions(); - expect(store.calls.listSessions).toBeGreaterThan(0); - expect(sessions.some((s) => s.sessionId === session.sessionId)).toBe(true); - }); - - it("should call onDelete when deleting a session", async () => { - const store = new InMemorySessionStore(); - - const client4 = new CopilotClient({ - env, - logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, - sessionDataStore: store.toConfig("memory://test-delete"), - }); - onTestFinished(() => client4.forceStop()); - - const session = await client4.createSession({ - onPermissionRequest: approveAll, - }); - const sessionId = session.sessionId; - - // Send a message to create some events - await session.sendAndWait({ prompt: "What is 7 + 7?" }); - - // Wait for events to flush - await vi.waitFor(() => expect(store.hasSession(sessionId)).toBe(true), { - timeout: 10_000, - interval: 200, - }); - - expect(store.calls.delete).toBe(0); - - // Delete the session - await client4.deleteSession(sessionId); - - // Verify onDelete was called and the session was removed from our store - expect(store.calls.delete).toBeGreaterThan(0); - expect(store.hasSession(sessionId)).toBe(false); - }); - - it("should reject sessionDataStore when sessions already exist", async () => { - // First client uses TCP so a second client can connect to the same runtime - const client5 = new CopilotClient({ - env, - logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, - useStdio: false, - }); - onTestFinished(() => client5.forceStop()); - - const session = await client5.createSession({ - onPermissionRequest: approveAll, - }); - await session.sendAndWait({ prompt: "Hello" }); - - // Get the port the first client's runtime is listening on - const port = (client5 as unknown as { actualPort: number }).actualPort; - - // Second client tries to connect with a data store — should fail - // because sessions already exist on the runtime. - const store = new InMemorySessionStore(); - const client6 = new CopilotClient({ - env, - logLevel: "error", - cliUrl: `localhost:${port}`, - sessionDataStore: store.toConfig("memory://too-late"), - }); - onTestFinished(() => client6.forceStop()); - - await expect(client6.start()).rejects.toThrow(); - }); -}); diff --git a/test/snapshots/session_store/should_load_events_from_a_client_supplied_store_on_resume.yaml b/test/snapshots/session_fs/should_load_session_data_from_fs_provider_on_resume.yaml similarity index 100% rename from test/snapshots/session_store/should_load_events_from_a_client_supplied_store_on_resume.yaml rename to test/snapshots/session_fs/should_load_session_data_from_fs_provider_on_resume.yaml diff --git a/test/snapshots/session_store/should_use_client_supplied_store_for_listsessions.yaml b/test/snapshots/session_fs/should_reject_setprovider_when_sessions_already_exist.yaml similarity index 67% rename from test/snapshots/session_store/should_use_client_supplied_store_for_listsessions.yaml rename to test/snapshots/session_fs/should_reject_setprovider_when_sessions_already_exist.yaml index 3461d8aee..269a80f11 100644 --- a/test/snapshots/session_store/should_use_client_supplied_store_for_listsessions.yaml +++ b/test/snapshots/session_fs/should_reject_setprovider_when_sessions_already_exist.yaml @@ -5,6 +5,6 @@ conversations: - role: system content: ${system} - role: user - content: What is 10 + 10? + content: Hello - role: assistant - content: 10 + 10 = 20 + content: Hello! How can I help you today? diff --git a/test/snapshots/session_store/should_persist_events_to_a_client_supplied_store.yaml b/test/snapshots/session_fs/should_route_file_operations_through_the_session_fs_provider.yaml similarity index 100% rename from test/snapshots/session_store/should_persist_events_to_a_client_supplied_store.yaml rename to test/snapshots/session_fs/should_route_file_operations_through_the_session_fs_provider.yaml diff --git a/test/snapshots/session_store/should_call_ondelete_when_deleting_a_session.yaml b/test/snapshots/session_store/should_call_ondelete_when_deleting_a_session.yaml deleted file mode 100644 index 2081e76aa..000000000 --- a/test/snapshots/session_store/should_call_ondelete_when_deleting_a_session.yaml +++ /dev/null @@ -1,10 +0,0 @@ -models: - - claude-sonnet-4.5 -conversations: - - messages: - - role: system - content: ${system} - - role: user - content: What is 7 + 7? - - role: assistant - content: 7 + 7 = 14 diff --git a/test/snapshots/session_store/should_list_sessions_from_the_data_store.yaml b/test/snapshots/session_store/should_list_sessions_from_the_data_store.yaml deleted file mode 100644 index 3461d8aee..000000000 --- a/test/snapshots/session_store/should_list_sessions_from_the_data_store.yaml +++ /dev/null @@ -1,10 +0,0 @@ -models: - - claude-sonnet-4.5 -conversations: - - messages: - - role: system - content: ${system} - - role: user - content: What is 10 + 10? - - role: assistant - content: 10 + 10 = 20 diff --git a/test/snapshots/session_store/should_list_sessions_from_the_storage_provider.yaml b/test/snapshots/session_store/should_list_sessions_from_the_storage_provider.yaml deleted file mode 100644 index 3461d8aee..000000000 --- a/test/snapshots/session_store/should_list_sessions_from_the_storage_provider.yaml +++ /dev/null @@ -1,10 +0,0 @@ -models: - - claude-sonnet-4.5 -conversations: - - messages: - - role: system - content: ${system} - - role: user - content: What is 10 + 10? - - role: assistant - content: 10 + 10 = 20 diff --git a/test/snapshots/session_store/should_load_events_from_store_on_resume.yaml b/test/snapshots/session_store/should_load_events_from_store_on_resume.yaml deleted file mode 100644 index 4744667cd..000000000 --- a/test/snapshots/session_store/should_load_events_from_store_on_resume.yaml +++ /dev/null @@ -1,14 +0,0 @@ -models: - - claude-sonnet-4.5 -conversations: - - messages: - - role: system - content: ${system} - - role: user - content: What is 50 + 50? - - role: assistant - content: 50 + 50 = 100 - - role: user - content: What is that times 3? - - role: assistant - content: 100 × 3 = 300 diff --git a/test/snapshots/session_store/should_reject_sessiondatastore_when_sessions_already_exist.yaml b/test/snapshots/session_store/should_reject_sessiondatastore_when_sessions_already_exist.yaml deleted file mode 100644 index fad18cf6f..000000000 --- a/test/snapshots/session_store/should_reject_sessiondatastore_when_sessions_already_exist.yaml +++ /dev/null @@ -1,19 +0,0 @@ -models: - - claude-sonnet-4.5 -conversations: - - messages: - - role: system - content: ${system} - - role: user - content: Hello - - role: assistant - content: |- - Hello! I'm GitHub Copilot CLI, ready to help you with the GitHub Copilot SDK repository. - - I can assist you with: - - Building, testing, and linting across all language SDKs (Node.js, Python, Go, .NET) - - Understanding the codebase architecture and JSON-RPC client implementation - - Adding new SDK features or E2E tests - - Running language-specific tasks or investigating issues - - What would you like to work on today? diff --git a/test/snapshots/session_store/should_reject_setstorageprovider_when_sessions_exist.yaml b/test/snapshots/session_store/should_reject_setstorageprovider_when_sessions_exist.yaml deleted file mode 100644 index 09d01531f..000000000 --- a/test/snapshots/session_store/should_reject_setstorageprovider_when_sessions_exist.yaml +++ /dev/null @@ -1,34 +0,0 @@ -models: - - claude-sonnet-4.5 -conversations: - - messages: - - role: system - content: ${system} - - role: user - content: Hello - - role: assistant - content: >- - Hello! I'm GitHub Copilot CLI, powered by claude-sonnet-4.5. I'm here to help you with software engineering - tasks in this repository. - - - I can see you're working in the **copilot-sdk/nodejs** directory, which is part of a monorepo that implements - language SDKs for connecting to the Copilot CLI via JSON-RPC. - - - How can I help you today? I can: - - - Build, test, or lint the codebase - - - Add new SDK features or E2E tests - - - Debug issues or investigate bugs - - - Explore the codebase structure - - - Generate types or run other scripts - - - And more! - - - What would you like to work on? From 5d7c9ec92dc71f81f384186848f09fd85468f354 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 27 Mar 2026 18:18:00 +0000 Subject: [PATCH 03/10] Test cleanup --- nodejs/test/e2e/harness/sdkTestContext.ts | 5 +- nodejs/test/e2e/session_fs.test.ts | 96 ++++++++--------------- 2 files changed, 37 insertions(+), 64 deletions(-) diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index ed505a0cb..c6d413936 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -9,7 +9,7 @@ import { basename, dirname, join, resolve } from "path"; import { rimraf } from "rimraf"; import { fileURLToPath } from "url"; import { afterAll, afterEach, beforeEach, onTestFailed, TestContext } from "vitest"; -import { CopilotClient } from "../../../src"; +import { CopilotClient, CopilotClientOptions } from "../../../src"; import { CapiProxy } from "./CapiProxy"; import { retry } from "./sdkTestHelper"; @@ -22,10 +22,12 @@ const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); export async function createSdkTestContext({ logLevel, useStdio, + copilotClientOptions, }: { logLevel?: "error" | "none" | "warning" | "info" | "debug" | "all"; cliPath?: string; useStdio?: boolean; + copilotClientOptions?: CopilotClientOptions; } = {}) { const homeDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-config-"))); const workDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-work-"))); @@ -51,6 +53,7 @@ export async function createSdkTestContext({ // Use fake token in CI to allow cached responses without real auth githubToken: isCI ? "fake-token-for-e2e-tests" : undefined, useStdio: useStdio, + ...copilotClientOptions, }); const harness = { homeDir, workDir, openAiEndpoint, copilotClient, env }; diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index fee1e51b2..b39489d54 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { beforeEach, describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient } from "../../src/client.js"; import { approveAll, type SessionFsConfig } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -30,6 +30,14 @@ class InMemorySessionFs { rename: 0, }; + public reset() { + this.files.clear(); + this.dirs.clear(); + for (const key in this.calls) { + this.calls[key as keyof typeof this.calls] = 0; + } + } + private getSessionFiles(sessionId: string): Map { let m = this.files.get(sessionId); if (!m) { @@ -203,27 +211,18 @@ class InMemorySessionFs { } } -// These tests require a runtime built with SessionFs support. -// Skip when COPILOT_CLI_PATH is not set (CI uses the published CLI which -// doesn't include this feature yet). -const runTests = process.env.COPILOT_CLI_PATH ? describe : describe.skip; - -runTests("Session Fs", async () => { - const { env } = await createSdkTestContext(); +describe("Session Fs", async () => { + const fs = new InMemorySessionFs(); + beforeEach(() => fs.reset()); - it("should route file operations through the session fs provider", async () => { - const fs = new InMemorySessionFs(); - const client1 = new CopilotClient({ - env, - logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, + const { copilotClient: client, env } = await createSdkTestContext({ + copilotClientOptions: { sessionFs: fs.toConfig("/projects/test", "/session-state"), - }); - onTestFinished(() => client1.forceStop()); + }, + }); - const session = await client1.createSession({ - onPermissionRequest: approveAll, - }); + it("should route file operations through the session fs provider", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); // Send a message and wait for the response const msg = await session.sendAndWait({ prompt: "What is 100 + 200?" }); @@ -231,46 +230,25 @@ runTests("Session Fs", async () => { // Verify file operations were routed through our fs provider. // The runtime writes events as JSONL through appendFile/writeFile. - await vi.waitFor( - () => { - const paths = fs.getFilePaths(session.sessionId); - const hasEvents = paths.some((p) => p.includes("events")); - expect(hasEvents).toBe(true); - }, - { timeout: 10_000, interval: 200 }, - ); - expect(fs.calls.writeFile + fs.calls.appendFile).toBeGreaterThan(0); - expect(fs.calls.mkdir).toBeGreaterThan(0); + // TODO: Replace these assertions with reading the events.jsonl file + await expect.poll(() => fs.calls.writeFile + fs.calls.appendFile).toBeGreaterThan(0); }); it("should load session data from fs provider on resume", async () => { - const sessionFs = new InMemorySessionFs(); - - const client2 = new CopilotClient({ - env, - logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, - sessionFs: sessionFs.toConfig("/projects/test", "/session-state"), - }); - onTestFinished(() => client2.forceStop()); - - // Create a session and send a message - const session1 = await client2.createSession({ - onPermissionRequest: approveAll, - }); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; - const msg1 = await session1.sendAndWait({ prompt: "What is 50 + 50?" }); - expect(msg1?.data.content).toContain("100"); + const msg = await session1.sendAndWait({ prompt: "What is 50 + 50?" }); + expect(msg?.data.content).toContain("100"); await session1.disconnect(); // Verify readFile is called when resuming (to load events) - const readCountBefore = sessionFs.calls.readFile; - const session2 = await client2.resumeSession(sessionId, { + const readCountBefore = fs.calls.readFile; + const session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll, }); - expect(sessionFs.calls.readFile).toBeGreaterThan(readCountBefore); + expect(fs.calls.readFile).toBeGreaterThan(readCountBefore); // Send another message to verify the session is functional const msg2 = await session2.sendAndWait({ prompt: "What is that times 3?" }); @@ -278,34 +256,26 @@ runTests("Session Fs", async () => { }); it("should reject setProvider when sessions already exist", async () => { - // First client uses TCP so a second client can connect to the same runtime - const client5 = new CopilotClient({ + const client = new CopilotClient({ + useStdio: false, // Use TCP so we can connect from a second client env, - logLevel: "error", - cliPath: process.env.COPILOT_CLI_PATH, - useStdio: false, }); - onTestFinished(() => client5.forceStop()); - - const session = await client5.createSession({ - onPermissionRequest: approveAll, - }); - await session.sendAndWait({ prompt: "Hello" }); + await client.createSession({ onPermissionRequest: approveAll }); // Get the port the first client's runtime is listening on - const port = (client5 as unknown as { actualPort: number }).actualPort; + const port = (client as unknown as { actualPort: number }).actualPort; // Second client tries to connect with a session fs — should fail // because sessions already exist on the runtime. const sessionFs = new InMemorySessionFs(); - const client6 = new CopilotClient({ + const client2 = new CopilotClient({ env, logLevel: "error", cliUrl: `localhost:${port}`, sessionFs: sessionFs.toConfig("/projects/test", "/session-state"), }); - onTestFinished(() => client6.forceStop()); + onTestFinished(() => client2.forceStop()); - await expect(client6.start()).rejects.toThrow(); + await expect(client2.start()).rejects.toThrow(); }); }); From 3631d83729a24ee35e218f412b72dfcd114bfadc Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 27 Mar 2026 19:48:25 +0000 Subject: [PATCH 04/10] Test large output handling --- nodejs/package-lock.json | 11 + nodejs/package.json | 1 + nodejs/test/e2e/session_fs.test.ts | 352 +++++++----------- test/harness/replayingCapiProxy.ts | 25 +- ..._large_output_handling_into_sessionfs.yaml | 25 ++ 5 files changed, 190 insertions(+), 224 deletions(-) create mode 100644 test/snapshots/session_fs/should_map_large_output_handling_into_sessionfs.yaml diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 42d9b6fb6..978183049 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -14,6 +14,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@platformatic/vfs": "^0.3.0", "@types/node": "^25.2.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", @@ -847,6 +848,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@platformatic/vfs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@platformatic/vfs/-/vfs-0.3.0.tgz", + "integrity": "sha512-BGXVOAz59HYPZCgI9v/MtiTF/ng8YAWtkooxVwOPR3TatNgGy0WZ/t15ScqytiZi5NdSRqWNRfuAbXKeAlKDdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 4ccda703d..241dd24db 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -61,6 +61,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@platformatic/vfs": "^0.3.0", "@types/node": "^25.2.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index b39489d54..de80ce123 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -2,236 +2,38 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { beforeEach, describe, expect, it, onTestFinished } from "vitest"; +import { MemoryProvider } from "@platformatic/vfs"; +import { describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient } from "../../src/client.js"; -import { approveAll, type SessionFsConfig } from "../../src/index.js"; +import { approveAll, defineTool, SessionEvent, type SessionFsConfig } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -/** - * In-memory session filesystem for testing. - * Implements the SessionFs handler interface by storing file contents - * in a nested Map structure (sessionId → path → content). - * Tracks call counts per operation for test assertions. - */ -class InMemorySessionFs { - // sessionId → path → content - private files = new Map>(); - // sessionId → Set - private dirs = new Map>(); - readonly calls = { - readFile: 0, - writeFile: 0, - appendFile: 0, - exists: 0, - stat: 0, - mkdir: 0, - readdir: 0, - rm: 0, - rename: 0, - }; - - public reset() { - this.files.clear(); - this.dirs.clear(); - for (const key in this.calls) { - this.calls[key as keyof typeof this.calls] = 0; - } - } - - private getSessionFiles(sessionId: string): Map { - let m = this.files.get(sessionId); - if (!m) { - m = new Map(); - this.files.set(sessionId, m); - } - return m; - } - - private getSessionDirs(sessionId: string): Set { - let s = this.dirs.get(sessionId); - if (!s) { - s = new Set(); - this.dirs.set(sessionId, s); - } - return s; - } - - /** Derive parent directory from a path (using linux conventions). */ - private parentDir(p: string): string { - const i = p.lastIndexOf("/"); - return i > 0 ? p.substring(0, i) : "/"; - } - - /** List all entry names directly under a directory path. */ - private entriesUnder(sessionId: string, dirPath: string): string[] { - const prefix = dirPath.endsWith("/") ? dirPath : dirPath + "/"; - const entries = new Set(); - - for (const p of this.getSessionFiles(sessionId).keys()) { - if (p.startsWith(prefix)) { - const rest = p.substring(prefix.length); - const name = rest.split("/")[0]; - if (name) entries.add(name); - } - } - for (const d of this.getSessionDirs(sessionId)) { - if (d.startsWith(prefix)) { - const rest = d.substring(prefix.length); - const name = rest.split("/")[0]; - if (name) entries.add(name); - } - } - return [...entries]; - } - - toConfig(initialCwd: string, sessionStatePath: string): SessionFsConfig { - return { - initialCwd, - sessionStatePath, - conventions: "linux", - readFile: async ({ sessionId, path }) => { - this.calls.readFile++; - const content = this.getSessionFiles(sessionId).get(path); - if (content === undefined) { - throw new Error(`ENOENT: ${path}`); - } - return { content }; - }, - writeFile: async ({ sessionId, path, content }) => { - this.calls.writeFile++; - this.getSessionFiles(sessionId).set(path, content); - }, - appendFile: async ({ sessionId, path, content }) => { - this.calls.appendFile++; - const files = this.getSessionFiles(sessionId); - files.set(path, (files.get(path) ?? "") + content); - }, - exists: async ({ sessionId, path }) => { - this.calls.exists++; - const files = this.getSessionFiles(sessionId); - const dirs = this.getSessionDirs(sessionId); - return { exists: files.has(path) || dirs.has(path) }; - }, - stat: async ({ sessionId, path }) => { - this.calls.stat++; - const files = this.getSessionFiles(sessionId); - const dirs = this.getSessionDirs(sessionId); - const now = new Date().toISOString(); - - if (files.has(path)) { - return { - isFile: true, - isDirectory: false, - size: Buffer.byteLength(files.get(path)!), - mtime: now, - birthtime: now, - }; - } - if (dirs.has(path)) { - return { - isFile: false, - isDirectory: true, - size: 0, - mtime: now, - birthtime: now, - }; - } - throw new Error(`ENOENT: ${path}`); - }, - mkdir: async ({ sessionId, path, recursive }) => { - this.calls.mkdir++; - const dirs = this.getSessionDirs(sessionId); - if (recursive) { - // Create all ancestors - let current = path; - while (current && current !== "/") { - dirs.add(current); - current = this.parentDir(current); - } - } else { - dirs.add(path); - } - }, - readdir: async ({ sessionId, path }) => { - this.calls.readdir++; - return { entries: this.entriesUnder(sessionId, path) }; - }, - rm: async ({ sessionId, path, recursive }) => { - this.calls.rm++; - const files = this.getSessionFiles(sessionId); - const dirs = this.getSessionDirs(sessionId); - if (recursive) { - const prefix = path.endsWith("/") ? path : path + "/"; - for (const p of [...files.keys()]) { - if (p === path || p.startsWith(prefix)) files.delete(p); - } - for (const d of [...dirs]) { - if (d === path || d.startsWith(prefix)) dirs.delete(d); - } - } else { - files.delete(path); - dirs.delete(path); - } - }, - rename: async ({ sessionId, src, dest }) => { - this.calls.rename++; - const files = this.getSessionFiles(sessionId); - const content = files.get(src); - if (content !== undefined) { - files.delete(src); - files.set(dest, content); - } - }, - }; - } - - /** Get all file paths for a session. */ - getFilePaths(sessionId: string): string[] { - return [...(this.files.get(sessionId)?.keys() ?? [])]; - } - - /** Get content of a specific file. */ - getFileContent(sessionId: string, path: string): string | undefined { - return this.files.get(sessionId)?.get(path); - } - - /** Check whether any files exist for a given session. */ - hasSession(sessionId: string): boolean { - const files = this.files.get(sessionId); - return files !== undefined && files.size > 0; - } - - /** Get the number of sessions with files. */ - get sessionCount(): number { - let count = 0; - for (const files of this.files.values()) { - if (files.size > 0) count++; - } - return count; - } -} - describe("Session Fs", async () => { - const fs = new InMemorySessionFs(); - beforeEach(() => fs.reset()); + // Single provider for the describe block — session IDs are unique per test, + // so no cross-contamination between tests. + const provider = new MemoryProvider(); + const { config } = createMemorySessionFs("/projects/test", "/session-state", provider); + + // Helpers to build session-namespaced paths for direct provider assertions + const p = (sessionId: string, path: string) => + `/${sessionId}${path.startsWith("/") ? path : "/" + path}`; const { copilotClient: client, env } = await createSdkTestContext({ copilotClientOptions: { - sessionFs: fs.toConfig("/projects/test", "/session-state"), + sessionFs: config, }, }); it("should route file operations through the session fs provider", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); - // Send a message and wait for the response const msg = await session.sendAndWait({ prompt: "What is 100 + 200?" }); expect(msg?.data.content).toContain("300"); + await session.disconnect(); - // Verify file operations were routed through our fs provider. - // The runtime writes events as JSONL through appendFile/writeFile. - // TODO: Replace these assertions with reading the events.jsonl file - await expect.poll(() => fs.calls.writeFile + fs.calls.appendFile).toBeGreaterThan(0); + const buf = await provider.readFile(p(session.sessionId, "/session-state/events.jsonl")); + const content = buf.toString("utf8"); + expect(content).toContain("300"); }); it("should load session data from fs provider on resume", async () => { @@ -242,16 +44,16 @@ describe("Session Fs", async () => { expect(msg?.data.content).toContain("100"); await session1.disconnect(); - // Verify readFile is called when resuming (to load events) - const readCountBefore = fs.calls.readFile; + // The events file should exist before resume + expect(await provider.exists(p(sessionId, "/session-state/events.jsonl"))).toBe(true); + const session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll, }); - expect(fs.calls.readFile).toBeGreaterThan(readCountBefore); - - // Send another message to verify the session is functional + // Send another message to verify the session is functional after resume const msg2 = await session2.sendAndWait({ prompt: "What is that times 3?" }); + await session2.disconnect(); expect(msg2?.data.content).toContain("300"); }); @@ -267,15 +69,123 @@ describe("Session Fs", async () => { // Second client tries to connect with a session fs — should fail // because sessions already exist on the runtime. - const sessionFs = new InMemorySessionFs(); + const { config: config2 } = createMemorySessionFs( + "/projects/test", + "/session-state", + new MemoryProvider() + ); const client2 = new CopilotClient({ env, logLevel: "error", cliUrl: `localhost:${port}`, - sessionFs: sessionFs.toConfig("/projects/test", "/session-state"), + sessionFs: config2, }); onTestFinished(() => client2.forceStop()); await expect(client2.start()).rejects.toThrow(); }); + + it("should map large output handling into sessionFs", async () => { + const suppliedFileContent = "x".repeat(100_000); + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("get_big_string", { + description: "Returns a large string", + handler: async () => suppliedFileContent, + }), + ], + }); + + await session.sendAndWait({ + prompt: "Call the get_big_string tool and reply with the word DONE only.", + }); + + // The tool result should reference a temp file under the session state path + const messages = await session.getMessages(); + const toolResult = findToolCallResult(messages, "get_big_string"); + expect(toolResult).toContain("/session-state/temp/"); + const filename = toolResult?.match(/(\/session-state\/temp\/[^\s]+)/)?.[1]; + expect(filename).toBeDefined(); + + // Verify the file was written with the correct content via the provider + const fileContent = await provider.readFile(p(session.sessionId, filename!), "utf8"); + expect(fileContent).toBe(suppliedFileContent); + }); }); + +function findToolCallResult(messages: SessionEvent[], toolName: string): string | undefined { + for (const m of messages) { + if (m.type === "tool.execution_complete") { + if (findToolName(messages, m.data.toolCallId) === toolName) { + return m.data.result?.content; + } + } + } +} + +function findToolName(messages: SessionEvent[], toolCallId: string): string | undefined { + for (const m of messages) { + if (m.type === "tool.execution_start" && m.data.toolCallId === toolCallId) { + return m.data.toolName; + } + } +} + +/** + * Builds a SessionFsConfig backed by a @platformatic/vfs MemoryProvider. + * Each sessionId is namespaced under `//` in the provider's tree. + * Tests can assert directly against the returned MemoryProvider instance. + */ +function createMemorySessionFs( + initialCwd: string, + sessionStatePath: string, + provider: MemoryProvider +): { config: SessionFsConfig } { + const sp = (sessionId: string, path: string) => + `/${sessionId}${path.startsWith("/") ? path : "/" + path}`; + + const config: SessionFsConfig = { + initialCwd, + sessionStatePath, + conventions: "linux", + readFile: async ({ sessionId, path }) => { + const content = await provider.readFile(sp(sessionId, path), "utf8"); + return { content: content as string }; + }, + writeFile: async ({ sessionId, path, content }) => { + await provider.writeFile(sp(sessionId, path), content); + }, + appendFile: async ({ sessionId, path, content }) => { + await provider.appendFile(sp(sessionId, path), content); + }, + exists: async ({ sessionId, path }) => { + return { exists: await provider.exists(sp(sessionId, path)) }; + }, + stat: async ({ sessionId, path }) => { + const st = await provider.stat(sp(sessionId, path)); + return { + isFile: st.isFile(), + isDirectory: st.isDirectory(), + size: st.size, + mtime: new Date(st.mtimeMs).toISOString(), + birthtime: new Date(st.birthtimeMs).toISOString(), + }; + }, + mkdir: async ({ sessionId, path, recursive }) => { + await provider.mkdir(sp(sessionId, path), { recursive: recursive ?? false }); + }, + readdir: async ({ sessionId, path }) => { + const entries = await provider.readdir(sp(sessionId, path)); + return { entries: entries as string[] }; + }, + rm: async ({ sessionId, path }) => { + await provider.unlink(sp(sessionId, path)); + }, + rename: async ({ sessionId, src, dest }) => { + await provider.rename(sp(sessionId, src), sp(sessionId, dest)); + }, + }; + + return { config }; +} diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index a41b93d78..53d8c2b07 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -52,6 +52,9 @@ const defaultModel = "claude-sonnet-4.5"; export class ReplayingCapiProxy extends CapturingHttpProxy { private state: ReplayingCapiProxyState | null = null; private startPromise: Promise | null = null; + private defaultToolResultNormalizers: ToolResultNormalizer[] = [ + { toolName: "*", normalizer: normalizeLargeOutputFilepaths }, + ]; /** * If true, cached responses are played back slowly (~ 2KiB/sec). Otherwise streaming responses are sent as fast as possible. @@ -70,7 +73,12 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { // skip the need to do a /config POST before other requests. This only makes // sense if the config will be static for the lifetime of the proxy. if (filePath && workDir) { - this.state = { filePath, workDir, testInfo, toolResultNormalizers: [] }; + this.state = { + filePath, + workDir, + testInfo, + toolResultNormalizers: [...this.defaultToolResultNormalizers], + }; } } @@ -96,7 +104,7 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { filePath: config.filePath, workDir: config.workDir, testInfo: config.testInfo, - toolResultNormalizers: [], + toolResultNormalizers: [...this.defaultToolResultNormalizers], }; this.clearExchanges(); @@ -592,7 +600,10 @@ function normalizeToolCalls( .find((tc) => tc.id === msg.tool_call_id); if (precedingToolCall) { for (const normalizer of resultNormalizers) { - if (precedingToolCall.function?.name === normalizer.toolName) { + if ( + precedingToolCall.function?.name === normalizer.toolName || + normalizer.toolName === "*" + ) { msg.content = normalizer.normalizer(msg.content); } } @@ -724,6 +735,14 @@ function normalizeUserMessage(content: string): string { .trim(); } +function normalizeLargeOutputFilepaths(result: string): string { + // Replaces filenames like 1774637043987-copilot-tool-output-tk7puw.txt with PLACEHOLDER-copilot-tool-output-PLACEHOLDER + return result.replace( + /\d+-copilot-tool-output-[a-z0-9.]+/g, + "PLACEHOLDER-copilot-tool-output-PLACEHOLDER", + ); +} + // Transforms a single OpenAI-style inbound response message into normalized form function transformOpenAIResponseChoice( choices: ChatCompletion.Choice[], diff --git a/test/snapshots/session_fs/should_map_large_output_handling_into_sessionfs.yaml b/test/snapshots/session_fs/should_map_large_output_handling_into_sessionfs.yaml new file mode 100644 index 000000000..e80ce51e6 --- /dev/null +++ b/test/snapshots/session_fs/should_map_large_output_handling_into_sessionfs.yaml @@ -0,0 +1,25 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Call the get_big_string tool and reply with the word DONE only. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: get_big_string + arguments: "{}" + - role: tool + tool_call_id: toolcall_0 + content: |- + Output too large to read at once (97.7 KB). Saved to: /session-state/temp/PLACEHOLDER-copilot-tool-output-PLACEHOLDER + Consider using tools like grep (for searching), head/tail (for viewing start/end), view with view_range (for specific sections), or jq (for JSON) to examine portions of the output. + + Preview (first 500 chars): + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + - role: assistant + content: DONE From fe1985cc6f1d65e3606b690722db5755a175549d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 30 Mar 2026 11:32:35 +0100 Subject: [PATCH 05/10] Expand API surface slightly --- nodejs/src/generated/rpc.ts | 26 ++++++++++++++++++++++++++ nodejs/src/types.ts | 3 +++ 2 files changed, 29 insertions(+) diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 8e1c7029a..5255ed011 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1175,6 +1175,10 @@ export interface SessionFsMkdirParams { sessionId: string; path: string; recursive?: boolean; + /** + * Optional POSIX-style mode for newly created directories + */ + mode?: number; } export interface SessionFsReaddirResult { @@ -1192,6 +1196,26 @@ export interface SessionFsReaddirParams { path: string; } +export interface SessionFsDirEntry { + name: string; + type: "file" | "directory"; +} + +export interface SessionFsReaddirWithTypesResult { + /** + * Directory entries with type information + */ + entries: SessionFsDirEntry[]; +} + +export interface SessionFsReaddirWithTypesParams { + /** + * Target session identifier + */ + sessionId: string; + path: string; +} + export interface SessionFsRmParams { /** * Target session identifier @@ -1366,6 +1390,7 @@ export interface SessionFsHandler { stat(params: SessionFsStatParams): Promise; mkdir(params: SessionFsMkdirParams): Promise; readdir(params: SessionFsReaddirParams): Promise; + readdirWithTypes(params: SessionFsReaddirWithTypesParams): Promise; rm(params: SessionFsRmParams): Promise; rename(params: SessionFsRenameParams): Promise; } @@ -1394,6 +1419,7 @@ export function registerClientApiHandlers( connection.onRequest("sessionFs.stat", (params: SessionFsStatParams) => h.stat(params)); connection.onRequest("sessionFs.mkdir", (params: SessionFsMkdirParams) => h.mkdir(params)); connection.onRequest("sessionFs.readdir", (params: SessionFsReaddirParams) => h.readdir(params)); + connection.onRequest("sessionFs.readdirWithTypes", (params: SessionFsReaddirWithTypesParams) => h.readdirWithTypes(params)); connection.onRequest("sessionFs.rm", (params: SessionFsRmParams) => h.rm(params)); connection.onRequest("sessionFs.rename", (params: SessionFsRenameParams) => h.rename(params)); } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 299459115..e92f7f87e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -24,6 +24,9 @@ export type { SessionFsMkdirParams, SessionFsReaddirParams, SessionFsReaddirResult, + SessionFsDirEntry, + SessionFsReaddirWithTypesParams, + SessionFsReaddirWithTypesResult, SessionFsRmParams, SessionFsRenameParams, ClientApiHandlers, From b2118d0a3a91c0b9d3345834b3bbaa083d8bcaca Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 30 Mar 2026 13:25:43 +0100 Subject: [PATCH 06/10] Update test --- nodejs/test/e2e/session_fs.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index de80ce123..dc1280b11 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -172,13 +172,23 @@ function createMemorySessionFs( birthtime: new Date(st.birthtimeMs).toISOString(), }; }, - mkdir: async ({ sessionId, path, recursive }) => { - await provider.mkdir(sp(sessionId, path), { recursive: recursive ?? false }); + mkdir: async ({ sessionId, path, recursive, mode }) => { + await provider.mkdir(sp(sessionId, path), { recursive: recursive ?? false, mode }); }, readdir: async ({ sessionId, path }) => { const entries = await provider.readdir(sp(sessionId, path)); return { entries: entries as string[] }; }, + readdirWithTypes: async ({ sessionId, path }) => { + const names = await provider.readdir(sp(sessionId, path)) as string[]; + const entries = await Promise.all( + names.map(async (name) => { + const st = await provider.stat(sp(sessionId, `${path}/${name}`)); + return { name, type: st.isDirectory() ? "directory" as const : "file" as const }; + }), + ); + return { entries }; + }, rm: async ({ sessionId, path }) => { await provider.unlink(sp(sessionId, path)); }, From 9193df911a7bbd0d4e134f753fed87f206ad5da2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 30 Mar 2026 15:41:21 +0100 Subject: [PATCH 07/10] Move to per-session client APIs --- nodejs/src/client.ts | 21 +++-- nodejs/src/generated/rpc.ts | 112 ++++++++++++++++++------- nodejs/src/generated/session-events.ts | 4 - nodejs/src/index.ts | 1 - nodejs/src/session.ts | 4 + nodejs/src/types.ts | 34 ++------ nodejs/test/e2e/session_fs.test.ts | 100 ++++++++++++---------- scripts/codegen/typescript.ts | 76 +++++++++-------- scripts/codegen/utils.ts | 2 +- 9 files changed, 202 insertions(+), 152 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 06dc60540..d686a4df4 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -24,7 +24,7 @@ import { StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; -import { createServerRpc, registerClientApiHandlers } from "./generated/rpc.js"; +import { createServerRpc, registerClientSessionApiHandlers } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js"; import { getTraceContext } from "./telemetry.js"; @@ -40,13 +40,13 @@ import type { SessionConfig, SessionContext, SessionEvent, + SessionFsConfig, SessionLifecycleEvent, SessionLifecycleEventType, SessionLifecycleHandler, SessionListFilter, SessionMetadata, SystemMessageCustomizeConfig, - SessionFsConfig, TelemetryConfig, Tool, ToolCallRequestPayload, @@ -677,6 +677,9 @@ export class CopilotClient { session.on(config.onEvent); } this.sessions.set(sessionId, session); + if (this.sessionFsConfig) { + session.clientSessionApis.sessionFs = this.sessionFsConfig.createHandler(session); + } try { const response = await this.connection!.sendRequest("session.create", { @@ -799,6 +802,9 @@ export class CopilotClient { session.on(config.onEvent); } this.sessions.set(sessionId, session); + if (this.sessionFsConfig) { + session.clientSessionApis.sessionFs = this.sessionFsConfig.createHandler(session); + } try { const response = await this.connection!.sendRequest("session.resume", { @@ -1578,12 +1584,11 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); - // Register session filesystem RPC handlers if configured. - if (this.sessionFsConfig) { - registerClientApiHandlers(this.connection, { - sessionFs: this.sessionFsConfig, - }); - } + // Register client session API handlers. + const sessions = this.sessions; + registerClientSessionApiHandlers(this.connection, (sessionId) => + sessions.get(sessionId)?.clientSessionApis, + ); this.connection.onClose(() => { this.state = "disconnected"; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 5255ed011..6016b4076 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1196,16 +1196,14 @@ export interface SessionFsReaddirParams { path: string; } -export interface SessionFsDirEntry { - name: string; - type: "file" | "directory"; -} - export interface SessionFsReaddirWithTypesResult { /** * Directory entries with type information */ - entries: SessionFsDirEntry[]; + entries: { + name: string; + type: "file" | "directory"; + }[]; } export interface SessionFsReaddirWithTypesParams { @@ -1378,10 +1376,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin }; } -/** - * Handler interface for the `sessionFs` client API group. - * Implement this to provide a custom sessionFs backend. - */ +/** Handler for `sessionFs` client session API methods. */ export interface SessionFsHandler { readFile(params: SessionFsReadFileParams): Promise; writeFile(params: SessionFsWriteFileParams): Promise; @@ -1395,32 +1390,89 @@ export interface SessionFsHandler { rename(params: SessionFsRenameParams): Promise; } -/** All client API handler groups. Each group is optional. */ -export interface ClientApiHandlers { +/** All client session API handler groups. */ +export interface ClientSessionApiHandlers { sessionFs?: SessionFsHandler; } /** - * Register client API handlers on a JSON-RPC connection. + * Register client session API handlers on a JSON-RPC connection. * The server calls these methods to delegate work to the client. - * Methods for unregistered groups will respond with a standard JSON-RPC - * method-not-found error. + * Each incoming call includes a `sessionId` in the params; the registration + * function uses `getHandlers` to resolve the session's handlers. */ -export function registerClientApiHandlers( +export function registerClientSessionApiHandlers( connection: MessageConnection, - handlers: ClientApiHandlers, + getHandlers: (sessionId: string) => ClientSessionApiHandlers | undefined, ): void { - if (handlers.sessionFs) { - const h = handlers.sessionFs!; - connection.onRequest("sessionFs.readFile", (params: SessionFsReadFileParams) => h.readFile(params)); - connection.onRequest("sessionFs.writeFile", (params: SessionFsWriteFileParams) => h.writeFile(params)); - connection.onRequest("sessionFs.appendFile", (params: SessionFsAppendFileParams) => h.appendFile(params)); - connection.onRequest("sessionFs.exists", (params: SessionFsExistsParams) => h.exists(params)); - connection.onRequest("sessionFs.stat", (params: SessionFsStatParams) => h.stat(params)); - connection.onRequest("sessionFs.mkdir", (params: SessionFsMkdirParams) => h.mkdir(params)); - connection.onRequest("sessionFs.readdir", (params: SessionFsReaddirParams) => h.readdir(params)); - connection.onRequest("sessionFs.readdirWithTypes", (params: SessionFsReaddirWithTypesParams) => h.readdirWithTypes(params)); - connection.onRequest("sessionFs.rm", (params: SessionFsRmParams) => h.rm(params)); - connection.onRequest("sessionFs.rename", (params: SessionFsRenameParams) => h.rename(params)); - } + connection.onRequest("sessionFs.readFile", async (params: SessionFsReadFileParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.readFile(params); + }); + connection.onRequest("sessionFs.writeFile", async (params: SessionFsWriteFileParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.writeFile(params); + }); + connection.onRequest("sessionFs.appendFile", async (params: SessionFsAppendFileParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.appendFile(params); + }); + connection.onRequest("sessionFs.exists", async (params: SessionFsExistsParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.exists(params); + }); + connection.onRequest("sessionFs.stat", async (params: SessionFsStatParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.stat(params); + }); + connection.onRequest("sessionFs.mkdir", async (params: SessionFsMkdirParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.mkdir(params); + }); + connection.onRequest("sessionFs.readdir", async (params: SessionFsReaddirParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.readdir(params); + }); + connection.onRequest("sessionFs.readdirWithTypes", async (params: SessionFsReaddirWithTypesParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.readdirWithTypes(params); + }); + connection.onRequest("sessionFs.rm", async (params: SessionFsRmParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.rm(params); + }); + connection.onRequest("sessionFs.rename", async (params: SessionFsRenameParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.rename(params); + }); } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 8a6bec680..91dc023e9 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -91,10 +91,6 @@ export type SessionEvent = * Whether the session was already in use by another client at start time */ alreadyInUse?: boolean; - /** - * Whether this session supports remote steering via Mission Control - */ - steerable?: boolean; }; } | { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index f9c38045e..353f1567f 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -58,7 +58,6 @@ export type { SessionUiApi, SessionFsConfig, SessionFsHandler, - ClientApiHandlers, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageCustomizeConfig, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 7a0220f6f..66f89b81a 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -10,6 +10,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; +import type { ClientSessionApiHandlers } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { CommandHandler, @@ -83,6 +84,9 @@ export class CopilotSession { private traceContextProvider?: TraceContextProvider; private _capabilities: SessionCapabilities = {}; + /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ + clientSessionApis: ClientSessionApiHandlers = {}; + /** * Creates a new CopilotSession instance. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e92f7f87e..1ad1c094a 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -10,29 +10,8 @@ import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; export type SessionEvent = GeneratedSessionEvent; -// Re-export generated client API types -export type { - SessionFsHandler, - SessionFsReadFileParams, - SessionFsReadFileResult, - SessionFsWriteFileParams, - SessionFsAppendFileParams, - SessionFsExistsParams, - SessionFsExistsResult, - SessionFsStatParams, - SessionFsStatResult, - SessionFsMkdirParams, - SessionFsReaddirParams, - SessionFsReaddirResult, - SessionFsDirEntry, - SessionFsReaddirWithTypesParams, - SessionFsReaddirWithTypesResult, - SessionFsRmParams, - SessionFsRenameParams, - ClientApiHandlers, -} from "./generated/rpc.js"; - import type { SessionFsHandler } from "./generated/rpc.js"; +export type { SessionFsHandler } from "./generated/rpc.js"; /** * Options for creating a CopilotClient @@ -644,6 +623,7 @@ export interface PermissionRequest { } import type { SessionPermissionsHandlePendingPermissionRequestParams } from "./generated/rpc.js"; +import { CopilotSession } from "./session.js"; export type PermissionRequestResult = | SessionPermissionsHandlePendingPermissionRequestParams["result"] @@ -1352,11 +1332,8 @@ export interface SessionContext { /** * Configuration for a custom session filesystem provider. - * - * Extends the generated {@link SessionFsHandler} with registration - * parameters sent to the server's `sessionFs.setProvider` call. */ -export interface SessionFsConfig extends SessionFsHandler { +export interface SessionFsConfig { /** * Initial working directory for sessions (user's project directory). */ @@ -1372,6 +1349,11 @@ export interface SessionFsConfig extends SessionFsHandler { * Path conventions used by this filesystem provider. */ conventions: "windows" | "linux"; + + /** + * Supplies a handler for session filesystem operations. + */ + createHandler: (session: CopilotSession) => SessionFsHandler; } /** diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index dc1280b11..280d374c0 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -149,52 +149,60 @@ function createMemorySessionFs( initialCwd, sessionStatePath, conventions: "linux", - readFile: async ({ sessionId, path }) => { - const content = await provider.readFile(sp(sessionId, path), "utf8"); - return { content: content as string }; - }, - writeFile: async ({ sessionId, path, content }) => { - await provider.writeFile(sp(sessionId, path), content); - }, - appendFile: async ({ sessionId, path, content }) => { - await provider.appendFile(sp(sessionId, path), content); - }, - exists: async ({ sessionId, path }) => { - return { exists: await provider.exists(sp(sessionId, path)) }; - }, - stat: async ({ sessionId, path }) => { - const st = await provider.stat(sp(sessionId, path)); - return { - isFile: st.isFile(), - isDirectory: st.isDirectory(), - size: st.size, - mtime: new Date(st.mtimeMs).toISOString(), - birthtime: new Date(st.birthtimeMs).toISOString(), - }; - }, - mkdir: async ({ sessionId, path, recursive, mode }) => { - await provider.mkdir(sp(sessionId, path), { recursive: recursive ?? false, mode }); - }, - readdir: async ({ sessionId, path }) => { - const entries = await provider.readdir(sp(sessionId, path)); - return { entries: entries as string[] }; - }, - readdirWithTypes: async ({ sessionId, path }) => { - const names = await provider.readdir(sp(sessionId, path)) as string[]; - const entries = await Promise.all( - names.map(async (name) => { - const st = await provider.stat(sp(sessionId, `${path}/${name}`)); - return { name, type: st.isDirectory() ? "directory" as const : "file" as const }; - }), - ); - return { entries }; - }, - rm: async ({ sessionId, path }) => { - await provider.unlink(sp(sessionId, path)); - }, - rename: async ({ sessionId, src, dest }) => { - await provider.rename(sp(sessionId, src), sp(sessionId, dest)); - }, + createHandler: (session) => ({ + readFile: async ({ path }) => { + const content = await provider.readFile(sp(session.sessionId, path), "utf8"); + return { content: content as string }; + }, + writeFile: async ({ path, content }) => { + await provider.writeFile(sp(session.sessionId, path), content); + }, + appendFile: async ({ path, content }) => { + await provider.appendFile(sp(session.sessionId, path), content); + }, + exists: async ({ path }) => { + return { exists: await provider.exists(sp(session.sessionId, path)) }; + }, + stat: async ({ path }) => { + const st = await provider.stat(sp(session.sessionId, path)); + return { + isFile: st.isFile(), + isDirectory: st.isDirectory(), + size: st.size, + mtime: new Date(st.mtimeMs).toISOString(), + birthtime: new Date(st.birthtimeMs).toISOString(), + }; + }, + mkdir: async ({ path, recursive, mode }) => { + await provider.mkdir(sp(session.sessionId, path), { + recursive: recursive ?? false, + mode, + }); + }, + readdir: async ({ path }) => { + const entries = await provider.readdir(sp(session.sessionId, path)); + return { entries: entries as string[] }; + }, + readdirWithTypes: async ({ path }) => { + const names = (await provider.readdir(sp(session.sessionId, path))) as string[]; + const entries = await Promise.all( + names.map(async (name) => { + const st = await provider.stat(sp(session.sessionId, `${path}/${name}`)); + return { + name, + type: st.isDirectory() ? ("directory" as const) : ("file" as const), + }; + }) + ); + return { entries }; + }, + rm: async ({ path }) => { + await provider.unlink(sp(session.sessionId, path)); + }, + rename: async ({ src, dest }) => { + await provider.rename(sp(session.sessionId, src), sp(session.sessionId, dest)); + }, + }), }; return { config }; diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index c8f831c4e..63fbd938a 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -86,9 +86,9 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; `); const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; - const clientMethods = collectRpcMethods(schema.client || {}); + const clientSessionMethods = collectRpcMethods(schema.clientSession || {}); - for (const method of [...allMethods, ...clientMethods]) { + for (const method of [...allMethods, ...clientSessionMethods]) { if (method.result) { const compiled = await compile(method.result, resultTypeName(method.rpcMethod), { bannerComment: "", @@ -135,9 +135,9 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; lines.push(""); } - // Generate client API handler interfaces and registration function - if (schema.client) { - lines.push(...emitClientApiHandlers(schema.client)); + // Generate client session API handler interfaces and registration function + if (schema.clientSession) { + lines.push(...emitClientSessionApiRegistration(schema.clientSession)); } const outPath = await writeGeneratedFile("nodejs/src/generated/rpc.ts", lines.join("\n")); @@ -193,11 +193,11 @@ function emitGroup(node: Record, indent: string, isSession: boo return lines; } -// ── Client API Handler Generation ─────────────────────────────────────────── +// ── Client Session API Handler Generation ─────────────────────────────────── /** * Collect client API methods grouped by their top-level namespace. - * Returns a map like: { sessionStore: [{ rpcMethod, params, result }, ...] } + * Returns a map like: { sessionFs: [{ rpcMethod, params, result }, ...] } */ function collectClientGroups(node: Record): Map { const groups = new Map(); @@ -211,7 +211,7 @@ function collectClientGroups(node: Record): Map): string[] { +function emitClientSessionApiRegistration(clientSchema: Record): string[] { const lines: string[] = []; const groups = collectClientGroups(clientSchema); // Emit a handler interface per group for (const [groupName, methods] of groups) { const interfaceName = toPascalCase(groupName) + "Handler"; - lines.push(`/**`); - lines.push(` * Handler interface for the \`${groupName}\` client API group.`); - lines.push(` * Implement this to provide a custom ${groupName} backend.`); - lines.push(` */`); + lines.push(`/** Handler for \`${groupName}\` client session API methods. */`); lines.push(`export interface ${interfaceName} {`); - for (const method of methods) { const name = handlerMethodName(method.rpcMethod); const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; const pType = hasParams ? paramsTypeName(method.rpcMethod) : ""; const rType = method.result ? resultTypeName(method.rpcMethod) : "void"; - const sig = hasParams - ? ` ${name}(params: ${pType}): Promise<${rType}>;` - : ` ${name}(): Promise<${rType}>;`; - lines.push(sig); + if (hasParams) { + lines.push(` ${name}(params: ${pType}): Promise<${rType}>;`); + } else { + lines.push(` ${name}(): Promise<${rType}>;`); + } } - lines.push(`}`); lines.push(""); } - // Emit combined ClientApiHandlers type - lines.push(`/** All client API handler groups. Each group is optional. */`); - lines.push(`export interface ClientApiHandlers {`); + // Emit combined ClientSessionApiHandlers type + lines.push(`/** All client session API handler groups. */`); + lines.push(`export interface ClientSessionApiHandlers {`); for (const [groupName] of groups) { const interfaceName = toPascalCase(groupName) + "Handler"; lines.push(` ${groupName}?: ${interfaceName};`); @@ -262,33 +263,36 @@ function emitClientApiHandlers(clientSchema: Record): string[] // Emit registration function lines.push(`/**`); - lines.push(` * Register client API handlers on a JSON-RPC connection.`); + lines.push(` * Register client session API handlers on a JSON-RPC connection.`); lines.push(` * The server calls these methods to delegate work to the client.`); - lines.push(` * Methods for unregistered groups will respond with a standard JSON-RPC`); - lines.push(` * method-not-found error.`); + lines.push(` * Each incoming call includes a \`sessionId\` in the params; the registration`); + lines.push(` * function uses \`getHandlers\` to resolve the session's handlers.`); lines.push(` */`); - lines.push(`export function registerClientApiHandlers(`); + lines.push(`export function registerClientSessionApiHandlers(`); lines.push(` connection: MessageConnection,`); - lines.push(` handlers: ClientApiHandlers,`); + lines.push(` getHandlers: (sessionId: string) => ClientSessionApiHandlers | undefined,`); lines.push(`): void {`); for (const [groupName, methods] of groups) { - lines.push(` if (handlers.${groupName}) {`); - lines.push(` const h = handlers.${groupName}!;`); - for (const method of methods) { const name = handlerMethodName(method.rpcMethod); + const pType = paramsTypeName(method.rpcMethod); const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; - const pType = hasParams ? paramsTypeName(method.rpcMethod) : ""; if (hasParams) { - lines.push(` connection.onRequest("${method.rpcMethod}", (params: ${pType}) => h.${name}(params));`); + lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); + lines.push(` const handlers = getHandlers(params.sessionId);`); + lines.push(` if (!handlers) throw new Error(\`No session found for sessionId: \${params.sessionId}\`);`); + lines.push(` const handler = handlers.${groupName};`); + lines.push(` if (!handler) throw new Error(\`No ${groupName} handler registered for session: \${params.sessionId}\`);`); + lines.push(` return handler.${name}(params);`); + lines.push(` });`); } else { - lines.push(` connection.onRequest("${method.rpcMethod}", () => h.${name}());`); + lines.push(` connection.onRequest("${method.rpcMethod}", async () => {`); + lines.push(` throw new Error("No params provided for ${method.rpcMethod}");`); + lines.push(` });`); } } - - lines.push(` }`); } lines.push(`}`); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index bc508e240..1e95b4dd4 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -132,7 +132,7 @@ export interface RpcMethod { export interface ApiSchema { server?: Record; session?: Record; - client?: Record; + clientSession?: Record; } export function isRpcMethod(node: unknown): node is RpcMethod { From 47bf24e0c3bab4ec13662c891fd636fbecfa68cc Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 30 Mar 2026 15:46:27 +0100 Subject: [PATCH 08/10] Simplify --- nodejs/src/client.ts | 8 ++++--- nodejs/src/generated/rpc.ts | 42 +++++++++-------------------------- scripts/codegen/typescript.ts | 6 ++--- 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index d686a4df4..432c07cde 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1586,9 +1586,11 @@ export class CopilotClient { // Register client session API handlers. const sessions = this.sessions; - registerClientSessionApiHandlers(this.connection, (sessionId) => - sessions.get(sessionId)?.clientSessionApis, - ); + registerClientSessionApiHandlers(this.connection, (sessionId) => { + const session = sessions.get(sessionId); + if (!session) throw new Error(`No session found for sessionId: ${sessionId}`); + return session.clientSessionApis; + }); this.connection.onClose(() => { this.state = "disconnected"; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 6016b4076..e54236d82 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1403,75 +1403,55 @@ export interface ClientSessionApiHandlers { */ export function registerClientSessionApiHandlers( connection: MessageConnection, - getHandlers: (sessionId: string) => ClientSessionApiHandlers | undefined, + getHandlers: (sessionId: string) => ClientSessionApiHandlers, ): void { connection.onRequest("sessionFs.readFile", async (params: SessionFsReadFileParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.readFile(params); }); connection.onRequest("sessionFs.writeFile", async (params: SessionFsWriteFileParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.writeFile(params); }); connection.onRequest("sessionFs.appendFile", async (params: SessionFsAppendFileParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.appendFile(params); }); connection.onRequest("sessionFs.exists", async (params: SessionFsExistsParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.exists(params); }); connection.onRequest("sessionFs.stat", async (params: SessionFsStatParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.stat(params); }); connection.onRequest("sessionFs.mkdir", async (params: SessionFsMkdirParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.mkdir(params); }); connection.onRequest("sessionFs.readdir", async (params: SessionFsReaddirParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.readdir(params); }); connection.onRequest("sessionFs.readdirWithTypes", async (params: SessionFsReaddirWithTypesParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.readdirWithTypes(params); }); connection.onRequest("sessionFs.rm", async (params: SessionFsRmParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.rm(params); }); connection.onRequest("sessionFs.rename", async (params: SessionFsRenameParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.rename(params); }); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 63fbd938a..1939d5152 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -270,7 +270,7 @@ function emitClientSessionApiRegistration(clientSchema: Record) lines.push(` */`); lines.push(`export function registerClientSessionApiHandlers(`); lines.push(` connection: MessageConnection,`); - lines.push(` getHandlers: (sessionId: string) => ClientSessionApiHandlers | undefined,`); + lines.push(` getHandlers: (sessionId: string) => ClientSessionApiHandlers,`); lines.push(`): void {`); for (const [groupName, methods] of groups) { @@ -281,9 +281,7 @@ function emitClientSessionApiRegistration(clientSchema: Record) if (hasParams) { lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); - lines.push(` const handlers = getHandlers(params.sessionId);`); - lines.push(` if (!handlers) throw new Error(\`No session found for sessionId: \${params.sessionId}\`);`); - lines.push(` const handler = handlers.${groupName};`); + lines.push(` const handler = getHandlers(params.sessionId).${groupName};`); lines.push(` if (!handler) throw new Error(\`No ${groupName} handler registered for session: \${params.sessionId}\`);`); lines.push(` return handler.${name}(params);`); lines.push(` });`); From 534805a396dd00191ca2f51fadaa710db87d83ac Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Mar 2026 13:52:50 +0100 Subject: [PATCH 09/10] Move createSessionFsHandler onto SessionConfig --- nodejs/src/client.ts | 16 ++- nodejs/src/types.ts | 15 +-- nodejs/test/e2e/session_fs.test.ts | 175 +++++++++++++++-------------- 3 files changed, 112 insertions(+), 94 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 432c07cde..e780e9bfb 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -678,7 +678,13 @@ export class CopilotClient { } this.sessions.set(sessionId, session); if (this.sessionFsConfig) { - session.clientSessionApis.sessionFs = this.sessionFsConfig.createHandler(session); + if (config.createSessionFsHandler) { + session.clientSessionApis.sessionFs = config.createSessionFsHandler(session); + } else { + throw new Error( + "createSessionFsHandler is required in session config when sessionFs is enabled in client options." + ); + } } try { @@ -803,7 +809,13 @@ export class CopilotClient { } this.sessions.set(sessionId, session); if (this.sessionFsConfig) { - session.clientSessionApis.sessionFs = this.sessionFsConfig.createHandler(session); + if (config.createSessionFsHandler) { + session.clientSessionApis.sessionFs = config.createSessionFsHandler(session); + } else { + throw new Error( + "createSessionFsHandler is required in session config when sessionFs is enabled in client options." + ); + } } try { diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 1ad1c094a..3e4e163d2 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -11,7 +11,7 @@ import type { SessionEvent as GeneratedSessionEvent } from "./generated/session- export type SessionEvent = GeneratedSessionEvent; import type { SessionFsHandler } from "./generated/rpc.js"; -export type { SessionFsHandler } from "./generated/rpc.js"; +import type { CopilotSession } from "./session.js"; /** * Options for creating a CopilotClient @@ -623,7 +623,6 @@ export interface PermissionRequest { } import type { SessionPermissionsHandlePendingPermissionRequestParams } from "./generated/rpc.js"; -import { CopilotSession } from "./session.js"; export type PermissionRequestResult = | SessionPermissionsHandlePendingPermissionRequestParams["result"] @@ -1160,6 +1159,12 @@ export interface SessionConfig { * but executes earlier in the lifecycle so no events are missed. */ onEvent?: SessionEventHandler; + + /** + * Supplies a handler for session filesystem operations. This takes effect + * only if {@link CopilotClientOptions.sessionFs} is configured. + */ + createSessionFsHandler?: (session: CopilotSession) => SessionFsHandler; } /** @@ -1189,6 +1194,7 @@ export type ResumeSessionConfig = Pick< | "disabledSkills" | "infiniteSessions" | "onEvent" + | "createSessionFsHandler" > & { /** * When true, skips emitting the session.resume event. @@ -1349,11 +1355,6 @@ export interface SessionFsConfig { * Path conventions used by this filesystem provider. */ conventions: "windows" | "linux"; - - /** - * Supplies a handler for session filesystem operations. - */ - createHandler: (session: CopilotSession) => SessionFsHandler; } /** diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index 280d374c0..c7f5bc17f 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -2,30 +2,42 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { MemoryProvider } from "@platformatic/vfs"; +import { MemoryProvider, VirtualProvider } from "@platformatic/vfs"; import { describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient } from "../../src/client.js"; -import { approveAll, defineTool, SessionEvent, type SessionFsConfig } from "../../src/index.js"; +import { SessionFsHandler } from "../../src/generated/rpc.js"; +import { + approveAll, + CopilotSession, + defineTool, + SessionEvent, + type SessionFsConfig, +} from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; +process.env.COPILOT_CLI_PATH = + "c:\\Users\\stevesa\\.copilot\\worktrees\\copilot-agent-runtime\\amber-aura\\dist-cli\\index.js"; + describe("Session Fs", async () => { // Single provider for the describe block — session IDs are unique per test, // so no cross-contamination between tests. const provider = new MemoryProvider(); - const { config } = createMemorySessionFs("/projects/test", "/session-state", provider); + const createSessionFsHandler = (session: CopilotSession) => + createTestSessionFsHandler(session, provider); // Helpers to build session-namespaced paths for direct provider assertions const p = (sessionId: string, path: string) => `/${sessionId}${path.startsWith("/") ? path : "/" + path}`; const { copilotClient: client, env } = await createSdkTestContext({ - copilotClientOptions: { - sessionFs: config, - }, + copilotClientOptions: { sessionFs: sessionFsConfig }, }); it("should route file operations through the session fs provider", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + createSessionFsHandler, + }); const msg = await session.sendAndWait({ prompt: "What is 100 + 200?" }); expect(msg?.data.content).toContain("300"); @@ -37,7 +49,10 @@ describe("Session Fs", async () => { }); it("should load session data from fs provider on resume", async () => { - const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const session1 = await client.createSession({ + onPermissionRequest: approveAll, + createSessionFsHandler, + }); const sessionId = session1.sessionId; const msg = await session1.sendAndWait({ prompt: "What is 50 + 50?" }); @@ -49,6 +64,7 @@ describe("Session Fs", async () => { const session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll, + createSessionFsHandler, }); // Send another message to verify the session is functional after resume @@ -62,23 +78,18 @@ describe("Session Fs", async () => { useStdio: false, // Use TCP so we can connect from a second client env, }); - await client.createSession({ onPermissionRequest: approveAll }); + await client.createSession({ onPermissionRequest: approveAll, createSessionFsHandler }); // Get the port the first client's runtime is listening on const port = (client as unknown as { actualPort: number }).actualPort; // Second client tries to connect with a session fs — should fail // because sessions already exist on the runtime. - const { config: config2 } = createMemorySessionFs( - "/projects/test", - "/session-state", - new MemoryProvider() - ); const client2 = new CopilotClient({ env, logLevel: "error", cliUrl: `localhost:${port}`, - sessionFs: config2, + sessionFs: sessionFsConfig, }); onTestFinished(() => client2.forceStop()); @@ -89,6 +100,7 @@ describe("Session Fs", async () => { const suppliedFileContent = "x".repeat(100_000); const session = await client.createSession({ onPermissionRequest: approveAll, + createSessionFsHandler, tools: [ defineTool("get_big_string", { description: "Returns a large string", @@ -132,78 +144,71 @@ function findToolName(messages: SessionEvent[], toolCallId: string): string | un } } -/** - * Builds a SessionFsConfig backed by a @platformatic/vfs MemoryProvider. - * Each sessionId is namespaced under `//` in the provider's tree. - * Tests can assert directly against the returned MemoryProvider instance. - */ -function createMemorySessionFs( - initialCwd: string, - sessionStatePath: string, - provider: MemoryProvider -): { config: SessionFsConfig } { +const sessionFsConfig: SessionFsConfig = { + initialCwd: "/", + sessionStatePath: "/session-state", + conventions: "linux", +}; + +function createTestSessionFsHandler( + session: CopilotSession, + provider: VirtualProvider +): SessionFsHandler { const sp = (sessionId: string, path: string) => `/${sessionId}${path.startsWith("/") ? path : "/" + path}`; - const config: SessionFsConfig = { - initialCwd, - sessionStatePath, - conventions: "linux", - createHandler: (session) => ({ - readFile: async ({ path }) => { - const content = await provider.readFile(sp(session.sessionId, path), "utf8"); - return { content: content as string }; - }, - writeFile: async ({ path, content }) => { - await provider.writeFile(sp(session.sessionId, path), content); - }, - appendFile: async ({ path, content }) => { - await provider.appendFile(sp(session.sessionId, path), content); - }, - exists: async ({ path }) => { - return { exists: await provider.exists(sp(session.sessionId, path)) }; - }, - stat: async ({ path }) => { - const st = await provider.stat(sp(session.sessionId, path)); - return { - isFile: st.isFile(), - isDirectory: st.isDirectory(), - size: st.size, - mtime: new Date(st.mtimeMs).toISOString(), - birthtime: new Date(st.birthtimeMs).toISOString(), - }; - }, - mkdir: async ({ path, recursive, mode }) => { - await provider.mkdir(sp(session.sessionId, path), { - recursive: recursive ?? false, - mode, - }); - }, - readdir: async ({ path }) => { - const entries = await provider.readdir(sp(session.sessionId, path)); - return { entries: entries as string[] }; - }, - readdirWithTypes: async ({ path }) => { - const names = (await provider.readdir(sp(session.sessionId, path))) as string[]; - const entries = await Promise.all( - names.map(async (name) => { - const st = await provider.stat(sp(session.sessionId, `${path}/${name}`)); - return { - name, - type: st.isDirectory() ? ("directory" as const) : ("file" as const), - }; - }) - ); - return { entries }; - }, - rm: async ({ path }) => { - await provider.unlink(sp(session.sessionId, path)); - }, - rename: async ({ src, dest }) => { - await provider.rename(sp(session.sessionId, src), sp(session.sessionId, dest)); - }, - }), + return { + readFile: async ({ path }) => { + const content = await provider.readFile(sp(session.sessionId, path), "utf8"); + return { content: content as string }; + }, + writeFile: async ({ path, content }) => { + await provider.writeFile(sp(session.sessionId, path), content); + }, + appendFile: async ({ path, content }) => { + await provider.appendFile(sp(session.sessionId, path), content); + }, + exists: async ({ path }) => { + return { exists: await provider.exists(sp(session.sessionId, path)) }; + }, + stat: async ({ path }) => { + const st = await provider.stat(sp(session.sessionId, path)); + return { + isFile: st.isFile(), + isDirectory: st.isDirectory(), + size: st.size, + mtime: new Date(st.mtimeMs).toISOString(), + birthtime: new Date(st.birthtimeMs).toISOString(), + }; + }, + mkdir: async ({ path, recursive, mode }) => { + await provider.mkdir(sp(session.sessionId, path), { + recursive: recursive ?? false, + mode, + }); + }, + readdir: async ({ path }) => { + const entries = await provider.readdir(sp(session.sessionId, path)); + return { entries: entries as string[] }; + }, + readdirWithTypes: async ({ path }) => { + const names = (await provider.readdir(sp(session.sessionId, path))) as string[]; + const entries = await Promise.all( + names.map(async (name) => { + const st = await provider.stat(sp(session.sessionId, `${path}/${name}`)); + return { + name, + type: st.isDirectory() ? ("directory" as const) : ("file" as const), + }; + }) + ); + return { entries }; + }, + rm: async ({ path }) => { + await provider.unlink(sp(session.sessionId, path)); + }, + rename: async ({ src, dest }) => { + await provider.rename(sp(session.sessionId, src), sp(session.sessionId, dest)); + }, }; - - return { config }; } From b523e3726c47cfe76a09ad09f8088ff5d8976026 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Mar 2026 13:53:51 +0100 Subject: [PATCH 10/10] Fix --- nodejs/src/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 3e4e163d2..1e03582c8 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -7,11 +7,11 @@ */ // Import and re-export generated session event types -import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; -export type SessionEvent = GeneratedSessionEvent; - import type { SessionFsHandler } from "./generated/rpc.js"; +import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; +export type SessionEvent = GeneratedSessionEvent; +export type { SessionFsHandler } from "./generated/rpc.js"; /** * Options for creating a CopilotClient