diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 42d9b6fb6..978183049 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -14,6 +14,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@platformatic/vfs": "^0.3.0", "@types/node": "^25.2.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", @@ -847,6 +848,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@platformatic/vfs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@platformatic/vfs/-/vfs-0.3.0.tgz", + "integrity": "sha512-BGXVOAz59HYPZCgI9v/MtiTF/ng8YAWtkooxVwOPR3TatNgGy0WZ/t15ScqytiZi5NdSRqWNRfuAbXKeAlKDdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 4ccda703d..241dd24db 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -61,6 +61,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@platformatic/vfs": "^0.3.0", "@types/node": "^25.2.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index f18b70f42..06dc60540 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, + SessionFsConfig, TelemetryConfig, Tool, ToolCallRequestPayload, @@ -216,6 +217,7 @@ export class CopilotClient { | "onListModels" | "telemetry" | "onGetTraceContext" + | "sessionFs" > > & { 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 filesystem config, set via constructor option. */ + private sessionFsConfig: SessionFsConfig | null = null; /** * Typed server-scoped RPC methods. @@ -307,6 +311,7 @@ export class CopilotClient { this.onListModels = options.onListModels; this.onGetTraceContext = options.onGetTraceContext; + this.sessionFsConfig = options.sessionFs ?? null; const effectiveEnv = options.env ?? process.env; this.options = { @@ -399,6 +404,15 @@ export class CopilotClient { // Verify protocol version compatibility await this.verifyProtocolVersion(); + // 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, + }); + } + this.state = "connected"; } catch (error) { this.state = "error"; @@ -1069,7 +1083,9 @@ export class CopilotClient { throw new Error("Client not connected"); } - const response = await this.connection.sendRequest("session.list", { filter }); + const response = await this.connection.sendRequest("session.list", { + filter, + }); const { sessions } = response as { sessions: Array<{ sessionId: string; @@ -1562,6 +1578,13 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); + // Register session filesystem RPC handlers if configured. + if (this.sessionFsConfig) { + registerClientApiHandlers(this.connection, { + sessionFs: this.sessionFsConfig, + }); + } + this.connection.onClose(() => { this.state = "disconnected"; }); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index dadb9e79d..5255ed011 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -179,6 +179,28 @@ export interface AccountGetQuotaResult { }; } +export interface SessionFsSetProviderResult { + /** + * Whether the provider was set successfully + */ + success: boolean; +} + +export interface SessionFsSetProviderParams { + /** + * Initial working directory for sessions + */ + 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 { /** * Currently active model identifier @@ -1050,6 +1072,169 @@ export interface SessionShellKillParams { signal?: "SIGTERM" | "SIGKILL" | "SIGINT"; } +export interface SessionFsReadFileResult { + /** + * File content as UTF-8 string + */ + content: string; +} + +export interface SessionFsReadFileParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; +} + +export interface SessionFsWriteFileParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; + /** + * Content to write + */ + content: string; + /** + * Optional POSIX-style mode for newly created files + */ + mode?: number; +} + +export interface SessionFsAppendFileParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; + /** + * Content to append + */ + content: string; + /** + * Optional POSIX-style mode for newly created files + */ + mode?: number; +} + +export interface SessionFsExistsResult { + exists: boolean; +} + +export interface SessionFsExistsParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: 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 SessionFsStatParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; +} + +export interface SessionFsMkdirParams { + /** + * Target session identifier + */ + sessionId: string; + path: string; + recursive?: boolean; + /** + * Optional POSIX-style mode for newly created directories + */ + mode?: number; +} + +export interface SessionFsReaddirResult { + /** + * Entry names in the directory + */ + entries: string[]; +} + +export interface SessionFsReaddirParams { + /** + * Target session identifier + */ + sessionId: string; + 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 + */ + 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). */ export function createServerRpc(connection: MessageConnection) { return { @@ -1067,6 +1252,10 @@ export function createServerRpc(connection: MessageConnection) { getQuota: async (): Promise => connection.sendRequest("account.getQuota", {}), }, + sessionFs: { + setProvider: async (params: SessionFsSetProviderParams): Promise => + connection.sendRequest("sessionFs.setProvider", params), + }, }; } @@ -1188,3 +1377,50 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin }, }; } + +/** + * Handler interface for the `sessionFs` client API group. + * Implement this to provide a custom sessionFs backend. + */ +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; + readdirWithTypes(params: SessionFsReaddirWithTypesParams): Promise; + rm(params: SessionFsRmParams): Promise; + rename(params: SessionFsRenameParams): Promise; +} + +/** All client API handler groups. Each group is optional. */ +export interface ClientApiHandlers { + sessionFs?: SessionFsHandler; +} + +/** + * 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.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)); + } +} diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 91dc023e9..8a6bec680 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -91,6 +91,10 @@ export type SessionEvent = * Whether the session was already in use by another client at start time */ alreadyInUse?: boolean; + /** + * Whether this session supports remote steering via Mission Control + */ + steerable?: boolean; }; } | { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index c42935a26..f9c38045e 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -56,6 +56,9 @@ export type { SessionListFilter, SessionMetadata, SessionUiApi, + SessionFsConfig, + SessionFsHandler, + ClientApiHandlers, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageCustomizeConfig, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 96694137d..e92f7f87e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -10,6 +10,30 @@ 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"; + /** * Options for creating a CopilotClient */ @@ -171,6 +195,14 @@ export interface CopilotClientOptions { * ``` */ onGetTraceContext?: TraceContextProvider; + + /** + * 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. + */ + sessionFs?: SessionFsConfig; } /** @@ -1318,6 +1350,30 @@ export interface SessionContext { branch?: string; } +/** + * 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 { + /** + * Initial working directory for sessions (user's project directory). + */ + 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"; +} + /** * Filter options for listing sessions */ 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 new file mode 100644 index 000000000..de80ce123 --- /dev/null +++ b/nodejs/test/e2e/session_fs.test.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { MemoryProvider } 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 { createSdkTestContext } from "./harness/sdkTestContext.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); + + // 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, + }, + }); + + it("should route file operations through the session fs provider", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const msg = await session.sendAndWait({ prompt: "What is 100 + 200?" }); + expect(msg?.data.content).toContain("300"); + await session.disconnect(); + + 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 () => { + const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const sessionId = session1.sessionId; + + const msg = await session1.sendAndWait({ prompt: "What is 50 + 50?" }); + expect(msg?.data.content).toContain("100"); + await session1.disconnect(); + + // 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, + }); + + // 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"); + }); + + it("should reject setProvider when sessions already exist", async () => { + const client = new CopilotClient({ + useStdio: false, // Use TCP so we can connect from a second client + env, + }); + await client.createSession({ onPermissionRequest: approveAll }); + + // 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, + }); + 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/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/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_load_session_data_from_fs_provider_on_resume.yaml b/test/snapshots/session_fs/should_load_session_data_from_fs_provider_on_resume.yaml new file mode 100644 index 000000000..4744667cd --- /dev/null +++ b/test/snapshots/session_fs/should_load_session_data_from_fs_provider_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_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 diff --git a/test/snapshots/session_fs/should_reject_setprovider_when_sessions_already_exist.yaml b/test/snapshots/session_fs/should_reject_setprovider_when_sessions_already_exist.yaml new file mode 100644 index 000000000..269a80f11 --- /dev/null +++ b/test/snapshots/session_fs/should_reject_setprovider_when_sessions_already_exist.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Hello + - role: assistant + content: Hello! How can I help you today? diff --git a/test/snapshots/session_fs/should_route_file_operations_through_the_session_fs_provider.yaml b/test/snapshots/session_fs/should_route_file_operations_through_the_session_fs_provider.yaml new file mode 100644 index 000000000..455652bfd --- /dev/null +++ b/test/snapshots/session_fs/should_route_file_operations_through_the_session_fs_provider.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_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"