From be2246b89db57ec02c3f5c85294582a5d740dc09 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 25 Mar 2026 15:28:55 +0000 Subject: [PATCH 01/18] 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 50715c0eb..e64781328 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"; @@ -1077,7 +1089,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; @@ -1623,6 +1637,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 1db497ae6..138219ea9 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 @@ -1083,6 +1097,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 { @@ -1100,6 +1186,10 @@ export function createServerRpc(connection: MessageConnection) { getQuota: async (): Promise => connection.sendRequest("account.getQuota", {}), }, + sessionDataStore: { + setDataStore: async (params: SessionDataStoreSetDataStoreParams): Promise => + connection.sendRequest("sessionDataStore.setDataStore", params), + }, }; } @@ -1223,3 +1313,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 4fc1b75fb..dc7c3ba5f 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -58,6 +58,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 b4b9e563c..5a01a3477 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; } /** @@ -1352,6 +1375,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 24dc8dab4f05ed97c166968dfcdde5c2e9676d79 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 27 Mar 2026 17:22:28 +0000 Subject: [PATCH 02/18] 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 | 133 +------- 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, 511 insertions(+), 576 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 e64781328..09a45e0b3 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, }); } @@ -1637,10 +1639,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 138219ea9..7178febb9 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 { @@ -1097,76 +1105,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). */ @@ -1186,9 +1261,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), }, }; } @@ -1315,20 +1390,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; } /** @@ -1341,12 +1420,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 5d8e12830..8a6bec680 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -94,7 +94,7 @@ export type SessionEvent = /** * Whether this session supports remote steering via Mission Control */ - remoteSteerable?: boolean; + steerable?: boolean; }; } | { @@ -172,38 +172,6 @@ export type SessionEvent = * Whether the session was already in use by another client at resume time */ alreadyInUse?: boolean; - /** - * Whether this session supports remote steering via Mission Control - */ - remoteSteerable?: boolean; - }; - } - | { - /** - * Unique event identifier (UUID v4), generated when the event is emitted - */ - id: string; - /** - * ISO 8601 timestamp when the event was created - */ - timestamp: string; - /** - * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. - */ - parentId: string | null; - /** - * When true, the event is transient and not persisted to the session event log on disk - */ - ephemeral?: boolean; - type: "session.remote_steerable_changed"; - /** - * Notifies Mission Control that the session's remote steering capability has changed - */ - data: { - /** - * Whether this session now supports remote steering via Mission Control - */ - remoteSteerable: boolean; }; } | { @@ -1620,15 +1588,7 @@ export type SessionEvent = */ duration?: number; /** - * Time to first token in milliseconds. Only available for streaming requests - */ - ttftMs?: number; - /** - * Average inter-token latency in milliseconds. Only available for streaming requests - */ - interTokenLatencyMs?: number; - /** - * What initiated this API call (e.g., "sub-agent", "mcp-sampling"); absent for user-initiated calls + * What initiated this API call (e.g., "sub-agent"); absent for user-initiated calls */ initiator?: string; /** @@ -3065,65 +3025,6 @@ export type SessionEvent = requestId: string; }; } - | { - /** - * Unique event identifier (UUID v4), generated when the event is emitted - */ - id: string; - /** - * ISO 8601 timestamp when the event was created - */ - timestamp: string; - /** - * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. - */ - parentId: string | null; - ephemeral: true; - type: "sampling.requested"; - /** - * Sampling request from an MCP server; contains the server name and a requestId for correlation - */ - data: { - /** - * Unique identifier for this sampling request; used to respond via session.respondToSampling() - */ - requestId: string; - /** - * Name of the MCP server that initiated the sampling request - */ - serverName: string; - /** - * The JSON-RPC request ID from the MCP protocol - */ - mcpRequestId: string | number; - [k: string]: unknown; - }; - } - | { - /** - * Unique event identifier (UUID v4), generated when the event is emitted - */ - id: string; - /** - * ISO 8601 timestamp when the event was created - */ - timestamp: string; - /** - * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. - */ - parentId: string | null; - ephemeral: true; - type: "sampling.completed"; - /** - * Sampling request completion notification signaling UI dismissal - */ - data: { - /** - * Request ID of the resolved sampling request; clients should dismiss any UI for this request - */ - requestId: string; - }; - } | { /** * Unique event identifier (UUID v4), generated when the event is emitted @@ -3390,36 +3291,6 @@ export type SessionEvent = }[]; }; } - | { - /** - * Unique event identifier (UUID v4), generated when the event is emitted - */ - id: string; - /** - * ISO 8601 timestamp when the event was created - */ - timestamp: string; - /** - * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. - */ - parentId: string | null; - ephemeral: true; - type: "capabilities.changed"; - /** - * Session capability change notification - */ - data: { - /** - * UI capability changes - */ - ui?: { - /** - * Whether elicitation is now supported - */ - elicitation?: boolean; - }; - }; - } | { /** * Unique event identifier (UUID v4), generated when the event is emitted diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index dc7c3ba5f..cb46641a3 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -58,8 +58,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 5a01a3477..e7f314b45 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; } /** @@ -1376,17 +1382,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 b89301f796fbc64c6242e02ddb0d0818f3820bf2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 27 Mar 2026 18:18:00 +0000 Subject: [PATCH 03/18] 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 2dc150ea82fa6a88494d8b490cfbb07b9a793692 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 27 Mar 2026 19:48:25 +0000 Subject: [PATCH 04/18] 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 a3a94ac5e..849047134 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 1787721a8..8979a579e 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 e41b30dec0290692b5356cf77eee1213940875d9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 30 Mar 2026 11:32:35 +0100 Subject: [PATCH 05/18] 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 7178febb9..e965e0f1f 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1208,6 +1208,10 @@ export interface SessionFsMkdirParams { sessionId: string; path: string; recursive?: boolean; + /** + * Optional POSIX-style mode for newly created directories + */ + mode?: number; } export interface SessionFsReaddirResult { @@ -1225,6 +1229,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 @@ -1401,6 +1425,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; } @@ -1429,6 +1454,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 e7f314b45..e84974eb9 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -24,6 +24,9 @@ export type { SessionFsMkdirParams, SessionFsReaddirParams, SessionFsReaddirResult, + SessionFsDirEntry, + SessionFsReaddirWithTypesParams, + SessionFsReaddirWithTypesResult, SessionFsRmParams, SessionFsRenameParams, ClientApiHandlers, From 607492d9433b577fa9f956bde9907a0a650dabcb Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 30 Mar 2026 13:25:43 +0100 Subject: [PATCH 06/18] Update test --- nodejs/test/e2e/session_fs.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index de80ce123..dc1280b11 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -172,13 +172,23 @@ function createMemorySessionFs( birthtime: new Date(st.birthtimeMs).toISOString(), }; }, - mkdir: async ({ sessionId, path, recursive }) => { - await provider.mkdir(sp(sessionId, path), { recursive: recursive ?? false }); + mkdir: async ({ sessionId, path, recursive, mode }) => { + await provider.mkdir(sp(sessionId, path), { recursive: recursive ?? false, mode }); }, readdir: async ({ sessionId, path }) => { const entries = await provider.readdir(sp(sessionId, path)); return { entries: entries as string[] }; }, + readdirWithTypes: async ({ sessionId, path }) => { + const names = await provider.readdir(sp(sessionId, path)) as string[]; + const entries = await Promise.all( + names.map(async (name) => { + const st = await provider.stat(sp(sessionId, `${path}/${name}`)); + return { name, type: st.isDirectory() ? "directory" as const : "file" as const }; + }), + ); + return { entries }; + }, rm: async ({ sessionId, path }) => { await provider.unlink(sp(sessionId, path)); }, From b5f5738b7a858628a4959885eb25c650128a4abb Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 30 Mar 2026 15:41:21 +0100 Subject: [PATCH 07/18] Move to per-session client APIs --- nodejs/src/client.ts | 21 +++-- nodejs/src/generated/rpc.ts | 112 ++++++++++++++++++------- nodejs/src/generated/session-events.ts | 4 - nodejs/src/index.ts | 1 - nodejs/src/session.ts | 4 + nodejs/src/types.ts | 34 ++------ nodejs/test/e2e/session_fs.test.ts | 100 ++++++++++++---------- scripts/codegen/typescript.ts | 76 +++++++++-------- scripts/codegen/utils.ts | 2 +- 9 files changed, 202 insertions(+), 152 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 09a45e0b3..9dd975a21 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -24,7 +24,7 @@ import { StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; -import { createServerRpc, registerClientApiHandlers } from "./generated/rpc.js"; +import { createServerRpc, registerClientSessionApiHandlers } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js"; import { getTraceContext } from "./telemetry.js"; @@ -40,13 +40,13 @@ import type { SessionConfig, SessionContext, SessionEvent, + SessionFsConfig, SessionLifecycleEvent, SessionLifecycleEventType, SessionLifecycleHandler, SessionListFilter, SessionMetadata, SystemMessageCustomizeConfig, - SessionFsConfig, TelemetryConfig, Tool, ToolCallRequestPayload, @@ -680,6 +680,9 @@ export class CopilotClient { session.on(config.onEvent); } this.sessions.set(sessionId, session); + if (this.sessionFsConfig) { + session.clientSessionApis.sessionFs = this.sessionFsConfig.createHandler(session); + } try { const response = await this.connection!.sendRequest("session.create", { @@ -806,6 +809,9 @@ export class CopilotClient { session.on(config.onEvent); } this.sessions.set(sessionId, session); + if (this.sessionFsConfig) { + session.clientSessionApis.sessionFs = this.sessionFsConfig.createHandler(session); + } try { const response = await this.connection!.sendRequest("session.resume", { @@ -1639,12 +1645,11 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); - // Register session filesystem RPC handlers if configured. - if (this.sessionFsConfig) { - registerClientApiHandlers(this.connection, { - sessionFs: this.sessionFsConfig, - }); - } + // Register client session API handlers. + const sessions = this.sessions; + registerClientSessionApiHandlers(this.connection, (sessionId) => + sessions.get(sessionId)?.clientSessionApis, + ); this.connection.onClose(() => { this.state = "disconnected"; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index e965e0f1f..6316e18d3 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1229,16 +1229,14 @@ export interface SessionFsReaddirParams { path: string; } -export interface SessionFsDirEntry { - name: string; - type: "file" | "directory"; -} - export interface SessionFsReaddirWithTypesResult { /** * Directory entries with type information */ - entries: SessionFsDirEntry[]; + entries: { + name: string; + type: "file" | "directory"; + }[]; } export interface SessionFsReaddirWithTypesParams { @@ -1413,10 +1411,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin }; } -/** - * Handler interface for the `sessionFs` client API group. - * Implement this to provide a custom sessionFs backend. - */ +/** Handler for `sessionFs` client session API methods. */ export interface SessionFsHandler { readFile(params: SessionFsReadFileParams): Promise; writeFile(params: SessionFsWriteFileParams): Promise; @@ -1430,32 +1425,89 @@ export interface SessionFsHandler { rename(params: SessionFsRenameParams): Promise; } -/** All client API handler groups. Each group is optional. */ -export interface ClientApiHandlers { +/** All client session API handler groups. */ +export interface ClientSessionApiHandlers { sessionFs?: SessionFsHandler; } /** - * Register client API handlers on a JSON-RPC connection. + * Register client session API handlers on a JSON-RPC connection. * The server calls these methods to delegate work to the client. - * Methods for unregistered groups will respond with a standard JSON-RPC - * method-not-found error. + * Each incoming call includes a `sessionId` in the params; the registration + * function uses `getHandlers` to resolve the session's handlers. */ -export function registerClientApiHandlers( +export function registerClientSessionApiHandlers( connection: MessageConnection, - handlers: ClientApiHandlers, + getHandlers: (sessionId: string) => ClientSessionApiHandlers | undefined, ): void { - if (handlers.sessionFs) { - const h = handlers.sessionFs!; - connection.onRequest("sessionFs.readFile", (params: SessionFsReadFileParams) => h.readFile(params)); - connection.onRequest("sessionFs.writeFile", (params: SessionFsWriteFileParams) => h.writeFile(params)); - connection.onRequest("sessionFs.appendFile", (params: SessionFsAppendFileParams) => h.appendFile(params)); - connection.onRequest("sessionFs.exists", (params: SessionFsExistsParams) => h.exists(params)); - connection.onRequest("sessionFs.stat", (params: SessionFsStatParams) => h.stat(params)); - connection.onRequest("sessionFs.mkdir", (params: SessionFsMkdirParams) => h.mkdir(params)); - connection.onRequest("sessionFs.readdir", (params: SessionFsReaddirParams) => h.readdir(params)); - connection.onRequest("sessionFs.readdirWithTypes", (params: SessionFsReaddirWithTypesParams) => h.readdirWithTypes(params)); - connection.onRequest("sessionFs.rm", (params: SessionFsRmParams) => h.rm(params)); - connection.onRequest("sessionFs.rename", (params: SessionFsRenameParams) => h.rename(params)); - } + connection.onRequest("sessionFs.readFile", async (params: SessionFsReadFileParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.readFile(params); + }); + connection.onRequest("sessionFs.writeFile", async (params: SessionFsWriteFileParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.writeFile(params); + }); + connection.onRequest("sessionFs.appendFile", async (params: SessionFsAppendFileParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.appendFile(params); + }); + connection.onRequest("sessionFs.exists", async (params: SessionFsExistsParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.exists(params); + }); + connection.onRequest("sessionFs.stat", async (params: SessionFsStatParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.stat(params); + }); + connection.onRequest("sessionFs.mkdir", async (params: SessionFsMkdirParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.mkdir(params); + }); + connection.onRequest("sessionFs.readdir", async (params: SessionFsReaddirParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.readdir(params); + }); + connection.onRequest("sessionFs.readdirWithTypes", async (params: SessionFsReaddirWithTypesParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.readdirWithTypes(params); + }); + connection.onRequest("sessionFs.rm", async (params: SessionFsRmParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.rm(params); + }); + connection.onRequest("sessionFs.rename", async (params: SessionFsRenameParams) => { + const handlers = getHandlers(params.sessionId); + if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); + const handler = handlers.sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.rename(params); + }); } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 8a6bec680..91dc023e9 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -91,10 +91,6 @@ export type SessionEvent = * Whether the session was already in use by another client at start time */ alreadyInUse?: boolean; - /** - * Whether this session supports remote steering via Mission Control - */ - steerable?: boolean; }; } | { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index cb46641a3..4c41d2dfe 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -60,7 +60,6 @@ export type { SessionUiApi, SessionFsConfig, SessionFsHandler, - ClientApiHandlers, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageCustomizeConfig, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index cb2cf826b..4cb636e1a 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -10,6 +10,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; +import type { ClientSessionApiHandlers } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { CommandHandler, @@ -86,6 +87,9 @@ export class CopilotSession { private traceContextProvider?: TraceContextProvider; private _capabilities: SessionCapabilities = {}; + /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ + clientSessionApis: ClientSessionApiHandlers = {}; + /** * Creates a new CopilotSession instance. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e84974eb9..266caff4f 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -10,29 +10,8 @@ import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; export type SessionEvent = GeneratedSessionEvent; -// Re-export generated client API types -export type { - SessionFsHandler, - SessionFsReadFileParams, - SessionFsReadFileResult, - SessionFsWriteFileParams, - SessionFsAppendFileParams, - SessionFsExistsParams, - SessionFsExistsResult, - SessionFsStatParams, - SessionFsStatResult, - SessionFsMkdirParams, - SessionFsReaddirParams, - SessionFsReaddirResult, - SessionFsDirEntry, - SessionFsReaddirWithTypesParams, - SessionFsReaddirWithTypesResult, - SessionFsRmParams, - SessionFsRenameParams, - ClientApiHandlers, -} from "./generated/rpc.js"; - import type { SessionFsHandler } from "./generated/rpc.js"; +export type { SessionFsHandler } from "./generated/rpc.js"; /** * Options for creating a CopilotClient @@ -670,6 +649,7 @@ export interface PermissionRequest { } import type { SessionPermissionsHandlePendingPermissionRequestParams } from "./generated/rpc.js"; +import { CopilotSession } from "./session.js"; export type PermissionRequestResult = | SessionPermissionsHandlePendingPermissionRequestParams["result"] @@ -1386,11 +1366,8 @@ export interface SessionContext { /** * Configuration for a custom session filesystem provider. - * - * Extends the generated {@link SessionFsHandler} with registration - * parameters sent to the server's `sessionFs.setProvider` call. */ -export interface SessionFsConfig extends SessionFsHandler { +export interface SessionFsConfig { /** * Initial working directory for sessions (user's project directory). */ @@ -1406,6 +1383,11 @@ export interface SessionFsConfig extends SessionFsHandler { * Path conventions used by this filesystem provider. */ conventions: "windows" | "linux"; + + /** + * Supplies a handler for session filesystem operations. + */ + createHandler: (session: CopilotSession) => SessionFsHandler; } /** diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index dc1280b11..280d374c0 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -149,52 +149,60 @@ function createMemorySessionFs( initialCwd, sessionStatePath, conventions: "linux", - readFile: async ({ sessionId, path }) => { - const content = await provider.readFile(sp(sessionId, path), "utf8"); - return { content: content as string }; - }, - writeFile: async ({ sessionId, path, content }) => { - await provider.writeFile(sp(sessionId, path), content); - }, - appendFile: async ({ sessionId, path, content }) => { - await provider.appendFile(sp(sessionId, path), content); - }, - exists: async ({ sessionId, path }) => { - return { exists: await provider.exists(sp(sessionId, path)) }; - }, - stat: async ({ sessionId, path }) => { - const st = await provider.stat(sp(sessionId, path)); - return { - isFile: st.isFile(), - isDirectory: st.isDirectory(), - size: st.size, - mtime: new Date(st.mtimeMs).toISOString(), - birthtime: new Date(st.birthtimeMs).toISOString(), - }; - }, - mkdir: async ({ sessionId, path, recursive, mode }) => { - await provider.mkdir(sp(sessionId, path), { recursive: recursive ?? false, mode }); - }, - readdir: async ({ sessionId, path }) => { - const entries = await provider.readdir(sp(sessionId, path)); - return { entries: entries as string[] }; - }, - readdirWithTypes: async ({ sessionId, path }) => { - const names = await provider.readdir(sp(sessionId, path)) as string[]; - const entries = await Promise.all( - names.map(async (name) => { - const st = await provider.stat(sp(sessionId, `${path}/${name}`)); - return { name, type: st.isDirectory() ? "directory" as const : "file" as const }; - }), - ); - return { entries }; - }, - rm: async ({ sessionId, path }) => { - await provider.unlink(sp(sessionId, path)); - }, - rename: async ({ sessionId, src, dest }) => { - await provider.rename(sp(sessionId, src), sp(sessionId, dest)); - }, + createHandler: (session) => ({ + readFile: async ({ path }) => { + const content = await provider.readFile(sp(session.sessionId, path), "utf8"); + return { content: content as string }; + }, + writeFile: async ({ path, content }) => { + await provider.writeFile(sp(session.sessionId, path), content); + }, + appendFile: async ({ path, content }) => { + await provider.appendFile(sp(session.sessionId, path), content); + }, + exists: async ({ path }) => { + return { exists: await provider.exists(sp(session.sessionId, path)) }; + }, + stat: async ({ path }) => { + const st = await provider.stat(sp(session.sessionId, path)); + return { + isFile: st.isFile(), + isDirectory: st.isDirectory(), + size: st.size, + mtime: new Date(st.mtimeMs).toISOString(), + birthtime: new Date(st.birthtimeMs).toISOString(), + }; + }, + mkdir: async ({ path, recursive, mode }) => { + await provider.mkdir(sp(session.sessionId, path), { + recursive: recursive ?? false, + mode, + }); + }, + readdir: async ({ path }) => { + const entries = await provider.readdir(sp(session.sessionId, path)); + return { entries: entries as string[] }; + }, + readdirWithTypes: async ({ path }) => { + const names = (await provider.readdir(sp(session.sessionId, path))) as string[]; + const entries = await Promise.all( + names.map(async (name) => { + const st = await provider.stat(sp(session.sessionId, `${path}/${name}`)); + return { + name, + type: st.isDirectory() ? ("directory" as const) : ("file" as const), + }; + }) + ); + return { entries }; + }, + rm: async ({ path }) => { + await provider.unlink(sp(session.sessionId, path)); + }, + rename: async ({ src, dest }) => { + await provider.rename(sp(session.sessionId, src), sp(session.sessionId, dest)); + }, + }), }; return { config }; diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index c8f831c4e..63fbd938a 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -86,9 +86,9 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; `); const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; - const clientMethods = collectRpcMethods(schema.client || {}); + const clientSessionMethods = collectRpcMethods(schema.clientSession || {}); - for (const method of [...allMethods, ...clientMethods]) { + for (const method of [...allMethods, ...clientSessionMethods]) { if (method.result) { const compiled = await compile(method.result, resultTypeName(method.rpcMethod), { bannerComment: "", @@ -135,9 +135,9 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; lines.push(""); } - // Generate client API handler interfaces and registration function - if (schema.client) { - lines.push(...emitClientApiHandlers(schema.client)); + // Generate client session API handler interfaces and registration function + if (schema.clientSession) { + lines.push(...emitClientSessionApiRegistration(schema.clientSession)); } const outPath = await writeGeneratedFile("nodejs/src/generated/rpc.ts", lines.join("\n")); @@ -193,11 +193,11 @@ function emitGroup(node: Record, indent: string, isSession: boo return lines; } -// ── Client API Handler Generation ─────────────────────────────────────────── +// ── Client Session API Handler Generation ─────────────────────────────────── /** * Collect client API methods grouped by their top-level namespace. - * Returns a map like: { sessionStore: [{ rpcMethod, params, result }, ...] } + * Returns a map like: { sessionFs: [{ rpcMethod, params, result }, ...] } */ function collectClientGroups(node: Record): Map { const groups = new Map(); @@ -211,7 +211,7 @@ function collectClientGroups(node: Record): Map): string[] { +function emitClientSessionApiRegistration(clientSchema: Record): string[] { const lines: string[] = []; const groups = collectClientGroups(clientSchema); // Emit a handler interface per group for (const [groupName, methods] of groups) { const interfaceName = toPascalCase(groupName) + "Handler"; - lines.push(`/**`); - lines.push(` * Handler interface for the \`${groupName}\` client API group.`); - lines.push(` * Implement this to provide a custom ${groupName} backend.`); - lines.push(` */`); + lines.push(`/** Handler for \`${groupName}\` client session API methods. */`); lines.push(`export interface ${interfaceName} {`); - for (const method of methods) { const name = handlerMethodName(method.rpcMethod); const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; const pType = hasParams ? paramsTypeName(method.rpcMethod) : ""; const rType = method.result ? resultTypeName(method.rpcMethod) : "void"; - const sig = hasParams - ? ` ${name}(params: ${pType}): Promise<${rType}>;` - : ` ${name}(): Promise<${rType}>;`; - lines.push(sig); + if (hasParams) { + lines.push(` ${name}(params: ${pType}): Promise<${rType}>;`); + } else { + lines.push(` ${name}(): Promise<${rType}>;`); + } } - lines.push(`}`); lines.push(""); } - // Emit combined ClientApiHandlers type - lines.push(`/** All client API handler groups. Each group is optional. */`); - lines.push(`export interface ClientApiHandlers {`); + // Emit combined ClientSessionApiHandlers type + lines.push(`/** All client session API handler groups. */`); + lines.push(`export interface ClientSessionApiHandlers {`); for (const [groupName] of groups) { const interfaceName = toPascalCase(groupName) + "Handler"; lines.push(` ${groupName}?: ${interfaceName};`); @@ -262,33 +263,36 @@ function emitClientApiHandlers(clientSchema: Record): string[] // Emit registration function lines.push(`/**`); - lines.push(` * Register client API handlers on a JSON-RPC connection.`); + lines.push(` * Register client session API handlers on a JSON-RPC connection.`); lines.push(` * The server calls these methods to delegate work to the client.`); - lines.push(` * Methods for unregistered groups will respond with a standard JSON-RPC`); - lines.push(` * method-not-found error.`); + lines.push(` * Each incoming call includes a \`sessionId\` in the params; the registration`); + lines.push(` * function uses \`getHandlers\` to resolve the session's handlers.`); lines.push(` */`); - lines.push(`export function registerClientApiHandlers(`); + lines.push(`export function registerClientSessionApiHandlers(`); lines.push(` connection: MessageConnection,`); - lines.push(` handlers: ClientApiHandlers,`); + lines.push(` getHandlers: (sessionId: string) => ClientSessionApiHandlers | undefined,`); lines.push(`): void {`); for (const [groupName, methods] of groups) { - lines.push(` if (handlers.${groupName}) {`); - lines.push(` const h = handlers.${groupName}!;`); - for (const method of methods) { const name = handlerMethodName(method.rpcMethod); + const pType = paramsTypeName(method.rpcMethod); const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; - const pType = hasParams ? paramsTypeName(method.rpcMethod) : ""; if (hasParams) { - lines.push(` connection.onRequest("${method.rpcMethod}", (params: ${pType}) => h.${name}(params));`); + lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); + lines.push(` const handlers = getHandlers(params.sessionId);`); + lines.push(` if (!handlers) throw new Error(\`No session found for sessionId: \${params.sessionId}\`);`); + lines.push(` const handler = handlers.${groupName};`); + lines.push(` if (!handler) throw new Error(\`No ${groupName} handler registered for session: \${params.sessionId}\`);`); + lines.push(` return handler.${name}(params);`); + lines.push(` });`); } else { - lines.push(` connection.onRequest("${method.rpcMethod}", () => h.${name}());`); + lines.push(` connection.onRequest("${method.rpcMethod}", async () => {`); + lines.push(` throw new Error("No params provided for ${method.rpcMethod}");`); + lines.push(` });`); } } - - lines.push(` }`); } lines.push(`}`); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index bc508e240..1e95b4dd4 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -132,7 +132,7 @@ export interface RpcMethod { export interface ApiSchema { server?: Record; session?: Record; - client?: Record; + clientSession?: Record; } export function isRpcMethod(node: unknown): node is RpcMethod { From 3b030dbee35cd2820156bc575f629378e8e66730 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 30 Mar 2026 15:46:27 +0100 Subject: [PATCH 08/18] Simplify --- nodejs/src/client.ts | 8 ++++--- nodejs/src/generated/rpc.ts | 42 +++++++++-------------------------- scripts/codegen/typescript.ts | 6 ++--- 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9dd975a21..e6205efee 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1647,9 +1647,11 @@ export class CopilotClient { // Register client session API handlers. const sessions = this.sessions; - registerClientSessionApiHandlers(this.connection, (sessionId) => - sessions.get(sessionId)?.clientSessionApis, - ); + registerClientSessionApiHandlers(this.connection, (sessionId) => { + const session = sessions.get(sessionId); + if (!session) throw new Error(`No session found for sessionId: ${sessionId}`); + return session.clientSessionApis; + }); this.connection.onClose(() => { this.state = "disconnected"; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 6316e18d3..16763785a 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1438,75 +1438,55 @@ export interface ClientSessionApiHandlers { */ export function registerClientSessionApiHandlers( connection: MessageConnection, - getHandlers: (sessionId: string) => ClientSessionApiHandlers | undefined, + getHandlers: (sessionId: string) => ClientSessionApiHandlers, ): void { connection.onRequest("sessionFs.readFile", async (params: SessionFsReadFileParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.readFile(params); }); connection.onRequest("sessionFs.writeFile", async (params: SessionFsWriteFileParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.writeFile(params); }); connection.onRequest("sessionFs.appendFile", async (params: SessionFsAppendFileParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.appendFile(params); }); connection.onRequest("sessionFs.exists", async (params: SessionFsExistsParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.exists(params); }); connection.onRequest("sessionFs.stat", async (params: SessionFsStatParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.stat(params); }); connection.onRequest("sessionFs.mkdir", async (params: SessionFsMkdirParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.mkdir(params); }); connection.onRequest("sessionFs.readdir", async (params: SessionFsReaddirParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.readdir(params); }); connection.onRequest("sessionFs.readdirWithTypes", async (params: SessionFsReaddirWithTypesParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.readdirWithTypes(params); }); connection.onRequest("sessionFs.rm", async (params: SessionFsRmParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.rm(params); }); connection.onRequest("sessionFs.rename", async (params: SessionFsRenameParams) => { - const handlers = getHandlers(params.sessionId); - if (!handlers) throw new Error(`No session found for sessionId: ${params.sessionId}`); - const handler = handlers.sessionFs; + const handler = getHandlers(params.sessionId).sessionFs; if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); return handler.rename(params); }); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 63fbd938a..1939d5152 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -270,7 +270,7 @@ function emitClientSessionApiRegistration(clientSchema: Record) lines.push(` */`); lines.push(`export function registerClientSessionApiHandlers(`); lines.push(` connection: MessageConnection,`); - lines.push(` getHandlers: (sessionId: string) => ClientSessionApiHandlers | undefined,`); + lines.push(` getHandlers: (sessionId: string) => ClientSessionApiHandlers,`); lines.push(`): void {`); for (const [groupName, methods] of groups) { @@ -281,9 +281,7 @@ function emitClientSessionApiRegistration(clientSchema: Record) if (hasParams) { lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); - lines.push(` const handlers = getHandlers(params.sessionId);`); - lines.push(` if (!handlers) throw new Error(\`No session found for sessionId: \${params.sessionId}\`);`); - lines.push(` const handler = handlers.${groupName};`); + lines.push(` const handler = getHandlers(params.sessionId).${groupName};`); lines.push(` if (!handler) throw new Error(\`No ${groupName} handler registered for session: \${params.sessionId}\`);`); lines.push(` return handler.${name}(params);`); lines.push(` });`); From bbc11a8bff78a03e929c41e4b31a4258d468500f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Mar 2026 13:52:50 +0100 Subject: [PATCH 09/18] Move createSessionFsHandler onto SessionConfig --- nodejs/src/client.ts | 16 ++- nodejs/src/types.ts | 15 +-- nodejs/test/e2e/session_fs.test.ts | 175 +++++++++++++++-------------- 3 files changed, 112 insertions(+), 94 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index e6205efee..23aac99a3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -681,7 +681,13 @@ export class CopilotClient { } this.sessions.set(sessionId, session); if (this.sessionFsConfig) { - session.clientSessionApis.sessionFs = this.sessionFsConfig.createHandler(session); + if (config.createSessionFsHandler) { + session.clientSessionApis.sessionFs = config.createSessionFsHandler(session); + } else { + throw new Error( + "createSessionFsHandler is required in session config when sessionFs is enabled in client options." + ); + } } try { @@ -810,7 +816,13 @@ export class CopilotClient { } this.sessions.set(sessionId, session); if (this.sessionFsConfig) { - session.clientSessionApis.sessionFs = this.sessionFsConfig.createHandler(session); + if (config.createSessionFsHandler) { + session.clientSessionApis.sessionFs = config.createSessionFsHandler(session); + } else { + throw new Error( + "createSessionFsHandler is required in session config when sessionFs is enabled in client options." + ); + } } try { diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 266caff4f..f475bfb8e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -11,7 +11,7 @@ import type { SessionEvent as GeneratedSessionEvent } from "./generated/session- export type SessionEvent = GeneratedSessionEvent; import type { SessionFsHandler } from "./generated/rpc.js"; -export type { SessionFsHandler } from "./generated/rpc.js"; +import type { CopilotSession } from "./session.js"; /** * Options for creating a CopilotClient @@ -649,7 +649,6 @@ export interface PermissionRequest { } import type { SessionPermissionsHandlePendingPermissionRequestParams } from "./generated/rpc.js"; -import { CopilotSession } from "./session.js"; export type PermissionRequestResult = | SessionPermissionsHandlePendingPermissionRequestParams["result"] @@ -1193,6 +1192,12 @@ export interface SessionConfig { * but executes earlier in the lifecycle so no events are missed. */ onEvent?: SessionEventHandler; + + /** + * Supplies a handler for session filesystem operations. This takes effect + * only if {@link CopilotClientOptions.sessionFs} is configured. + */ + createSessionFsHandler?: (session: CopilotSession) => SessionFsHandler; } /** @@ -1223,6 +1228,7 @@ export type ResumeSessionConfig = Pick< | "disabledSkills" | "infiniteSessions" | "onEvent" + | "createSessionFsHandler" > & { /** * When true, skips emitting the session.resume event. @@ -1383,11 +1389,6 @@ export interface SessionFsConfig { * Path conventions used by this filesystem provider. */ conventions: "windows" | "linux"; - - /** - * Supplies a handler for session filesystem operations. - */ - createHandler: (session: CopilotSession) => SessionFsHandler; } /** diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index 280d374c0..c7f5bc17f 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -2,30 +2,42 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { MemoryProvider } from "@platformatic/vfs"; +import { MemoryProvider, VirtualProvider } from "@platformatic/vfs"; import { describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient } from "../../src/client.js"; -import { approveAll, defineTool, SessionEvent, type SessionFsConfig } from "../../src/index.js"; +import { SessionFsHandler } from "../../src/generated/rpc.js"; +import { + approveAll, + CopilotSession, + defineTool, + SessionEvent, + type SessionFsConfig, +} from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; +process.env.COPILOT_CLI_PATH = + "c:\\Users\\stevesa\\.copilot\\worktrees\\copilot-agent-runtime\\amber-aura\\dist-cli\\index.js"; + describe("Session Fs", async () => { // Single provider for the describe block — session IDs are unique per test, // so no cross-contamination between tests. const provider = new MemoryProvider(); - const { config } = createMemorySessionFs("/projects/test", "/session-state", provider); + const createSessionFsHandler = (session: CopilotSession) => + createTestSessionFsHandler(session, provider); // Helpers to build session-namespaced paths for direct provider assertions const p = (sessionId: string, path: string) => `/${sessionId}${path.startsWith("/") ? path : "/" + path}`; const { copilotClient: client, env } = await createSdkTestContext({ - copilotClientOptions: { - sessionFs: config, - }, + copilotClientOptions: { sessionFs: sessionFsConfig }, }); it("should route file operations through the session fs provider", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + createSessionFsHandler, + }); const msg = await session.sendAndWait({ prompt: "What is 100 + 200?" }); expect(msg?.data.content).toContain("300"); @@ -37,7 +49,10 @@ describe("Session Fs", async () => { }); it("should load session data from fs provider on resume", async () => { - const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const session1 = await client.createSession({ + onPermissionRequest: approveAll, + createSessionFsHandler, + }); const sessionId = session1.sessionId; const msg = await session1.sendAndWait({ prompt: "What is 50 + 50?" }); @@ -49,6 +64,7 @@ describe("Session Fs", async () => { const session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll, + createSessionFsHandler, }); // Send another message to verify the session is functional after resume @@ -62,23 +78,18 @@ describe("Session Fs", async () => { useStdio: false, // Use TCP so we can connect from a second client env, }); - await client.createSession({ onPermissionRequest: approveAll }); + await client.createSession({ onPermissionRequest: approveAll, createSessionFsHandler }); // Get the port the first client's runtime is listening on const port = (client as unknown as { actualPort: number }).actualPort; // Second client tries to connect with a session fs — should fail // because sessions already exist on the runtime. - const { config: config2 } = createMemorySessionFs( - "/projects/test", - "/session-state", - new MemoryProvider() - ); const client2 = new CopilotClient({ env, logLevel: "error", cliUrl: `localhost:${port}`, - sessionFs: config2, + sessionFs: sessionFsConfig, }); onTestFinished(() => client2.forceStop()); @@ -89,6 +100,7 @@ describe("Session Fs", async () => { const suppliedFileContent = "x".repeat(100_000); const session = await client.createSession({ onPermissionRequest: approveAll, + createSessionFsHandler, tools: [ defineTool("get_big_string", { description: "Returns a large string", @@ -132,78 +144,71 @@ function findToolName(messages: SessionEvent[], toolCallId: string): string | un } } -/** - * Builds a SessionFsConfig backed by a @platformatic/vfs MemoryProvider. - * Each sessionId is namespaced under `//` in the provider's tree. - * Tests can assert directly against the returned MemoryProvider instance. - */ -function createMemorySessionFs( - initialCwd: string, - sessionStatePath: string, - provider: MemoryProvider -): { config: SessionFsConfig } { +const sessionFsConfig: SessionFsConfig = { + initialCwd: "/", + sessionStatePath: "/session-state", + conventions: "linux", +}; + +function createTestSessionFsHandler( + session: CopilotSession, + provider: VirtualProvider +): SessionFsHandler { const sp = (sessionId: string, path: string) => `/${sessionId}${path.startsWith("/") ? path : "/" + path}`; - const config: SessionFsConfig = { - initialCwd, - sessionStatePath, - conventions: "linux", - createHandler: (session) => ({ - readFile: async ({ path }) => { - const content = await provider.readFile(sp(session.sessionId, path), "utf8"); - return { content: content as string }; - }, - writeFile: async ({ path, content }) => { - await provider.writeFile(sp(session.sessionId, path), content); - }, - appendFile: async ({ path, content }) => { - await provider.appendFile(sp(session.sessionId, path), content); - }, - exists: async ({ path }) => { - return { exists: await provider.exists(sp(session.sessionId, path)) }; - }, - stat: async ({ path }) => { - const st = await provider.stat(sp(session.sessionId, path)); - return { - isFile: st.isFile(), - isDirectory: st.isDirectory(), - size: st.size, - mtime: new Date(st.mtimeMs).toISOString(), - birthtime: new Date(st.birthtimeMs).toISOString(), - }; - }, - mkdir: async ({ path, recursive, mode }) => { - await provider.mkdir(sp(session.sessionId, path), { - recursive: recursive ?? false, - mode, - }); - }, - readdir: async ({ path }) => { - const entries = await provider.readdir(sp(session.sessionId, path)); - return { entries: entries as string[] }; - }, - readdirWithTypes: async ({ path }) => { - const names = (await provider.readdir(sp(session.sessionId, path))) as string[]; - const entries = await Promise.all( - names.map(async (name) => { - const st = await provider.stat(sp(session.sessionId, `${path}/${name}`)); - return { - name, - type: st.isDirectory() ? ("directory" as const) : ("file" as const), - }; - }) - ); - return { entries }; - }, - rm: async ({ path }) => { - await provider.unlink(sp(session.sessionId, path)); - }, - rename: async ({ src, dest }) => { - await provider.rename(sp(session.sessionId, src), sp(session.sessionId, dest)); - }, - }), + return { + readFile: async ({ path }) => { + const content = await provider.readFile(sp(session.sessionId, path), "utf8"); + return { content: content as string }; + }, + writeFile: async ({ path, content }) => { + await provider.writeFile(sp(session.sessionId, path), content); + }, + appendFile: async ({ path, content }) => { + await provider.appendFile(sp(session.sessionId, path), content); + }, + exists: async ({ path }) => { + return { exists: await provider.exists(sp(session.sessionId, path)) }; + }, + stat: async ({ path }) => { + const st = await provider.stat(sp(session.sessionId, path)); + return { + isFile: st.isFile(), + isDirectory: st.isDirectory(), + size: st.size, + mtime: new Date(st.mtimeMs).toISOString(), + birthtime: new Date(st.birthtimeMs).toISOString(), + }; + }, + mkdir: async ({ path, recursive, mode }) => { + await provider.mkdir(sp(session.sessionId, path), { + recursive: recursive ?? false, + mode, + }); + }, + readdir: async ({ path }) => { + const entries = await provider.readdir(sp(session.sessionId, path)); + return { entries: entries as string[] }; + }, + readdirWithTypes: async ({ path }) => { + const names = (await provider.readdir(sp(session.sessionId, path))) as string[]; + const entries = await Promise.all( + names.map(async (name) => { + const st = await provider.stat(sp(session.sessionId, `${path}/${name}`)); + return { + name, + type: st.isDirectory() ? ("directory" as const) : ("file" as const), + }; + }) + ); + return { entries }; + }, + rm: async ({ path }) => { + await provider.unlink(sp(session.sessionId, path)); + }, + rename: async ({ src, dest }) => { + await provider.rename(sp(session.sessionId, src), sp(session.sessionId, dest)); + }, }; - - return { config }; } From c98a3432e9029d4bfa54b0a6a174bccc0b44dc79 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 31 Mar 2026 13:53:51 +0100 Subject: [PATCH 10/18] Fix --- nodejs/src/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index f475bfb8e..d13cd5055 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -7,11 +7,11 @@ */ // Import and re-export generated session event types -import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; -export type SessionEvent = GeneratedSessionEvent; - import type { SessionFsHandler } from "./generated/rpc.js"; +import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; +export type SessionEvent = GeneratedSessionEvent; +export type { SessionFsHandler } from "./generated/rpc.js"; /** * Options for creating a CopilotClient From 1625c5d79731672312f07d05dca05f8e545abf7d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 1 Apr 2026 13:06:24 +0100 Subject: [PATCH 11/18] Update to newer API schema --- nodejs/src/generated/rpc.ts | 224 ++++++++++++++++++++++++++++- nodejs/src/types.ts | 2 +- nodejs/test/e2e/session_fs.test.ts | 2 +- scripts/codegen/typescript.ts | 2 +- 4 files changed, 224 insertions(+), 6 deletions(-) diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 16763785a..845d49129 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -179,6 +179,167 @@ export interface AccountGetQuotaResult { }; } +export interface McpConfigListResult { + /** + * All MCP servers from user config, keyed by name + */ + servers: { + /** + * MCP server configuration (local/stdio or remote/http) + */ + [k: string]: + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type?: "local" | "stdio"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + command: string; + args: string[]; + cwd?: string; + env?: { + [k: string]: string; + }; + } + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type: "http" | "sse"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + url: string; + headers?: { + [k: string]: string; + }; + oauthClientId?: string; + oauthPublicClient?: boolean; + }; + }; +} + +export interface McpConfigAddParams { + /** + * Unique name for the MCP server + */ + name: string; + /** + * MCP server configuration (local/stdio or remote/http) + */ + config: + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type?: "local" | "stdio"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + command: string; + args: string[]; + cwd?: string; + env?: { + [k: string]: string; + }; + } + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type: "http" | "sse"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + url: string; + headers?: { + [k: string]: string; + }; + oauthClientId?: string; + oauthPublicClient?: boolean; + }; +} + +export interface McpConfigUpdateParams { + /** + * Name of the MCP server to update + */ + name: string; + /** + * MCP server configuration (local/stdio or remote/http) + */ + config: + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type?: "local" | "stdio"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + command: string; + args: string[]; + cwd?: string; + env?: { + [k: string]: string; + }; + } + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type: "http" | "sse"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + url: string; + headers?: { + [k: string]: string; + }; + oauthClientId?: string; + oauthPublicClient?: boolean; + }; +} + +export interface McpConfigRemoveParams { + /** + * Name of the MCP server to remove + */ + name: string; +} + export interface SessionFsSetProviderResult { /** * Whether the provider was set successfully @@ -198,7 +359,7 @@ export interface SessionFsSetProviderParams { /** * Path conventions used by this filesystem */ - conventions: "windows" | "linux"; + conventions: "windows" | "posix"; } export interface SessionModelGetCurrentResult { @@ -606,9 +767,9 @@ export interface SessionMcpListResult { */ name: string; /** - * Connection status: connected, failed, pending, disabled, or not_configured + * Connection status: connected, failed, needs-auth, pending, disabled, or not_configured */ - status: "connected" | "failed" | "pending" | "disabled" | "not_configured"; + status: "connected" | "failed" | "needs-auth" | "pending" | "disabled" | "not_configured"; /** * Configuration source: user, workspace, plugin, or builtin */ @@ -1162,6 +1323,9 @@ export interface SessionFsAppendFileParams { } export interface SessionFsExistsResult { + /** + * Whether the path exists + */ exists: boolean; } @@ -1177,8 +1341,17 @@ export interface SessionFsExistsParams { } export interface SessionFsStatResult { + /** + * Whether the path is a file + */ isFile: boolean; + /** + * Whether the path is a directory + */ isDirectory: boolean; + /** + * File size in bytes + */ size: number; /** * ISO 8601 timestamp of last modification @@ -1206,7 +1379,13 @@ export interface SessionFsMkdirParams { * Target session identifier */ sessionId: string; + /** + * Path using SessionFs conventions + */ path: string; + /** + * Create parent directories as needed + */ recursive?: boolean; /** * Optional POSIX-style mode for newly created directories @@ -1226,6 +1405,9 @@ export interface SessionFsReaddirParams { * Target session identifier */ sessionId: string; + /** + * Path using SessionFs conventions + */ path: string; } @@ -1234,7 +1416,13 @@ export interface SessionFsReaddirWithTypesResult { * Directory entries with type information */ entries: { + /** + * Entry name + */ name: string; + /** + * Entry type + */ type: "file" | "directory"; }[]; } @@ -1244,6 +1432,9 @@ export interface SessionFsReaddirWithTypesParams { * Target session identifier */ sessionId: string; + /** + * Path using SessionFs conventions + */ path: string; } @@ -1252,8 +1443,17 @@ export interface SessionFsRmParams { * Target session identifier */ sessionId: string; + /** + * Path using SessionFs conventions + */ path: string; + /** + * Remove directories and their contents recursively + */ recursive?: boolean; + /** + * Ignore errors if the path does not exist + */ force?: boolean; } @@ -1262,7 +1462,13 @@ export interface SessionFsRenameParams { * Target session identifier */ sessionId: string; + /** + * Source path using SessionFs conventions + */ src: string; + /** + * Destination path using SessionFs conventions + */ dest: string; } @@ -1283,6 +1489,18 @@ export function createServerRpc(connection: MessageConnection) { getQuota: async (): Promise => connection.sendRequest("account.getQuota", {}), }, + mcp: { + config: { + list: async (): Promise => + connection.sendRequest("mcp.config.list", {}), + add: async (params: McpConfigAddParams): Promise => + connection.sendRequest("mcp.config.add", params), + update: async (params: McpConfigUpdateParams): Promise => + connection.sendRequest("mcp.config.update", params), + remove: async (params: McpConfigRemoveParams): Promise => + connection.sendRequest("mcp.config.remove", params), + }, + }, sessionFs: { setProvider: async (params: SessionFsSetProviderParams): Promise => connection.sendRequest("sessionFs.setProvider", params), diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index d13cd5055..10bbf68db 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1388,7 +1388,7 @@ export interface SessionFsConfig { /** * Path conventions used by this filesystem provider. */ - conventions: "windows" | "linux"; + conventions: "windows" | "posix"; } /** diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index c7f5bc17f..ad36d49c8 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -147,7 +147,7 @@ function findToolName(messages: SessionEvent[], toolCallId: string): string | un const sessionFsConfig: SessionFsConfig = { initialCwd: "/", sessionStatePath: "/session-state", - conventions: "linux", + conventions: "posix", }; function createTestSessionFsHandler( diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 1939d5152..e5e82bdc6 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -149,7 +149,7 @@ function emitGroup(node: Record, indent: string, isSession: boo for (const [key, value] of Object.entries(node)) { if (isRpcMethod(value)) { const { rpcMethod, params } = value; - const resultType = resultTypeName(rpcMethod); + const resultType = value.result ? resultTypeName(rpcMethod) : "void"; const paramsType = paramsTypeName(rpcMethod); const paramEntries = params?.properties ? Object.entries(params.properties).filter(([k]) => k !== "sessionId") : []; From 1b8dd2a05ce3b1a96ed9ad4cd321efc5efd0d122 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 1 Apr 2026 13:39:32 +0100 Subject: [PATCH 12/18] Add compaction+sessionFs test --- nodejs/test/e2e/session_fs.test.ts | 21 ++++++ ...with_compaction_while_using_sessionfs.yaml | 75 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 test/snapshots/session_fs/should_succeed_with_compaction_while_using_sessionfs.yaml diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index ad36d49c8..9e6fb6d2d 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +import { SessionCompactionCompleteEvent } from "@github/copilot/sdk"; import { MemoryProvider, VirtualProvider } from "@platformatic/vfs"; import { describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient } from "../../src/client.js"; @@ -124,6 +125,26 @@ describe("Session Fs", async () => { const fileContent = await provider.readFile(p(session.sessionId, filename!), "utf8"); expect(fileContent).toBe(suppliedFileContent); }); + + it("should succeed with compaction while using sessionFs", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + createSessionFsHandler, + }); + + let compactionEvent: SessionCompactionCompleteEvent | undefined; + session.on("session.compaction_complete", (evt) => (compactionEvent = evt)); + + await session.sendAndWait({ prompt: "What is 2+2?" }); + + const eventsPath = p(session.sessionId, "/session-state/events.jsonl"); + await expect.poll(() => provider.exists(eventsPath)).toBe(true); + + await session.rpc.compaction.compact(); + await expect.poll(() => compactionEvent).toBeDefined(); + expect(compactionEvent!.data.success).toBe(true); + expect(compactionEvent!.data.summaryContent).toContain(""); + }); }); function findToolCallResult(messages: SessionEvent[], toolName: string): string | undefined { diff --git a/test/snapshots/session_fs/should_succeed_with_compaction_while_using_sessionfs.yaml b/test/snapshots/session_fs/should_succeed_with_compaction_while_using_sessionfs.yaml new file mode 100644 index 000000000..2b984d74c --- /dev/null +++ b/test/snapshots/session_fs/should_succeed_with_compaction_while_using_sessionfs.yaml @@ -0,0 +1,75 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: 2 + 2 = 4 + - role: user + content: ${compaction_prompt} + - role: assistant + content: >- + + + The user asked a simple arithmetic question ("What is 2+2?"). This was a minimal interaction with no technical + work, coding tasks, or file modifications requested or performed. The conversation consisted solely of + providing a basic mathematical answer. + + + + + + + 1. The user asked "What is 2+2?" + - Provided the answer: 4 + - No further requests or actions were needed + + 2. The user requested a checkpoint summary + - Creating this summary to preserve conversation context before history compaction + + + + + + No files were created, modified, or deleted. No technical work was performed. The conversation consisted only + of answering a simple arithmetic question. + + + Current state: + + - No active tasks + + - No code changes + + - No systems or processes started + + + + + + + No technical work was performed during this conversation. No technical decisions, issues, or discoveries were + made. + + + + + + + No files are relevant to this conversation, as no technical work was performed. + + + + + + + No pending work or next steps. The user's request (answering "2+2") has been completed. Awaiting further + instructions from the user. + + + + + Simple arithmetic question answered From fcd6746de25e5e2aac60b5a0bc1ec51ea08a26f1 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 1 Apr 2026 13:43:50 +0100 Subject: [PATCH 13/18] Improve compaction test --- nodejs/test/e2e/session_fs.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index 9e6fb6d2d..cda538569 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -139,11 +139,17 @@ describe("Session Fs", async () => { const eventsPath = p(session.sessionId, "/session-state/events.jsonl"); await expect.poll(() => provider.exists(eventsPath)).toBe(true); + const contentBefore = await provider.readFile(eventsPath, "utf8"); + expect(contentBefore).not.toContain("checkpointNumber"); await session.rpc.compaction.compact(); await expect.poll(() => compactionEvent).toBeDefined(); expect(compactionEvent!.data.success).toBe(true); - expect(compactionEvent!.data.summaryContent).toContain(""); + + // Verify the events file was rewritten with a checkpoint via sessionFs + await expect + .poll(() => provider.readFile(eventsPath, "utf8")) + .toContain("checkpointNumber"); }); }); From 6d6e3e13ef8d28d543864aeba7f141137d28f1ab Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 1 Apr 2026 13:54:31 +0100 Subject: [PATCH 14/18] Update codegen output --- dotnet/src/Generated/Rpc.cs | 83 +++++- go/rpc/generated_rpc.go | 157 +++++++++++- python/copilot/generated/rpc.py | 430 +++++++++++++++++++++++++++++++- scripts/codegen/csharp.ts | 11 +- 4 files changed, 663 insertions(+), 18 deletions(-) diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 406a961a2..3c1035e20 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -219,6 +219,30 @@ public class AccountGetQuotaResult public Dictionary QuotaSnapshots { get => field ??= []; set; } } +/// RPC data type for SessionFsSetProvider operations. +public class SessionFsSetProviderResult +{ + /// Whether the provider was set successfully. + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +/// RPC data type for SessionFsSetProvider operations. +internal class SessionFsSetProviderRequest +{ + /// Initial working directory for sessions. + [JsonPropertyName("initialCwd")] + public string InitialCwd { get; set; } = string.Empty; + + /// Path within each session's SessionFs where the runtime stores files for that session. + [JsonPropertyName("sessionStatePath")] + public string SessionStatePath { get; set; } = string.Empty; + + /// Path conventions used by this filesystem. + [JsonPropertyName("conventions")] + public SessionFsSetProviderRequestConventions Conventions { get; set; } +} + /// RPC data type for SessionLog operations. public class SessionLogResult { @@ -705,7 +729,7 @@ public class Server [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; - /// Connection status: connected, failed, pending, disabled, or not_configured. + /// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonPropertyName("status")] public ServerStatus Status { get; set; } @@ -1156,6 +1180,19 @@ internal class SessionShellKillRequest public SessionShellKillRequestSignal? Signal { get; set; } } +/// Path conventions used by this filesystem. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionFsSetProviderRequestConventions +{ + /// The windows variant. + [JsonStringEnumMemberName("windows")] + Windows, + /// The posix variant. + [JsonStringEnumMemberName("posix")] + Posix, +} + + /// Log severity level. Determines how the message is displayed in the timeline. Defaults to "info". [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionLogRequestLevel @@ -1188,7 +1225,7 @@ public enum SessionModeGetResultMode } -/// Connection status: connected, failed, pending, disabled, or not_configured. +/// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonConverter(typeof(JsonStringEnumConverter))] public enum ServerStatus { @@ -1198,6 +1235,9 @@ public enum ServerStatus /// The failed variant. [JsonStringEnumMemberName("failed")] Failed, + /// The needs-auth variant. + [JsonStringEnumMemberName("needs-auth")] + NeedsAuth, /// The pending variant. [JsonStringEnumMemberName("pending")] Pending, @@ -1285,6 +1325,8 @@ internal ServerRpc(JsonRpc rpc) Models = new ServerModelsApi(rpc); Tools = new ServerToolsApi(rpc); Account = new ServerAccountApi(rpc); + Mcp = new ServerMcpApi(rpc); + SessionFs = new ServerSessionFsApi(rpc); } /// Calls "ping". @@ -1302,6 +1344,12 @@ public async Task PingAsync(string? message = null, CancellationToke /// Account APIs. public ServerAccountApi Account { get; } + + /// Mcp APIs. + public ServerMcpApi Mcp { get; } + + /// SessionFs APIs. + public ServerSessionFsApi SessionFs { get; } } /// Provides server-scoped Models APIs. @@ -1356,6 +1404,35 @@ public async Task GetQuotaAsync(CancellationToken cancell } } +/// Provides server-scoped Mcp APIs. +public class ServerMcpApi +{ + private readonly JsonRpc _rpc; + + internal ServerMcpApi(JsonRpc rpc) + { + _rpc = rpc; + } +} + +/// Provides server-scoped SessionFs APIs. +public class ServerSessionFsApi +{ + private readonly JsonRpc _rpc; + + internal ServerSessionFsApi(JsonRpc rpc) + { + _rpc = rpc; + } + + /// Calls "sessionFs.setProvider". + public async Task SetProviderAsync(string initialCwd, string sessionStatePath, SessionFsSetProviderRequestConventions conventions, CancellationToken cancellationToken = default) + { + var request = new SessionFsSetProviderRequest { InitialCwd = initialCwd, SessionStatePath = sessionStatePath, Conventions = conventions }; + return await CopilotClient.InvokeRpcAsync(_rpc, "sessionFs.setProvider", [request], cancellationToken); + } +} + /// Provides typed session-scoped RPC methods. public class SessionRpc { @@ -1959,6 +2036,8 @@ public async Task KillAsync(string processId, SessionShe [JsonSerializable(typeof(SessionExtensionsReloadResult))] [JsonSerializable(typeof(SessionFleetStartRequest))] [JsonSerializable(typeof(SessionFleetStartResult))] +[JsonSerializable(typeof(SessionFsSetProviderRequest))] +[JsonSerializable(typeof(SessionFsSetProviderResult))] [JsonSerializable(typeof(SessionLogRequest))] [JsonSerializable(typeof(SessionLogResult))] [JsonSerializable(typeof(SessionMcpDisableRequest))] diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index e9042e964..f6011d900 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -131,6 +131,98 @@ type QuotaSnapshot struct { UsedRequests float64 `json:"usedRequests"` } +type MCPConfigListResult struct { + // All MCP servers from user config, keyed by name + Servers map[string]ServerValue `json:"servers"` +} + +// MCP server configuration (local/stdio or remote/http) +type ServerValue struct { + Args []string `json:"args,omitempty"` + Command *string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + FilterMapping *FilterMappingUnion `json:"filterMapping"` + IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + Timeout *float64 `json:"timeout,omitempty"` + // Tools to include. Defaults to all tools if not specified. + Tools []string `json:"tools,omitempty"` + Type *ServerType `json:"type,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + OauthClientID *string `json:"oauthClientId,omitempty"` + OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + URL *string `json:"url,omitempty"` +} + +type MCPConfigAddParams struct { + // MCP server configuration (local/stdio or remote/http) + Config MCPConfigAddParamsConfig `json:"config"` + // Unique name for the MCP server + Name string `json:"name"` +} + +// MCP server configuration (local/stdio or remote/http) +type MCPConfigAddParamsConfig struct { + Args []string `json:"args,omitempty"` + Command *string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + FilterMapping *FilterMappingUnion `json:"filterMapping"` + IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + Timeout *float64 `json:"timeout,omitempty"` + // Tools to include. Defaults to all tools if not specified. + Tools []string `json:"tools,omitempty"` + Type *ServerType `json:"type,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + OauthClientID *string `json:"oauthClientId,omitempty"` + OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + URL *string `json:"url,omitempty"` +} + +type MCPConfigUpdateParams struct { + // MCP server configuration (local/stdio or remote/http) + Config MCPConfigUpdateParamsConfig `json:"config"` + // Name of the MCP server to update + Name string `json:"name"` +} + +// MCP server configuration (local/stdio or remote/http) +type MCPConfigUpdateParamsConfig struct { + Args []string `json:"args,omitempty"` + Command *string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + FilterMapping *FilterMappingUnion `json:"filterMapping"` + IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + Timeout *float64 `json:"timeout,omitempty"` + // Tools to include. Defaults to all tools if not specified. + Tools []string `json:"tools,omitempty"` + Type *ServerType `json:"type,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + OauthClientID *string `json:"oauthClientId,omitempty"` + OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + URL *string `json:"url,omitempty"` +} + +type MCPConfigRemoveParams struct { + // Name of the MCP server to remove + Name string `json:"name"` +} + +type SessionFSSetProviderResult struct { + // Whether the provider was set successfully + Success bool `json:"success"` +} + +type SessionFSSetProviderParams struct { + // Path conventions used by this filesystem + Conventions Conventions `json:"conventions"` + // Initial working directory for sessions + InitialCwd string `json:"initialCwd"` + // Path within each session's SessionFs where the runtime stores files for that session + SessionStatePath string `json:"sessionStatePath"` +} + type SessionModelGetCurrentResult struct { // Currently active model identifier ModelID *string `json:"modelId,omitempty"` @@ -338,17 +430,17 @@ type SessionSkillsReloadResult struct { type SessionMCPListResult struct { // Configured MCP servers - Servers []Server `json:"servers"` + Servers []ServerElement `json:"servers"` } -type Server struct { +type ServerElement struct { // Error message if the server failed to connect Error *string `json:"error,omitempty"` // Server name (config key) Name string `json:"name"` // Configuration source: user, workspace, plugin, or builtin Source *string `json:"source,omitempty"` - // Connection status: connected, failed, pending, disabled, or not_configured + // Connection status: connected, failed, needs-auth, pending, disabled, or not_configured Status ServerStatus `json:"status"` } @@ -610,6 +702,31 @@ type SessionShellKillParams struct { Signal *Signal `json:"signal,omitempty"` } +type FilterMappingEnum string + +const ( + FilterMappingEnumHiddenCharacters FilterMappingEnum = "hidden_characters" + FilterMappingEnumMarkdown FilterMappingEnum = "markdown" + FilterMappingEnumNone FilterMappingEnum = "none" +) + +type ServerType string + +const ( + ServerTypeHTTP ServerType = "http" + ServerTypeLocal ServerType = "local" + ServerTypeSse ServerType = "sse" + ServerTypeStdio ServerType = "stdio" +) + +// Path conventions used by this filesystem +type Conventions string + +const ( + ConventionsPosix Conventions = "posix" + ConventionsWindows Conventions = "windows" +) + // The current agent mode. // // The agent mode after switching. @@ -623,11 +740,12 @@ const ( ModePlan Mode = "plan" ) -// Connection status: connected, failed, pending, disabled, or not_configured +// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured type ServerStatus string const ( ServerStatusConnected ServerStatus = "connected" + ServerStatusNeedsAuth ServerStatus = "needs-auth" ServerStatusNotConfigured ServerStatus = "not_configured" ServerStatusPending ServerStatus = "pending" ServerStatusDisabled ServerStatus = "disabled" @@ -721,6 +839,11 @@ const ( SignalSIGTERM Signal = "SIGTERM" ) +type FilterMappingUnion struct { + Enum *FilterMappingEnum + EnumMap map[string]FilterMappingEnum +} + type ResultUnion struct { ResultResult *ResultResult String *string @@ -779,13 +902,31 @@ func (a *ServerAccountApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult return &result, nil } +type ServerMcpApi serverApi + +type ServerSessionFsApi serverApi + +func (a *ServerSessionFsApi) SetProvider(ctx context.Context, params *SessionFSSetProviderParams) (*SessionFSSetProviderResult, error) { + raw, err := a.client.Request("sessionFs.setProvider", params) + if err != nil { + return nil, err + } + var result SessionFSSetProviderResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // ServerRpc provides typed server-scoped RPC methods. type ServerRpc struct { common serverApi // Reuse a single struct instead of allocating one for each service on the heap. - Models *ServerModelsApi - Tools *ServerToolsApi - Account *ServerAccountApi + Models *ServerModelsApi + Tools *ServerToolsApi + Account *ServerAccountApi + Mcp *ServerMcpApi + SessionFs *ServerSessionFsApi } func (a *ServerRpc) Ping(ctx context.Context, params *PingParams) (*PingResult, error) { @@ -806,6 +947,8 @@ func NewServerRpc(client *jsonrpc2.Client) *ServerRpc { r.Models = (*ServerModelsApi)(&r.common) r.Tools = (*ServerToolsApi)(&r.common) r.Account = (*ServerAccountApi)(&r.common) + r.Mcp = (*ServerMcpApi)(&r.common) + r.SessionFs = (*ServerSessionFsApi)(&r.common) return r } diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index f7ea6dbad..39e20a05d 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -454,6 +454,355 @@ def to_dict(self) -> dict: return result +class FilterMappingEnum(Enum): + HIDDEN_CHARACTERS = "hidden_characters" + MARKDOWN = "markdown" + NONE = "none" + + +class ServerType(Enum): + HTTP = "http" + LOCAL = "local" + SSE = "sse" + STDIO = "stdio" + + +@dataclass +class ServerValue: + """MCP server configuration (local/stdio or remote/http)""" + + args: list[str] | None = None + command: str | None = None + cwd: str | None = None + env: dict[str, str] | None = None + filter_mapping: dict[str | FilterMappingEnum] | FilterMappingEnum | None = None + is_default_server: bool | None = None + timeout: float | None = None + tools: list[str] | None = None + """Tools to include. Defaults to all tools if not specified.""" + + type: ServerType | None = None + headers: dict[str, str] | None = None + oauth_client_id: str | None = None + oauth_public_client: bool | None = None + url: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'ServerValue': + assert isinstance(obj, dict) + args = from_union([lambda x: from_list(from_str, x), from_none], obj.get("args")) + command = from_union([from_str, from_none], obj.get("command")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + env = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("env")) + filter_mapping = from_union([lambda x: from_dict(FilterMappingEnum, x), FilterMappingEnum, from_none], obj.get("filterMapping")) + is_default_server = from_union([from_bool, from_none], obj.get("isDefaultServer")) + timeout = from_union([from_float, from_none], obj.get("timeout")) + tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) + type = from_union([ServerType, from_none], obj.get("type")) + headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) + oauth_client_id = from_union([from_str, from_none], obj.get("oauthClientId")) + oauth_public_client = from_union([from_bool, from_none], obj.get("oauthPublicClient")) + url = from_union([from_str, from_none], obj.get("url")) + return ServerValue(args, command, cwd, env, filter_mapping, is_default_server, timeout, tools, type, headers, oauth_client_id, oauth_public_client, url) + + def to_dict(self) -> dict: + result: dict = {} + if self.args is not None: + result["args"] = from_union([lambda x: from_list(from_str, x), from_none], self.args) + if self.command is not None: + result["command"] = from_union([from_str, from_none], self.command) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.env is not None: + result["env"] = from_union([lambda x: from_dict(from_str, x), from_none], self.env) + if self.filter_mapping is not None: + result["filterMapping"] = from_union([lambda x: from_dict(lambda x: to_enum(FilterMappingEnum, x), x), lambda x: to_enum(FilterMappingEnum, x), from_none], self.filter_mapping) + if self.is_default_server is not None: + result["isDefaultServer"] = from_union([from_bool, from_none], self.is_default_server) + if self.timeout is not None: + result["timeout"] = from_union([to_float, from_none], self.timeout) + if self.tools is not None: + result["tools"] = from_union([lambda x: from_list(from_str, x), from_none], self.tools) + if self.type is not None: + result["type"] = from_union([lambda x: to_enum(ServerType, x), from_none], self.type) + if self.headers is not None: + result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) + if self.oauth_client_id is not None: + result["oauthClientId"] = from_union([from_str, from_none], self.oauth_client_id) + if self.oauth_public_client is not None: + result["oauthPublicClient"] = from_union([from_bool, from_none], self.oauth_public_client) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + + +@dataclass +class MCPConfigListResult: + servers: dict[str, ServerValue] + """All MCP servers from user config, keyed by name""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigListResult': + assert isinstance(obj, dict) + servers = from_dict(ServerValue.from_dict, obj.get("servers")) + return MCPConfigListResult(servers) + + def to_dict(self) -> dict: + result: dict = {} + result["servers"] = from_dict(lambda x: to_class(ServerValue, x), self.servers) + return result + + +@dataclass +class MCPConfigAddParamsConfig: + """MCP server configuration (local/stdio or remote/http)""" + + args: list[str] | None = None + command: str | None = None + cwd: str | None = None + env: dict[str, str] | None = None + filter_mapping: dict[str | FilterMappingEnum] | FilterMappingEnum | None = None + is_default_server: bool | None = None + timeout: float | None = None + tools: list[str] | None = None + """Tools to include. Defaults to all tools if not specified.""" + + type: ServerType | None = None + headers: dict[str, str] | None = None + oauth_client_id: str | None = None + oauth_public_client: bool | None = None + url: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigAddParamsConfig': + assert isinstance(obj, dict) + args = from_union([lambda x: from_list(from_str, x), from_none], obj.get("args")) + command = from_union([from_str, from_none], obj.get("command")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + env = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("env")) + filter_mapping = from_union([lambda x: from_dict(FilterMappingEnum, x), FilterMappingEnum, from_none], obj.get("filterMapping")) + is_default_server = from_union([from_bool, from_none], obj.get("isDefaultServer")) + timeout = from_union([from_float, from_none], obj.get("timeout")) + tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) + type = from_union([ServerType, from_none], obj.get("type")) + headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) + oauth_client_id = from_union([from_str, from_none], obj.get("oauthClientId")) + oauth_public_client = from_union([from_bool, from_none], obj.get("oauthPublicClient")) + url = from_union([from_str, from_none], obj.get("url")) + return MCPConfigAddParamsConfig(args, command, cwd, env, filter_mapping, is_default_server, timeout, tools, type, headers, oauth_client_id, oauth_public_client, url) + + def to_dict(self) -> dict: + result: dict = {} + if self.args is not None: + result["args"] = from_union([lambda x: from_list(from_str, x), from_none], self.args) + if self.command is not None: + result["command"] = from_union([from_str, from_none], self.command) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.env is not None: + result["env"] = from_union([lambda x: from_dict(from_str, x), from_none], self.env) + if self.filter_mapping is not None: + result["filterMapping"] = from_union([lambda x: from_dict(lambda x: to_enum(FilterMappingEnum, x), x), lambda x: to_enum(FilterMappingEnum, x), from_none], self.filter_mapping) + if self.is_default_server is not None: + result["isDefaultServer"] = from_union([from_bool, from_none], self.is_default_server) + if self.timeout is not None: + result["timeout"] = from_union([to_float, from_none], self.timeout) + if self.tools is not None: + result["tools"] = from_union([lambda x: from_list(from_str, x), from_none], self.tools) + if self.type is not None: + result["type"] = from_union([lambda x: to_enum(ServerType, x), from_none], self.type) + if self.headers is not None: + result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) + if self.oauth_client_id is not None: + result["oauthClientId"] = from_union([from_str, from_none], self.oauth_client_id) + if self.oauth_public_client is not None: + result["oauthPublicClient"] = from_union([from_bool, from_none], self.oauth_public_client) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + + +@dataclass +class MCPConfigAddParams: + config: MCPConfigAddParamsConfig + """MCP server configuration (local/stdio or remote/http)""" + + name: str + """Unique name for the MCP server""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigAddParams': + assert isinstance(obj, dict) + config = MCPConfigAddParamsConfig.from_dict(obj.get("config")) + name = from_str(obj.get("name")) + return MCPConfigAddParams(config, name) + + def to_dict(self) -> dict: + result: dict = {} + result["config"] = to_class(MCPConfigAddParamsConfig, self.config) + result["name"] = from_str(self.name) + return result + + +@dataclass +class MCPConfigUpdateParamsConfig: + """MCP server configuration (local/stdio or remote/http)""" + + args: list[str] | None = None + command: str | None = None + cwd: str | None = None + env: dict[str, str] | None = None + filter_mapping: dict[str | FilterMappingEnum] | FilterMappingEnum | None = None + is_default_server: bool | None = None + timeout: float | None = None + tools: list[str] | None = None + """Tools to include. Defaults to all tools if not specified.""" + + type: ServerType | None = None + headers: dict[str, str] | None = None + oauth_client_id: str | None = None + oauth_public_client: bool | None = None + url: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigUpdateParamsConfig': + assert isinstance(obj, dict) + args = from_union([lambda x: from_list(from_str, x), from_none], obj.get("args")) + command = from_union([from_str, from_none], obj.get("command")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + env = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("env")) + filter_mapping = from_union([lambda x: from_dict(FilterMappingEnum, x), FilterMappingEnum, from_none], obj.get("filterMapping")) + is_default_server = from_union([from_bool, from_none], obj.get("isDefaultServer")) + timeout = from_union([from_float, from_none], obj.get("timeout")) + tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) + type = from_union([ServerType, from_none], obj.get("type")) + headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) + oauth_client_id = from_union([from_str, from_none], obj.get("oauthClientId")) + oauth_public_client = from_union([from_bool, from_none], obj.get("oauthPublicClient")) + url = from_union([from_str, from_none], obj.get("url")) + return MCPConfigUpdateParamsConfig(args, command, cwd, env, filter_mapping, is_default_server, timeout, tools, type, headers, oauth_client_id, oauth_public_client, url) + + def to_dict(self) -> dict: + result: dict = {} + if self.args is not None: + result["args"] = from_union([lambda x: from_list(from_str, x), from_none], self.args) + if self.command is not None: + result["command"] = from_union([from_str, from_none], self.command) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.env is not None: + result["env"] = from_union([lambda x: from_dict(from_str, x), from_none], self.env) + if self.filter_mapping is not None: + result["filterMapping"] = from_union([lambda x: from_dict(lambda x: to_enum(FilterMappingEnum, x), x), lambda x: to_enum(FilterMappingEnum, x), from_none], self.filter_mapping) + if self.is_default_server is not None: + result["isDefaultServer"] = from_union([from_bool, from_none], self.is_default_server) + if self.timeout is not None: + result["timeout"] = from_union([to_float, from_none], self.timeout) + if self.tools is not None: + result["tools"] = from_union([lambda x: from_list(from_str, x), from_none], self.tools) + if self.type is not None: + result["type"] = from_union([lambda x: to_enum(ServerType, x), from_none], self.type) + if self.headers is not None: + result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) + if self.oauth_client_id is not None: + result["oauthClientId"] = from_union([from_str, from_none], self.oauth_client_id) + if self.oauth_public_client is not None: + result["oauthPublicClient"] = from_union([from_bool, from_none], self.oauth_public_client) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + + +@dataclass +class MCPConfigUpdateParams: + config: MCPConfigUpdateParamsConfig + """MCP server configuration (local/stdio or remote/http)""" + + name: str + """Name of the MCP server to update""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigUpdateParams': + assert isinstance(obj, dict) + config = MCPConfigUpdateParamsConfig.from_dict(obj.get("config")) + name = from_str(obj.get("name")) + return MCPConfigUpdateParams(config, name) + + def to_dict(self) -> dict: + result: dict = {} + result["config"] = to_class(MCPConfigUpdateParamsConfig, self.config) + result["name"] = from_str(self.name) + return result + + +@dataclass +class MCPConfigRemoveParams: + name: str + """Name of the MCP server to remove""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigRemoveParams': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + return MCPConfigRemoveParams(name) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionFSSetProviderResult: + success: bool + """Whether the provider was set successfully""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionFSSetProviderResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SessionFSSetProviderResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + + +class Conventions(Enum): + """Path conventions used by this filesystem""" + + POSIX = "posix" + WINDOWS = "windows" + + +@dataclass +class SessionFSSetProviderParams: + conventions: Conventions + """Path conventions used by this filesystem""" + + initial_cwd: str + """Initial working directory for sessions""" + + session_state_path: str + """Path within each session's SessionFs where the runtime stores files for that session""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionFSSetProviderParams': + assert isinstance(obj, dict) + conventions = Conventions(obj.get("conventions")) + initial_cwd = from_str(obj.get("initialCwd")) + session_state_path = from_str(obj.get("sessionStatePath")) + return SessionFSSetProviderParams(conventions, initial_cwd, session_state_path) + + def to_dict(self) -> dict: + result: dict = {} + result["conventions"] = to_enum(Conventions, self.conventions) + result["initialCwd"] = from_str(self.initial_cwd) + result["sessionStatePath"] = from_str(self.session_state_path) + return result + + @dataclass class SessionModelGetCurrentResult: model_id: str | None = None @@ -1116,22 +1465,23 @@ def to_dict(self) -> dict: class ServerStatus(Enum): - """Connection status: connected, failed, pending, disabled, or not_configured""" + """Connection status: connected, failed, needs-auth, pending, disabled, or not_configured""" CONNECTED = "connected" DISABLED = "disabled" FAILED = "failed" + NEEDS_AUTH = "needs-auth" NOT_CONFIGURED = "not_configured" PENDING = "pending" @dataclass -class Server: +class ServerElement: name: str """Server name (config key)""" status: ServerStatus - """Connection status: connected, failed, pending, disabled, or not_configured""" + """Connection status: connected, failed, needs-auth, pending, disabled, or not_configured""" error: str | None = None """Error message if the server failed to connect""" @@ -1140,13 +1490,13 @@ class Server: """Configuration source: user, workspace, plugin, or builtin""" @staticmethod - def from_dict(obj: Any) -> 'Server': + def from_dict(obj: Any) -> 'ServerElement': assert isinstance(obj, dict) name = from_str(obj.get("name")) status = ServerStatus(obj.get("status")) error = from_union([from_str, from_none], obj.get("error")) source = from_union([from_str, from_none], obj.get("source")) - return Server(name, status, error, source) + return ServerElement(name, status, error, source) def to_dict(self) -> dict: result: dict = {} @@ -1161,18 +1511,18 @@ def to_dict(self) -> dict: @dataclass class SessionMCPListResult: - servers: list[Server] + servers: list[ServerElement] """Configured MCP servers""" @staticmethod def from_dict(obj: Any) -> 'SessionMCPListResult': assert isinstance(obj, dict) - servers = from_list(Server.from_dict, obj.get("servers")) + servers = from_list(ServerElement.from_dict, obj.get("servers")) return SessionMCPListResult(servers) def to_dict(self) -> dict: result: dict = {} - result["servers"] = from_list(lambda x: to_class(Server, x), self.servers) + result["servers"] = from_list(lambda x: to_class(ServerElement, x), self.servers) return result @@ -2167,6 +2517,54 @@ def account_get_quota_result_to_dict(x: AccountGetQuotaResult) -> Any: return to_class(AccountGetQuotaResult, x) +def mcp_config_list_result_from_dict(s: Any) -> MCPConfigListResult: + return MCPConfigListResult.from_dict(s) + + +def mcp_config_list_result_to_dict(x: MCPConfigListResult) -> Any: + return to_class(MCPConfigListResult, x) + + +def mcp_config_add_params_from_dict(s: Any) -> MCPConfigAddParams: + return MCPConfigAddParams.from_dict(s) + + +def mcp_config_add_params_to_dict(x: MCPConfigAddParams) -> Any: + return to_class(MCPConfigAddParams, x) + + +def mcp_config_update_params_from_dict(s: Any) -> MCPConfigUpdateParams: + return MCPConfigUpdateParams.from_dict(s) + + +def mcp_config_update_params_to_dict(x: MCPConfigUpdateParams) -> Any: + return to_class(MCPConfigUpdateParams, x) + + +def mcp_config_remove_params_from_dict(s: Any) -> MCPConfigRemoveParams: + return MCPConfigRemoveParams.from_dict(s) + + +def mcp_config_remove_params_to_dict(x: MCPConfigRemoveParams) -> Any: + return to_class(MCPConfigRemoveParams, x) + + +def session_fs_set_provider_result_from_dict(s: Any) -> SessionFSSetProviderResult: + return SessionFSSetProviderResult.from_dict(s) + + +def session_fs_set_provider_result_to_dict(x: SessionFSSetProviderResult) -> Any: + return to_class(SessionFSSetProviderResult, x) + + +def session_fs_set_provider_params_from_dict(s: Any) -> SessionFSSetProviderParams: + return SessionFSSetProviderParams.from_dict(s) + + +def session_fs_set_provider_params_to_dict(x: SessionFSSetProviderParams) -> Any: + return to_class(SessionFSSetProviderParams, x) + + def session_model_get_current_result_from_dict(s: Any) -> SessionModelGetCurrentResult: return SessionModelGetCurrentResult.from_dict(s) @@ -2671,6 +3069,20 @@ async def get_quota(self, *, timeout: float | None = None) -> AccountGetQuotaRes return AccountGetQuotaResult.from_dict(await self._client.request("account.getQuota", {}, **_timeout_kwargs(timeout))) +class ServerMcpApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + +class ServerSessionFsApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + async def set_provider(self, params: SessionFSSetProviderParams, *, timeout: float | None = None) -> SessionFSSetProviderResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + return SessionFSSetProviderResult.from_dict(await self._client.request("sessionFs.setProvider", params_dict, **_timeout_kwargs(timeout))) + + class ServerRpc: """Typed server-scoped RPC methods.""" def __init__(self, client: "JsonRpcClient"): @@ -2678,6 +3090,8 @@ def __init__(self, client: "JsonRpcClient"): self.models = ServerModelsApi(client) self.tools = ServerToolsApi(client) self.account = ServerAccountApi(client) + self.mcp = ServerMcpApi(client) + self.session_fs = ServerSessionFsApi(client) async def ping(self, params: PingParams, *, timeout: float | None = None) -> PingResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index a48ed47b6..304324421 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -806,7 +806,16 @@ function emitServerInstanceMethod( for (const [pName, pSchema] of paramEntries) { if (typeof pSchema !== "object") continue; const isReq = requiredSet.has(pName); - const csType = schemaTypeToCSharp(pSchema as JSONSchema7, isReq, rpcKnownTypes); + const jsonSchema = pSchema as JSONSchema7; + let csType: string; + // If the property has an enum, resolve to the generated enum type + if (jsonSchema.enum && Array.isArray(jsonSchema.enum) && requestClassName) { + const valuesKey = [...jsonSchema.enum].sort().join("|"); + const match = [...generatedEnums.values()].find((e) => [...e.values].sort().join("|") === valuesKey); + csType = match ? (isReq ? match.enumName : `${match.enumName}?`) : schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); + } else { + csType = schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); + } sigParams.push(`${csType} ${pName}${isReq ? "" : " = null"}`); bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); } From 45b44c7cc4552308dea6e25e9cc89978ca0d1298 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 1 Apr 2026 13:57:11 +0100 Subject: [PATCH 15/18] Update to latest runtime --- dotnet/src/Generated/SessionEvents.cs | 14 +- go/generated_session_events.go | 11 +- nodejs/src/generated/session-events.ts | 147 ++++++++++++++++++++- python/copilot/generated/session_events.py | 17 ++- 4 files changed, 172 insertions(+), 17 deletions(-) diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 6da3de682..c01d1ddcd 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -1216,6 +1216,11 @@ public partial class SessionIdleData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("backgroundTasks")] public SessionIdleDataBackgroundTasks? BackgroundTasks { get; set; } + + /// True when the preceding agentic loop was cancelled via abort signal. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("aborted")] + public bool? Aborted { get; set; } } /// Session title change payload containing the new display title. @@ -2593,7 +2598,7 @@ public partial class SessionMcpServerStatusChangedData [JsonPropertyName("serverName")] public required string ServerName { get; set; } - /// New connection status: connected, failed, pending, disabled, or not_configured. + /// New connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonPropertyName("status")] public required SessionMcpServersLoadedDataServersItemStatus Status { get; set; } } @@ -3786,7 +3791,7 @@ public partial class SessionMcpServersLoadedDataServersItem [JsonPropertyName("name")] public required string Name { get; set; } - /// Connection status: connected, failed, pending, disabled, or not_configured. + /// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonPropertyName("status")] public required SessionMcpServersLoadedDataServersItemStatus Status { get; set; } @@ -3998,7 +4003,7 @@ public enum ElicitationRequestedDataMode Url, } -/// Connection status: connected, failed, pending, disabled, or not_configured. +/// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionMcpServersLoadedDataServersItemStatus { @@ -4008,6 +4013,9 @@ public enum SessionMcpServersLoadedDataServersItemStatus /// The failed variant. [JsonStringEnumMemberName("failed")] Failed, + /// The needs-auth variant. + [JsonStringEnumMemberName("needs-auth")] + NeedsAuth, /// The pending variant. [JsonStringEnumMemberName("pending")] Pending, diff --git a/go/generated_session_events.go b/go/generated_session_events.go index 8eafb13d0..4799aca91 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -385,6 +385,8 @@ type Data struct { // // URL to open in the user's browser (url mode only) URL *string `json:"url,omitempty"` + // True when the preceding agentic loop was cancelled via abort signal + Aborted *bool `json:"aborted,omitempty"` // Background tasks still running when the agent became idle BackgroundTasks *BackgroundTasks `json:"backgroundTasks,omitempty"` // The new display title for the session @@ -856,7 +858,7 @@ type Data struct { Warnings []string `json:"warnings,omitempty"` // Array of MCP server status summaries Servers []Server `json:"servers,omitempty"` - // New connection status: connected, failed, pending, disabled, or not_configured + // New connection status: connected, failed, needs-auth, pending, disabled, or not_configured Status *ServerStatus `json:"status,omitempty"` // Array of discovered extensions and their status Extensions []Extension `json:"extensions,omitempty"` @@ -1368,7 +1370,7 @@ type Server struct { Name string `json:"name"` // Configuration source: user, workspace, plugin, or builtin Source *string `json:"source,omitempty"` - // Connection status: connected, failed, pending, disabled, or not_configured + // Connection status: connected, failed, needs-auth, pending, disabled, or not_configured Status ServerStatus `json:"status"` } @@ -1567,14 +1569,15 @@ const ( RoleSystem Role = "system" ) -// Connection status: connected, failed, pending, disabled, or not_configured +// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured // -// New connection status: connected, failed, pending, disabled, or not_configured +// New connection status: connected, failed, needs-auth, pending, disabled, or not_configured type ServerStatus string const ( ServerStatusConnected ServerStatus = "connected" ServerStatusDisabled ServerStatus = "disabled" + ServerStatusNeedsAuth ServerStatus = "needs-auth" ServerStatusNotConfigured ServerStatus = "not_configured" ServerStatusPending ServerStatus = "pending" ServerStatusFailed ServerStatus = "failed" diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 91dc023e9..137c474f2 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 + */ + remoteSteerable?: boolean; }; } | { @@ -168,6 +172,38 @@ export type SessionEvent = * Whether the session was already in use by another client at resume time */ alreadyInUse?: boolean; + /** + * Whether this session supports remote steering via Mission Control + */ + remoteSteerable?: boolean; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + type: "session.remote_steerable_changed"; + /** + * Notifies Mission Control that the session's remote steering capability has changed + */ + data: { + /** + * Whether this session now supports remote steering via Mission Control + */ + remoteSteerable: boolean; }; } | { @@ -272,6 +308,10 @@ export type SessionEvent = description?: string; }[]; }; + /** + * True when the preceding agentic loop was cancelled via abort signal + */ + aborted?: boolean; }; } | { @@ -1584,7 +1624,15 @@ export type SessionEvent = */ duration?: number; /** - * What initiated this API call (e.g., "sub-agent"); absent for user-initiated calls + * Time to first token in milliseconds. Only available for streaming requests + */ + ttftMs?: number; + /** + * Average inter-token latency in milliseconds. Only available for streaming requests + */ + interTokenLatencyMs?: number; + /** + * What initiated this API call (e.g., "sub-agent", "mcp-sampling"); absent for user-initiated calls */ initiator?: string; /** @@ -3021,6 +3069,65 @@ export type SessionEvent = requestId: string; }; } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "sampling.requested"; + /** + * Sampling request from an MCP server; contains the server name and a requestId for correlation + */ + data: { + /** + * Unique identifier for this sampling request; used to respond via session.respondToSampling() + */ + requestId: string; + /** + * Name of the MCP server that initiated the sampling request + */ + serverName: string; + /** + * The JSON-RPC request ID from the MCP protocol + */ + mcpRequestId: string | number; + [k: string]: unknown; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "sampling.completed"; + /** + * Sampling request completion notification signaling UI dismissal + */ + data: { + /** + * Request ID of the resolved sampling request; clients should dismiss any UI for this request + */ + requestId: string; + }; + } | { /** * Unique event identifier (UUID v4), generated when the event is emitted @@ -3287,6 +3394,36 @@ export type SessionEvent = }[]; }; } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "capabilities.changed"; + /** + * Session capability change notification + */ + data: { + /** + * UI capability changes + */ + ui?: { + /** + * Whether elicitation is now supported + */ + elicitation?: boolean; + }; + }; + } | { /** * Unique event identifier (UUID v4), generated when the event is emitted @@ -3524,9 +3661,9 @@ export type SessionEvent = */ name: string; /** - * Connection status: connected, failed, pending, disabled, or not_configured + * Connection status: connected, failed, needs-auth, pending, disabled, or not_configured */ - status: "connected" | "failed" | "pending" | "disabled" | "not_configured"; + status: "connected" | "failed" | "needs-auth" | "pending" | "disabled" | "not_configured"; /** * Configuration source: user, workspace, plugin, or builtin */ @@ -3559,9 +3696,9 @@ export type SessionEvent = */ serverName: string; /** - * New connection status: connected, failed, pending, disabled, or not_configured + * New connection status: connected, failed, needs-auth, pending, disabled, or not_configured */ - status: "connected" | "failed" | "pending" | "disabled" | "not_configured"; + status: "connected" | "failed" | "needs-auth" | "pending" | "disabled" | "not_configured"; }; } | { diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index c3123102b..2c3acba81 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -1556,13 +1556,14 @@ class Role(Enum): class ServerStatus(Enum): - """Connection status: connected, failed, pending, disabled, or not_configured + """Connection status: connected, failed, needs-auth, pending, disabled, or not_configured - New connection status: connected, failed, pending, disabled, or not_configured + New connection status: connected, failed, needs-auth, pending, disabled, or not_configured """ CONNECTED = "connected" DISABLED = "disabled" FAILED = "failed" + NEEDS_AUTH = "needs-auth" NOT_CONFIGURED = "not_configured" PENDING = "pending" @@ -1573,7 +1574,7 @@ class Server: """Server name (config key)""" status: ServerStatus - """Connection status: connected, failed, pending, disabled, or not_configured""" + """Connection status: connected, failed, needs-auth, pending, disabled, or not_configured""" error: str | None = None """Error message if the server failed to connect""" @@ -1988,6 +1989,9 @@ class Data: URL to open in the user's browser (url mode only) """ + aborted: bool | None = None + """True when the preceding agentic loop was cancelled via abort signal""" + background_tasks: BackgroundTasks | None = None """Background tasks still running when the agent became idle""" @@ -2606,7 +2610,7 @@ class Data: """Array of MCP server status summaries""" status: ServerStatus | None = None - """New connection status: connected, failed, pending, disabled, or not_configured""" + """New connection status: connected, failed, needs-auth, pending, disabled, or not_configured""" extensions: list[Extension] | None = None """Array of discovered extensions and their status""" @@ -2632,6 +2636,7 @@ def from_dict(obj: Any) -> 'Data': stack = from_union([from_str, from_none], obj.get("stack")) status_code = from_union([from_int, from_none], obj.get("statusCode")) url = from_union([from_str, from_none], obj.get("url")) + aborted = from_union([from_bool, from_none], obj.get("aborted")) background_tasks = from_union([BackgroundTasks.from_dict, from_none], obj.get("backgroundTasks")) title = from_union([from_str, from_none], obj.get("title")) info_type = from_union([from_str, from_none], obj.get("infoType")) @@ -2780,7 +2785,7 @@ def from_dict(obj: Any) -> 'Data': servers = from_union([lambda x: from_list(Server.from_dict, x), from_none], obj.get("servers")) status = from_union([ServerStatus, from_none], obj.get("status")) extensions = from_union([lambda x: from_list(Extension.from_dict, x), from_none], obj.get("extensions")) - return Data(already_in_use, context, copilot_version, producer, reasoning_effort, remote_steerable, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, url, background_tasks, title, info_type, warning_type, new_model, previous_model, previous_reasoning_effort, new_mode, previous_mode, operation, path, handoff_time, host, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, conversation_tokens, current_model, current_tokens, error_reason, model_metrics, session_start_time, shutdown_type, system_tokens, tool_definitions_tokens, total_api_duration_ms, total_premium_requests, base_commit, branch, cwd, git_root, head_commit, host_type, is_initial, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, inter_token_latency_ms, model, quota_snapshots, ttft_ms, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, description, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, duration_ms, total_tokens, total_tool_calls, tools, hook_invocation_id, hook_type, input, output, metadata, role, kind, permission_request, allow_freeform, choices, question, elicitation_source, mode, requested_schema, mcp_request_id, server_name, server_url, static_client_config, traceparent, tracestate, command, args, command_name, commands, ui, actions, plan_content, recommended_action, skills, agents, errors, warnings, servers, status, extensions) + return Data(already_in_use, context, copilot_version, producer, reasoning_effort, remote_steerable, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, url, aborted, background_tasks, title, info_type, warning_type, new_model, previous_model, previous_reasoning_effort, new_mode, previous_mode, operation, path, handoff_time, host, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, conversation_tokens, current_model, current_tokens, error_reason, model_metrics, session_start_time, shutdown_type, system_tokens, tool_definitions_tokens, total_api_duration_ms, total_premium_requests, base_commit, branch, cwd, git_root, head_commit, host_type, is_initial, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, inter_token_latency_ms, model, quota_snapshots, ttft_ms, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, description, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, duration_ms, total_tokens, total_tool_calls, tools, hook_invocation_id, hook_type, input, output, metadata, role, kind, permission_request, allow_freeform, choices, question, elicitation_source, mode, requested_schema, mcp_request_id, server_name, server_url, static_client_config, traceparent, tracestate, command, args, command_name, commands, ui, actions, plan_content, recommended_action, skills, agents, errors, warnings, servers, status, extensions) def to_dict(self) -> dict: result: dict = {} @@ -2820,6 +2825,8 @@ def to_dict(self) -> dict: result["statusCode"] = from_union([from_int, from_none], self.status_code) if self.url is not None: result["url"] = from_union([from_str, from_none], self.url) + if self.aborted is not None: + result["aborted"] = from_union([from_bool, from_none], self.aborted) if self.background_tasks is not None: result["backgroundTasks"] = from_union([lambda x: to_class(BackgroundTasks, x), from_none], self.background_tasks) if self.title is not None: From b136d99f742305af3cd9ab2cf948c02bc05ce73b Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 1 Apr 2026 14:07:00 +0100 Subject: [PATCH 16/18] fix: bump @github/copilot to 1.0.15-1, remove spurious root package-lock --- nodejs/package-lock.json | 56 ++++++++++++++++++++-------------------- nodejs/package.json | 2 +- package-lock.json | 6 ----- 3 files changed, 29 insertions(+), 35 deletions(-) delete mode 100644 package-lock.json diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 849047134..1f472943d 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.14-0", + "@github/copilot": "^1.0.15-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -663,26 +663,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.14-0.tgz", - "integrity": "sha512-9eA5sFbvx69OtQnVoeik/8boFqHgGAhylLeUjEACc3kB70aaH1E/cHgxNzSMyYgZDjpXov0/IBXjtx2otpfHBw==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.15-1.tgz", + "integrity": "sha512-H5I7CXJpOj+nUD1+0VQzawhV86X9Nb2m4fU0h70KDk+LDWRGhWvOlhK/bfFTVj6TPQbjBaOU4n2QJ+zKv48fGw==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.14-0", - "@github/copilot-darwin-x64": "1.0.14-0", - "@github/copilot-linux-arm64": "1.0.14-0", - "@github/copilot-linux-x64": "1.0.14-0", - "@github/copilot-win32-arm64": "1.0.14-0", - "@github/copilot-win32-x64": "1.0.14-0" + "@github/copilot-darwin-arm64": "1.0.15-1", + "@github/copilot-darwin-x64": "1.0.15-1", + "@github/copilot-linux-arm64": "1.0.15-1", + "@github/copilot-linux-x64": "1.0.15-1", + "@github/copilot-win32-arm64": "1.0.15-1", + "@github/copilot-win32-x64": "1.0.15-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.14-0.tgz", - "integrity": "sha512-w11Eqmfnu0ihrvgLysTd5Tkq8LuQa9eW63CNTQ/k5copnG1AMCdvd3K/78MxE2DdFJPq2L95KGS5cs9jH1dlIw==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.15-1.tgz", + "integrity": "sha512-xo3yBGtzEliSnKZ+5RLBS94PxXpDkeNEf/dqi9/EtMjWTA8Zr6Zc318XDMG+7R/PwwiGdDNHa2+41/ffQ5ek4A==", "cpu": [ "arm64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.14-0.tgz", - "integrity": "sha512-4X/dMSPxCE/rvL6N1tgnwFxBg2uXnPrN63GGgS/FqK/fNi3TtcuojDVv8K1yjmEYpF8PXdkQttDlp6bKc+Nonw==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.15-1.tgz", + "integrity": "sha512-gJ4uVuETqHSdvz+XD65F7MJqojU8Nthoi4+10549jPNhn29rAk6huZSJHg7DzK9K/bSlKEXKDziOE+p799EF8g==", "cpu": [ "x64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.14-0.tgz", - "integrity": "sha512-A4thcLUoErEvfBO3Hsl/hJASibn44qwZm1ZSeVBPCa1FkpowBwo8fT1eV9EwN/ftKsyks3QkndNFvHkVzjUfxA==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.15-1.tgz", + "integrity": "sha512-j0a+rAopJxV1NaA4VJElHMsA7x7ICD3+vkhb/1tOW1mfRQSg9OMegajidA0UvnMBAgQrOODUm8CAXc2ko1QMNw==", "cpu": [ "arm64" ], @@ -728,9 +728,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.14-0.tgz", - "integrity": "sha512-Kwn+Qn8/BqWRKa2DewZipH7rPIO8nDRWzpVy/ZLcRWBAvnIU+6BLWfhnYEU44DsqkD2VeWhKVfQlNmDX23xKKg==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.15-1.tgz", + "integrity": "sha512-K0UAkXKHlKU2wPgafO6mNl6xF5EoJ8xRBbXgJZOQZZtuJVHxGrVmmQWMdvz7bixrL+F1eB35jMYexupXS3C4Vw==", "cpu": [ "x64" ], @@ -744,9 +744,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.14-0.tgz", - "integrity": "sha512-8P5kxcb8YVWSS+Ihs+ykyy8jov1WwQ8GKV4d7mJN268Jpd8y5VI8Peb7uE2VO0lRLgq5c2VcXuZDsLG/1Wgnlw==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.15-1.tgz", + "integrity": "sha512-BKMqmsZ/EKSJZZ3M2HHcVLOxFvqcwO4ZtpEQPsXqPpbjyRRZCfbVr0fwb9ltZmiNP8rKMtEAO8yxYStiYHXjgw==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.14-0.tgz", - "integrity": "sha512-JWxp08j5o/PUkRZtZVagNYJLjH+KCURCyZRb7BfnC0A3vLeqcJQ70JC5qlYEAlcRnb4uCUJnmnpbWLLOJ+ObrA==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.15-1.tgz", + "integrity": "sha512-qdOefGZzDq9V9BxRDCx45FtWBy2epmPYtAG4icGzjqJQnl5+D//SjMbfpcYPYopBgAywgH7tEVxvWcvJINA23w==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 8979a579e..4b92ad8ac 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.14-0", + "@github/copilot": "^1.0.15-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index c3f458113..000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "azure-otter", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From a938eced55bb8284de17a8274da7885b7520af46 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 1 Apr 2026 14:09:10 +0100 Subject: [PATCH 17/18] fix: remove hardcoded COPILOT_CLI_PATH from test --- nodejs/test/e2e/session_fs.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts index cda538569..2f67f2ca0 100644 --- a/nodejs/test/e2e/session_fs.test.ts +++ b/nodejs/test/e2e/session_fs.test.ts @@ -16,9 +16,6 @@ import { } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -process.env.COPILOT_CLI_PATH = - "c:\\Users\\stevesa\\.copilot\\worktrees\\copilot-agent-runtime\\amber-aura\\dist-cli\\index.js"; - describe("Session Fs", async () => { // Single provider for the describe block — session IDs are unique per test, // so no cross-contamination between tests. From 985543a7cfb48fe841dd7e70109afc0bc0f65c8e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 1 Apr 2026 14:26:12 +0100 Subject: [PATCH 18/18] skip postToolUse hook tests broken by runtime (issue #972) --- dotnet/test/HooksTests.cs | 6 ++++-- go/internal/e2e/hooks_test.go | 4 ++++ nodejs/test/e2e/hooks.test.ts | 6 ++++-- python/e2e/test_hooks.py | 4 ++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/dotnet/test/HooksTests.cs b/dotnet/test/HooksTests.cs index a37ef3c15..21479a376 100644 --- a/dotnet/test/HooksTests.cs +++ b/dotnet/test/HooksTests.cs @@ -46,7 +46,8 @@ await session.SendAsync(new MessageOptions Assert.Contains(preToolUseInputs, i => !string.IsNullOrEmpty(i.ToolName)); } - [Fact] + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + [Fact(Skip = "Runtime postToolUse hooks broken")] public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() { var postToolUseInputs = new List(); @@ -83,7 +84,8 @@ await session.SendAsync(new MessageOptions Assert.Contains(postToolUseInputs, i => i.ToolResult != null); } - [Fact] + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + [Fact(Skip = "Runtime postToolUse hooks broken")] public async Task Should_Invoke_Both_PreToolUse_And_PostToolUse_Hooks_For_Single_Tool_Call() { var preToolUseInputs = new List(); diff --git a/go/internal/e2e/hooks_test.go b/go/internal/e2e/hooks_test.go index 70aa6ec71..2b8a63921 100644 --- a/go/internal/e2e/hooks_test.go +++ b/go/internal/e2e/hooks_test.go @@ -74,7 +74,9 @@ func TestHooks(t *testing.T) { } }) + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) t.Run("should invoke postToolUse hook after model runs a tool", func(t *testing.T) { + t.Skip("Runtime postToolUse hooks broken") ctx.ConfigureForTest(t) var postToolUseInputs []copilot.PostToolUseHookInput @@ -139,7 +141,9 @@ func TestHooks(t *testing.T) { } }) + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) t.Run("should invoke both preToolUse and postToolUse hooks for a single tool call", func(t *testing.T) { + t.Skip("Runtime postToolUse hooks broken") ctx.ConfigureForTest(t) var preToolUseInputs []copilot.PreToolUseHookInput diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts index 9743d91f3..c510d7154 100644 --- a/nodejs/test/e2e/hooks.test.ts +++ b/nodejs/test/e2e/hooks.test.ts @@ -48,7 +48,8 @@ describe("Session hooks", async () => { await session.disconnect(); }); - it("should invoke postToolUse hook after model runs a tool", async () => { + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + it.skip("should invoke postToolUse hook after model runs a tool", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ @@ -79,7 +80,8 @@ describe("Session hooks", async () => { await session.disconnect(); }); - it("should invoke both preToolUse and postToolUse hooks for a single tool call", async () => { + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + it.skip("should invoke both preToolUse and postToolUse hooks for a single tool call", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const postToolUseInputs: PostToolUseHookInput[] = []; diff --git a/python/e2e/test_hooks.py b/python/e2e/test_hooks.py index e355f3a80..2ecdc6b07 100644 --- a/python/e2e/test_hooks.py +++ b/python/e2e/test_hooks.py @@ -41,6 +41,8 @@ async def on_pre_tool_use(input_data, invocation): await session.disconnect() + # TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + @pytest.mark.skip(reason="Runtime postToolUse hooks broken") async def test_should_invoke_posttooluse_hook_after_model_runs_a_tool( self, ctx: E2ETestContext ): @@ -71,6 +73,8 @@ async def on_post_tool_use(input_data, invocation): await session.disconnect() + # TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + @pytest.mark.skip(reason="Runtime postToolUse hooks broken") async def test_should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call( self, ctx: E2ETestContext ):