From 3d11cffe8c874495e121a99cf6f1f3dffd64b5db Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 25 Mar 2026 15:28:55 +0000 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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,