From f4689eac0bd49146de2ceab816370ba69b0a8f6e Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 04:43:33 +0000 Subject: [PATCH 01/10] Clean up dead exports, extract route handlers --- .gitignore | 1 + proxy-server/src/providers/index.ts | 2 - proxy-server/src/providers/names.ts | 1 - proxy-server/src/settings-patcher/claude.ts | 4 +- proxy-server/src/tool-bridge/index.ts | 2 - proxy-server/src/tool-bridge/routes.ts | 274 ++++++++++---------- proxy-server/test/integration/setup.ts | 2 +- 7 files changed, 138 insertions(+), 148 deletions(-) diff --git a/.gitignore b/.gitignore index 24cb5ca..8b1ed23 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ menu-bar-app/.build/ .env.* .DS_Store .vscode/ +.desloppify/ diff --git a/proxy-server/src/providers/index.ts b/proxy-server/src/providers/index.ts index 50fde27..1205de8 100644 --- a/proxy-server/src/providers/index.ts +++ b/proxy-server/src/providers/index.ts @@ -8,8 +8,6 @@ import { registerToolBridge } from "../tool-bridge/index.js"; import { PROVIDER_NAMES } from "copilot-sdk-proxy"; import type { ProviderName } from "copilot-sdk-proxy"; -export type { ProviderName, ProviderMode } from "copilot-sdk-proxy"; - export const providers: Record = { openai: openaiProvider, claude: claudeProvider, diff --git a/proxy-server/src/providers/names.ts b/proxy-server/src/providers/names.ts index 1b9b902..a0b95da 100644 --- a/proxy-server/src/providers/names.ts +++ b/proxy-server/src/providers/names.ts @@ -1,5 +1,4 @@ export { PROVIDER_NAMES } from "copilot-sdk-proxy"; -export type { ProviderName, ProviderMode } from "copilot-sdk-proxy"; import type { ProviderName } from "copilot-sdk-proxy"; export const UA_PREFIXES: Record = { diff --git a/proxy-server/src/settings-patcher/claude.ts b/proxy-server/src/settings-patcher/claude.ts index d6e7466..6430670 100644 --- a/proxy-server/src/settings-patcher/claude.ts +++ b/proxy-server/src/settings-patcher/claude.ts @@ -20,7 +20,7 @@ import type { import { extractLocalhostPort } from "./url-utils.js"; // Claude agent requires ANTHROPIC_AUTH_TOKEN to connect, but any value works for the local proxy. -const DUMMY_AUTH_TOKEN = "xcode-copilot"; +const PLACEHOLDER_AUTH_VALUE = "xcode-copilot"; function defaultSettingsPaths(): SettingsPaths { const dir = join( @@ -104,7 +104,7 @@ export async function patchClaudeSettings( settings.env = { ...settings.env, ANTHROPIC_BASE_URL: `http://localhost:${String(options.port)}`, - ANTHROPIC_AUTH_TOKEN: options.authToken ?? DUMMY_AUTH_TOKEN, + ANTHROPIC_AUTH_TOKEN: options.authToken ?? PLACEHOLDER_AUTH_VALUE, }; await writeFile(p.file, JSON.stringify(settings, null, 2) + "\n", "utf-8"); diff --git a/proxy-server/src/tool-bridge/index.ts b/proxy-server/src/tool-bridge/index.ts index 821addc..78a6ec8 100644 --- a/proxy-server/src/tool-bridge/index.ts +++ b/proxy-server/src/tool-bridge/index.ts @@ -3,8 +3,6 @@ import type { Logger } from "copilot-sdk-proxy"; import { ConversationManager } from "../conversation-manager.js"; import { registerRoutes } from "./routes.js"; -export { BRIDGE_SERVER_NAME, BRIDGE_TOOL_PREFIX } from "../bridge-constants.js"; - export function registerToolBridge( app: FastifyInstance, logger: Logger, diff --git a/proxy-server/src/tool-bridge/routes.ts b/proxy-server/src/tool-bridge/routes.ts index 7458a07..8421419 100644 --- a/proxy-server/src/tool-bridge/routes.ts +++ b/proxy-server/src/tool-bridge/routes.ts @@ -40,161 +40,155 @@ function stripMCPToolPrefix(name: string): string { return name.replace(MCP_TOOL_PREFIX, ""); } +type McpParams = FastifyRequest<{ Params: { convId: string } }>; + +function handleSseStream( + request: McpParams, + reply: FastifyReply, + logger: Logger, +): void { + const { convId } = request.params; + logger.debug(`MCP ${convId}: SSE stream opened`); + + reply.raw.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + request.raw.on("close", () => { + logger.debug(`MCP ${convId}: SSE stream closed`); + }); +} + +async function handleToolCall( + convId: string, + id: number | string, + params: Record | undefined, + stateProvider: ToolStateProvider, + logger: Logger, +) { + const state = stateProvider.getState(convId); + if (!state) { + logger.warn(`MCP ${convId} tools/call: conversation not found`); + return jsonRpcError(id, JSONRPC_INTERNAL_ERROR, "Conversation not found"); + } + + const rawName = params?.["name"]; + const name = typeof rawName === "string" ? rawName : undefined; + const rawArgs = params?.["arguments"]; + const args: Record = isRecord(rawArgs) ? rawArgs : {}; + + if (!name) { + return jsonRpcError(id, JSONRPC_INVALID_PARAMS, "Missing tool name"); + } + + const resolved = state.toolCache.resolveToolName(name); + if (resolved !== name) { + logger.info( + `MCP ${convId} tools/call: name="${name}" resolved to "${resolved}", args=${JSON.stringify(args)}`, + ); + } else { + logger.info( + `MCP ${convId} tools/call: name="${name}", args=${JSON.stringify(args)}`, + ); + } + + try { + const result = await new Promise((resolve, reject) => { + state.toolRouter.registerMCPRequest(resolved, resolve, reject); + }); + + logger.info(`MCP ${convId} tools/call resolved: name="${name}"`); + return jsonRpcResult(id, { + content: [{ type: "text", text: result }], + }); + } catch (err) { + logger.debug(`MCP ${convId} tools/call error details:`, err); + const message = err instanceof Error ? err.message : String(err); + logger.error(`MCP ${convId} tools/call error: ${message}`); + return jsonRpcError(id, JSONRPC_INTERNAL_ERROR, message); + } +} + export function registerRoutes( app: FastifyInstance, stateProvider: ToolStateProvider, logger: Logger, ): void { - // The SDK opens this SSE stream after initialize. We don't push anything, just keep it open. - app.get( - "/mcp/:convId", - ( - request: FastifyRequest<{ Params: { convId: string } }>, - reply: FastifyReply, - ) => { - const { convId } = request.params; - logger.debug(`MCP ${convId}: SSE stream opened`); - - reply.raw.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - - request.raw.on("close", () => { - logger.debug(`MCP ${convId}: SSE stream closed`); - }); - }, - ); - - app.post( - "/mcp/:convId", - async ( - request: FastifyRequest<{ Params: { convId: string } }>, - reply: FastifyReply, - ) => { - const { convId } = request.params; - const parsed = JsonRpcRequestSchema.safeParse(request.body); - if (!parsed.success) { - return reply.send( - jsonRpcError(null, JSONRPC_PARSE_ERROR, "Parse error"), - ); - } - const msg = parsed.data; - - logger.debug( - `MCP ${convId}: method="${msg.method}", id=${String(msg.id)}`, + app.get("/mcp/:convId", (request: McpParams, reply: FastifyReply) => { + handleSseStream(request, reply, logger); + }); + + app.post("/mcp/:convId", async (request: McpParams, reply: FastifyReply) => { + const { convId } = request.params; + const parsed = JsonRpcRequestSchema.safeParse(request.body); + if (!parsed.success) { + return reply.send( + jsonRpcError(null, JSONRPC_PARSE_ERROR, "Parse error"), ); + } + const msg = parsed.data; - if (msg.id === undefined) { - logger.debug( - `MCP ${convId}: notification method="${msg.method}", ignoring`, - ); - return reply.status(202).send(); - } - - const { id, method, params } = msg; + logger.debug( + `MCP ${convId}: method="${msg.method}", id=${String(msg.id)}`, + ); - switch (method) { - case "initialize": - return reply.send( - jsonRpcResult(id, { - protocolVersion: PROTOCOL_VERSION, - capabilities: { tools: {} }, - serverInfo: { name: BRIDGE_SERVER_NAME, version: "1.0.0" }, - }), - ); + if (msg.id === undefined) { + logger.debug( + `MCP ${convId}: notification method="${msg.method}", ignoring`, + ); + return reply.status(202).send(); + } - case "tools/list": { - const state = stateProvider.getState(convId); - if (!state) { - logger.warn(`MCP ${convId} tools/list: conversation not found`); - return reply.send( - jsonRpcError( - id, - JSONRPC_INTERNAL_ERROR, - "Conversation not found", - ), - ); - } - const tools = state.toolCache.getCachedTools().map((t) => ({ - name: stripMCPToolPrefix(t.name), - description: t.description, - inputSchema: t.input_schema, - })); - logger.debug( - `MCP ${convId} tools/list: ${String(tools.length)} tools`, - ); - return reply.send(jsonRpcResult(id, { tools })); - } + const { id, method, params } = msg; - case "tools/call": { - const state = stateProvider.getState(convId); - if (!state) { - logger.warn(`MCP ${convId} tools/call: conversation not found`); - return reply.send( - jsonRpcError( - id, - JSONRPC_INTERNAL_ERROR, - "Conversation not found", - ), - ); - } - - const rawName = params?.["name"]; - const name = typeof rawName === "string" ? rawName : undefined; - const rawArgs = params?.["arguments"]; - const args: Record = isRecord(rawArgs) - ? rawArgs - : {}; - - if (!name) { - return reply.send( - jsonRpcError(id, JSONRPC_INVALID_PARAMS, "Missing tool name"), - ); - } - - const resolved = state.toolCache.resolveToolName(name); - if (resolved !== name) { - logger.info( - `MCP ${convId} tools/call: name="${name}" resolved to "${resolved}", args=${JSON.stringify(args)}`, - ); - } else { - logger.info( - `MCP ${convId} tools/call: name="${name}", args=${JSON.stringify(args)}`, - ); - } - - try { - const result = await new Promise((resolve, reject) => { - state.toolRouter.registerMCPRequest(resolved, resolve, reject); - }); - - logger.info(`MCP ${convId} tools/call resolved: name="${name}"`); - return await reply.send( - jsonRpcResult(id, { - content: [{ type: "text", text: result }], - }), - ); - } catch (err) { - logger.debug(`MCP ${convId} tools/call error details:`, err); - const message = err instanceof Error ? err.message : String(err); - logger.error(`MCP ${convId} tools/call error: ${message}`); - return reply.send( - jsonRpcError(id, JSONRPC_INTERNAL_ERROR, message), - ); - } - } + switch (method) { + case "initialize": + return reply.send( + jsonRpcResult(id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: BRIDGE_SERVER_NAME, version: "1.0.0" }, + }), + ); - default: + case "tools/list": { + const state = stateProvider.getState(convId); + if (!state) { + logger.warn(`MCP ${convId} tools/list: conversation not found`); return reply.send( jsonRpcError( id, - JSONRPC_METHOD_NOT_FOUND, - `Method not found: ${method}`, + JSONRPC_INTERNAL_ERROR, + "Conversation not found", ), ); + } + const tools = state.toolCache.getCachedTools().map((t) => ({ + name: stripMCPToolPrefix(t.name), + description: t.description, + inputSchema: t.input_schema, + })); + logger.debug( + `MCP ${convId} tools/list: ${String(tools.length)} tools`, + ); + return reply.send(jsonRpcResult(id, { tools })); } - }, - ); + + case "tools/call": + return reply.send( + await handleToolCall(convId, id, params, stateProvider, logger), + ); + + default: + return reply.send( + jsonRpcError( + id, + JSONRPC_METHOD_NOT_FOUND, + `Method not found: ${method}`, + ), + ); + } + }); } diff --git a/proxy-server/test/integration/setup.ts b/proxy-server/test/integration/setup.ts index a56771a..fc815b0 100644 --- a/proxy-server/test/integration/setup.ts +++ b/proxy-server/test/integration/setup.ts @@ -11,7 +11,7 @@ export const TIMEOUT = 60_000; export const OPENAI_MODEL = "gpt-5.4"; export const CLAUDE_MODEL = "claude-sonnet-4-6"; -export let service: CopilotService; +let service: CopilotService; export let mock: MockServer; const logger = new Logger("none"); From b3fd8750331249474736483ea28d2335dd27d477 Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 04:48:40 +0000 Subject: [PATCH 02/10] Clean up voided vars, redundant async, magic numbers --- proxy-server/test/conversation-manager.test.ts | 13 ++----------- proxy-server/test/streaming-integration.test.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/proxy-server/test/conversation-manager.test.ts b/proxy-server/test/conversation-manager.test.ts index 8b5f510..46a5c75 100644 --- a/proxy-server/test/conversation-manager.test.ts +++ b/proxy-server/test/conversation-manager.test.ts @@ -225,16 +225,10 @@ describe("ConversationManager", () => { primary.state.toolRouter.registerExpected("toolu_fetch", "WebFetch"); primary.state.session.markSessionInactive(); - let taskResolve: (r: string) => void = () => {}; - let taskReject: (e: Error) => void = () => {}; primary.state.toolRouter.registerMCPRequest( "Task", - (r) => { - taskResolve = () => r; - }, - (e) => { - taskReject = () => e; - }, + () => {}, + () => {}, ); primary.state.toolRouter.registerMCPRequest( "WebFetch", @@ -253,9 +247,6 @@ describe("ConversationManager", () => { true, ); expect(manager.findByContinuationIds(["toolu_task"])).toBe(primary); - - void taskResolve; - void taskReject; }); it("primary is reusable again after pending tool calls are resolved", () => { diff --git a/proxy-server/test/streaming-integration.test.ts b/proxy-server/test/streaming-integration.test.ts index 99266a0..6059d78 100644 --- a/proxy-server/test/streaming-integration.test.ts +++ b/proxy-server/test/streaming-integration.test.ts @@ -13,6 +13,10 @@ import type { AppContext } from "../src/context.js"; import { BYTES_PER_MIB, type ServerConfig } from "../src/config-schema.js"; import { BRIDGE_TOOL_PREFIX } from "../src/bridge-constants.js"; import type { Provider } from "../src/providers/types.js"; +import { + JSONRPC_METHOD_NOT_FOUND, + JSONRPC_PARSE_ERROR, +} from "../src/tool-bridge/constants.js"; const BASE_EVENT = { id: "e1", @@ -218,7 +222,7 @@ function collectTextContent( .join(""); } -async function createApp( +function createApp( ctx: AppContext, provider: Provider, ): Promise { @@ -669,7 +673,7 @@ describe("MCP routes", () => { expect(res.statusCode).toBe(200); const body = res.json(); - expect(body.error.code).toBe(-32601); + expect(body.error.code).toBe(JSONRPC_METHOD_NOT_FOUND); }); it("returns parse error for invalid JSON-RPC", async () => { @@ -688,7 +692,7 @@ describe("MCP routes", () => { expect(res.statusCode).toBe(200); const body = res.json(); - expect(body.error.code).toBe(-32700); + expect(body.error.code).toBe(JSONRPC_PARSE_ERROR); }); it("accepts notifications (no id) with 202", async () => { From 60ba5cbb5910d428eabce11dc5a0778132b71db4 Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 05:01:34 +0000 Subject: [PATCH 03/10] Move bridge-constants into tool-bridge/, fix sendPrompt silent error --- proxy-server/src/providers/shared/session-config.ts | 2 +- proxy-server/src/providers/shared/streaming-core.ts | 8 ++++++-- proxy-server/src/{ => tool-bridge}/bridge-constants.ts | 0 proxy-server/src/tool-bridge/routes.ts | 2 +- proxy-server/test/streaming-integration.test.ts | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) rename proxy-server/src/{ => tool-bridge}/bridge-constants.ts (100%) diff --git a/proxy-server/src/providers/shared/session-config.ts b/proxy-server/src/providers/shared/session-config.ts index 55236ff..ffce227 100644 --- a/proxy-server/src/providers/shared/session-config.ts +++ b/proxy-server/src/providers/shared/session-config.ts @@ -8,7 +8,7 @@ import type { ServerConfig } from "../../config-schema.js"; import { BRIDGE_SERVER_NAME, BRIDGE_TOOL_PREFIX, -} from "../../bridge-constants.js"; +} from "../../tool-bridge/bridge-constants.js"; const SDK_BUILT_IN_TOOLS: string[] = [ // shell diff --git a/proxy-server/src/providers/shared/streaming-core.ts b/proxy-server/src/providers/shared/streaming-core.ts index 37e7729..64a03ae 100644 --- a/proxy-server/src/providers/shared/streaming-core.ts +++ b/proxy-server/src/providers/shared/streaming-core.ts @@ -8,7 +8,7 @@ import type { import { createCommonEventHandler } from "copilot-sdk-proxy"; import type { ToolBridgeState } from "../../tool-bridge/state.js"; import { isRecord } from "../../utils/type-guards.js"; -import { BRIDGE_TOOL_PREFIX } from "../../bridge-constants.js"; +import { BRIDGE_TOOL_PREFIX } from "../../tool-bridge/bridge-constants.js"; // Xcode sends tool names without the bridge prefix. function stripBridgePrefix(name: string): string { @@ -226,10 +226,14 @@ class StreamingHandler { if (this.sessionDone) return; this.logger.error("Failed to send prompt:", err); this.sessionDone = true; + const r = this.getReply(); + if (r) { + this.protocol.sendFailed(r); + } this.protocol.teardown(); this.protocol.reset(); this.unsubscribe(); - this.finishStream(this.getReply()); + this.finishStream(r); }); } } diff --git a/proxy-server/src/bridge-constants.ts b/proxy-server/src/tool-bridge/bridge-constants.ts similarity index 100% rename from proxy-server/src/bridge-constants.ts rename to proxy-server/src/tool-bridge/bridge-constants.ts diff --git a/proxy-server/src/tool-bridge/routes.ts b/proxy-server/src/tool-bridge/routes.ts index 8421419..2232a0a 100644 --- a/proxy-server/src/tool-bridge/routes.ts +++ b/proxy-server/src/tool-bridge/routes.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { z } from "zod"; import type { ToolStateProvider } from "../conversation-manager.js"; import type { Logger } from "copilot-sdk-proxy"; -import { BRIDGE_SERVER_NAME } from "../bridge-constants.js"; +import { BRIDGE_SERVER_NAME } from "./bridge-constants.js"; import { isRecord } from "../utils/type-guards.js"; import { JSONRPC_PARSE_ERROR, diff --git a/proxy-server/test/streaming-integration.test.ts b/proxy-server/test/streaming-integration.test.ts index 6059d78..845e377 100644 --- a/proxy-server/test/streaming-integration.test.ts +++ b/proxy-server/test/streaming-integration.test.ts @@ -11,7 +11,7 @@ import { codexProvider } from "../src/providers/codex/provider.js"; import { openaiProvider } from "../src/providers/openai/provider.js"; import type { AppContext } from "../src/context.js"; import { BYTES_PER_MIB, type ServerConfig } from "../src/config-schema.js"; -import { BRIDGE_TOOL_PREFIX } from "../src/bridge-constants.js"; +import { BRIDGE_TOOL_PREFIX } from "../src/tool-bridge/bridge-constants.js"; import type { Provider } from "../src/providers/types.js"; import { JSONRPC_METHOD_NOT_FOUND, From 256a8dfb63327185a78aa9cb65651332c3d2be65 Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 06:01:03 +0000 Subject: [PATCH 04/10] Add errorMessage() utility, replace inline error narrowing --- proxy-server/src/config.ts | 6 +++--- proxy-server/src/index.ts | 3 ++- proxy-server/src/tool-bridge/routes.ts | 4 ++-- proxy-server/src/utils/type-guards.ts | 4 ++++ 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/proxy-server/src/config.ts b/proxy-server/src/config.ts index 5f24605..9b20831 100644 --- a/proxy-server/src/config.ts +++ b/proxy-server/src/config.ts @@ -14,7 +14,7 @@ import { MS_PER_MINUTE, } from "./config-schema.js"; import type { ServerConfig } from "./config-schema.js"; -import { isErrnoException } from "./utils/type-guards.js"; +import { isErrnoException, errorMessage } from "./utils/type-guards.js"; function resolveServerPaths( servers: Record, @@ -73,7 +73,7 @@ async function parseConfigFile( return null; } throw new Error( - `Failed to read config file at ${absolutePath}: ${err instanceof Error ? err.message : String(err)}`, + `Failed to read config file at ${absolutePath}: ${errorMessage(err)}`, { cause: err }, ); } @@ -83,7 +83,7 @@ async function parseConfigFile( raw = JSON5.parse(text); } catch (err) { throw new Error( - `Failed to parse config file: ${err instanceof Error ? err.message : String(err)}`, + `Failed to parse config file: ${errorMessage(err)}`, { cause: err }, ); } diff --git a/proxy-server/src/index.ts b/proxy-server/src/index.ts index bde4d12..b64b027 100644 --- a/proxy-server/src/index.ts +++ b/proxy-server/src/index.ts @@ -14,6 +14,7 @@ import { } from "./cli-validators.js"; import { startServer, type StartOptions } from "./startup.js"; import { installAgent, uninstallAgent } from "./launchd/index.js"; +import { errorMessage } from "./utils/type-guards.js"; const PACKAGE_ROOT = dirname(import.meta.dirname); const DEFAULT_CONFIG_PATH = join(PACKAGE_ROOT, "config.json5"); @@ -27,7 +28,7 @@ try { version = z.object({ version: z.string() }).parse(raw).version; } catch (err) { throw new Error( - `Failed to read package.json: ${err instanceof Error ? err.message : String(err)}`, + `Failed to read package.json: ${errorMessage(err)}`, { cause: err }, ); } diff --git a/proxy-server/src/tool-bridge/routes.ts b/proxy-server/src/tool-bridge/routes.ts index 2232a0a..f68db0c 100644 --- a/proxy-server/src/tool-bridge/routes.ts +++ b/proxy-server/src/tool-bridge/routes.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { ToolStateProvider } from "../conversation-manager.js"; import type { Logger } from "copilot-sdk-proxy"; import { BRIDGE_SERVER_NAME } from "./bridge-constants.js"; -import { isRecord } from "../utils/type-guards.js"; +import { isRecord, errorMessage } from "../utils/type-guards.js"; import { JSONRPC_PARSE_ERROR, JSONRPC_INVALID_PARAMS, @@ -105,7 +105,7 @@ async function handleToolCall( }); } catch (err) { logger.debug(`MCP ${convId} tools/call error details:`, err); - const message = err instanceof Error ? err.message : String(err); + const message = errorMessage(err); logger.error(`MCP ${convId} tools/call error: ${message}`); return jsonRpcError(id, JSONRPC_INTERNAL_ERROR, message); } diff --git a/proxy-server/src/utils/type-guards.ts b/proxy-server/src/utils/type-guards.ts index c3ae83e..f0766b0 100644 --- a/proxy-server/src/utils/type-guards.ts +++ b/proxy-server/src/utils/type-guards.ts @@ -2,6 +2,10 @@ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + export function isErrnoException(err: unknown): err is NodeJS.ErrnoException { return ( err instanceof Error && From 3c0692c94453843cd0ea60fd496a70f89e8838c6 Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 06:01:08 +0000 Subject: [PATCH 05/10] Type filteredTools as FunctionTool[], use readonly unknown[] in session config --- proxy-server/src/providers/shared/session-config.ts | 4 ++-- proxy-server/src/tool-bridge/state.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/proxy-server/src/providers/shared/session-config.ts b/proxy-server/src/providers/shared/session-config.ts index ffce227..9eee50e 100644 --- a/proxy-server/src/providers/shared/session-config.ts +++ b/proxy-server/src/providers/shared/session-config.ts @@ -43,7 +43,7 @@ interface SessionConfigOptions extends BaseSessionConfigOptions { } interface ToolBridgeContext { - tools: unknown[] | undefined; + tools: readonly unknown[] | undefined; config: ServerConfig; logger: Logger; } @@ -65,7 +65,7 @@ function resolveToolBridge({ interface ProviderContext { conversationId: string; - tools: unknown[] | undefined; + tools: readonly unknown[] | undefined; config: ServerConfig; logger: Logger; port: number; diff --git a/proxy-server/src/tool-bridge/state.ts b/proxy-server/src/tool-bridge/state.ts index c82a578..411cf4f 100644 --- a/proxy-server/src/tool-bridge/state.ts +++ b/proxy-server/src/tool-bridge/state.ts @@ -1,25 +1,28 @@ +import type { filterFunctionTools } from "copilot-sdk-proxy"; import { ToolCache } from "./tool-cache.js"; import { ToolRouter } from "./tool-router.js"; import { ReplyTracker } from "./reply-tracker.js"; import { SessionLifecycle } from "./session-lifecycle.js"; +type FunctionTool = ReturnType[number]; + export class ToolBridgeState { readonly toolCache = new ToolCache(); readonly toolRouter: ToolRouter; readonly replies = new ReplyTracker(); readonly session: SessionLifecycle; - private _filteredTools: unknown[] | undefined; + private _filteredTools: FunctionTool[] | undefined; constructor(toolBridgeTimeoutMs = 0) { this.toolRouter = new ToolRouter(toolBridgeTimeoutMs); this.session = new SessionLifecycle(this.toolRouter); } - get filteredTools(): unknown[] | undefined { + get filteredTools(): FunctionTool[] | undefined { return this._filteredTools; } - setFilteredTools(tools: unknown[]): void { + setFilteredTools(tools: FunctionTool[]): void { this._filteredTools = tools; } } From 427e965d011dd8e2d20d9207742a273b6ecfaf35 Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 06:01:13 +0000 Subject: [PATCH 06/10] Mark session state in sendPrompt error path, align teardown ordering --- proxy-server/src/providers/shared/streaming-core.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/proxy-server/src/providers/shared/streaming-core.ts b/proxy-server/src/providers/shared/streaming-core.ts index 64a03ae..9d6814a 100644 --- a/proxy-server/src/providers/shared/streaming-core.ts +++ b/proxy-server/src/providers/shared/streaming-core.ts @@ -226,14 +226,16 @@ class StreamingHandler { if (this.sessionDone) return; this.logger.error("Failed to send prompt:", err); this.sessionDone = true; + this.state.session.markSessionErrored(); + this.state.session.markSessionInactive(); const r = this.getReply(); if (r) { this.protocol.sendFailed(r); } this.protocol.teardown(); this.protocol.reset(); - this.unsubscribe(); this.finishStream(r); + this.unsubscribe(); }); } } From 5229b99cf4d85f88ee3ac5665dae9b2405440dc6 Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 06:01:17 +0000 Subject: [PATCH 07/10] Export parseOptions, add startup option parsing tests --- proxy-server/src/startup.ts | 2 +- proxy-server/test/startup.test.ts | 93 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 proxy-server/test/startup.test.ts diff --git a/proxy-server/src/startup.ts b/proxy-server/src/startup.ts index 936fcf6..6ca8d34 100644 --- a/proxy-server/src/startup.ts +++ b/proxy-server/src/startup.ts @@ -57,7 +57,7 @@ interface ParsedOptions { cwd: string | undefined; } -function parseOptions(options: StartOptions): ParsedOptions { +export function parseOptions(options: StartOptions): ParsedOptions { const logLevel = parseLogLevel(options.logLevel); const logger = new Logger(logLevel); const port = parsePort(options.port); diff --git a/proxy-server/test/startup.test.ts b/proxy-server/test/startup.test.ts new file mode 100644 index 0000000..37a6248 --- /dev/null +++ b/proxy-server/test/startup.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { parseOptions, type StartOptions } from "../src/startup.js"; + +function baseOptions(overrides: Partial = {}): StartOptions { + return { + port: "8080", + logLevel: "none", + version: "1.0.0", + defaultConfigPath: "/default/config.json5", + ...overrides, + }; +} + +describe("parseOptions", () => { + it("returns defaults for minimal options", () => { + const result = parseOptions(baseOptions()); + expect(result.port).toBe(8080); + expect(result.proxyMode).toBe("auto"); + expect(result.logLevel).toBe("none"); + expect(result.launchdMode).toBe(false); + expect(result.idleTimeoutMinutes).toBe(0); + expect(result.cwd).toBeUndefined(); + }); + + it("parses port as number", () => { + const result = parseOptions(baseOptions({ port: "3000" })); + expect(result.port).toBe(3000); + }); + + it("sets proxyMode from proxy option", () => { + const result = parseOptions(baseOptions({ proxy: "claude" })); + expect(result.proxyMode).toBe("claude"); + }); + + it("enables launchd mode", () => { + const result = parseOptions(baseOptions({ launchd: true })); + expect(result.launchdMode).toBe(true); + expect(result.quiet).toBe(true); + }); + + it("sets quiet when log level is none", () => { + const result = parseOptions(baseOptions({ logLevel: "none" })); + expect(result.quiet).toBe(true); + }); + + it("parses idle timeout", () => { + const result = parseOptions(baseOptions({ idleTimeout: "30" })); + expect(result.idleTimeoutMinutes).toBe(30); + }); + + describe("shouldPatch", () => { + it("enables auto-patch in auto mode (non-launchd)", () => { + const result = parseOptions(baseOptions()); + expect(result.shouldPatch).toBe(true); + }); + + it("disables auto-patch in auto mode with launchd", () => { + const result = parseOptions(baseOptions({ launchd: true })); + expect(result.shouldPatch).toBe(false); + }); + + it("disables auto-patch for explicit provider without flag", () => { + const result = parseOptions(baseOptions({ proxy: "claude" })); + expect(result.shouldPatch).toBe(false); + }); + + it("enables auto-patch for explicit provider with flag", () => { + const result = parseOptions( + baseOptions({ proxy: "claude", autoPatch: true }), + ); + expect(result.shouldPatch).toBe(true); + }); + + it("disables auto-patch for explicit provider with launchd even if flag set", () => { + const result = parseOptions( + baseOptions({ proxy: "claude", autoPatch: true, launchd: true }), + ); + expect(result.shouldPatch).toBe(false); + }); + }); + + describe("configPath", () => { + it("uses explicit config path when provided", () => { + const result = parseOptions(baseOptions({ config: "/my/config.json5" })); + expect(result.configPath).toBe("/my/config.json5"); + }); + + it("falls back to default config path", () => { + const result = parseOptions(baseOptions()); + expect(typeof result.configPath).toBe("string"); + }); + }); +}); From e9249b808505f27ff2ac8268804f12aed97d9cc5 Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 06:01:23 +0000 Subject: [PATCH 08/10] Update copilot-sdk-proxy to 4.0.4, llm-mock-server to 1.0.4 --- .gitignore | 1 - proxy-server/package-lock.json | 16 ++++++++-------- proxy-server/package.json | 4 ++-- proxy-server/test/startup.test.ts | 4 ++-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 8b1ed23..24cb5ca 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,3 @@ menu-bar-app/.build/ .env.* .DS_Store .vscode/ -.desloppify/ diff --git a/proxy-server/package-lock.json b/proxy-server/package-lock.json index c5b1313..4a69317 100644 --- a/proxy-server/package-lock.json +++ b/proxy-server/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "commander": "14.0.3", - "copilot-sdk-proxy": "4.0.3", + "copilot-sdk-proxy": "4.0.4", "fastify": "5.8.2", "json5": "2.2.3", "koffi": "2.15.2", @@ -24,7 +24,7 @@ "devDependencies": { "@types/node": "25.5.0", "@types/plist": "3.0.5", - "llm-mock-server": "1.0.3", + "llm-mock-server": "1.0.4", "oxfmt": "0.40.0", "oxlint": "1.55.0", "patch-package": "8.0.1", @@ -2219,9 +2219,9 @@ } }, "node_modules/copilot-sdk-proxy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/copilot-sdk-proxy/-/copilot-sdk-proxy-4.0.3.tgz", - "integrity": "sha512-htEWPCV64xYp80cdEmV6gbx5XFWyb15KOu2ulfdDy2OTCsKqKJ0WfQITb4BkSp6DacTbym7sAtzUNfKQnz2R8g==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/copilot-sdk-proxy/-/copilot-sdk-proxy-4.0.4.tgz", + "integrity": "sha512-K2ZLejzSQthf15oJOKnusW80V896+Vpv7bqv5VVrqCfs7e98hhaPQs8FJmxy/gPJJ4VqZoO9C6gJYLYyga2/aw==", "license": "MIT", "dependencies": { "@fastify/cors": "11.2.0", @@ -3222,9 +3222,9 @@ } }, "node_modules/llm-mock-server": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/llm-mock-server/-/llm-mock-server-1.0.3.tgz", - "integrity": "sha512-lmG8w4B60O7F7HjA5hwKlPiOGVCM0nOPYo5fE9py3+lH7UUfbkoj8ewRx6ER6XffqyUq8G7PpL9XeU5N3OcS+Q==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/llm-mock-server/-/llm-mock-server-1.0.4.tgz", + "integrity": "sha512-BWfbKE2YaG+Q1UQcQMmUWO3fURUVFkEoya7QLVlCVVzqhwxcfKW4j5WWk+tyCsCg+Jiz+OLJvcPVS0ttWAV6NQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/proxy-server/package.json b/proxy-server/package.json index a926901..5b8186e 100644 --- a/proxy-server/package.json +++ b/proxy-server/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "commander": "14.0.3", - "copilot-sdk-proxy": "4.0.3", + "copilot-sdk-proxy": "4.0.4", "fastify": "5.8.2", "json5": "2.2.3", "koffi": "2.15.2", @@ -44,7 +44,7 @@ "devDependencies": { "@types/node": "25.5.0", "@types/plist": "3.0.5", - "llm-mock-server": "1.0.3", + "llm-mock-server": "1.0.4", "oxfmt": "0.40.0", "oxlint": "1.55.0", "patch-package": "8.0.1", diff --git a/proxy-server/test/startup.test.ts b/proxy-server/test/startup.test.ts index 37a6248..c79f4e4 100644 --- a/proxy-server/test/startup.test.ts +++ b/proxy-server/test/startup.test.ts @@ -85,9 +85,9 @@ describe("parseOptions", () => { expect(result.configPath).toBe("/my/config.json5"); }); - it("falls back to default config path", () => { + it("resolves a config path when not specified", () => { const result = parseOptions(baseOptions()); - expect(typeof result.configPath).toBe("string"); + expect(result.configPath).toContain("config.json5"); }); }); }); From 5f411e50404e109ba7069a2333ac2187dd07a06b Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 06:09:12 +0000 Subject: [PATCH 09/10] Add send-rejection tests for all three provider streaming paths --- .../test/streaming-integration.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/proxy-server/test/streaming-integration.test.ts b/proxy-server/test/streaming-integration.test.ts index 845e377..a834a10 100644 --- a/proxy-server/test/streaming-integration.test.ts +++ b/proxy-server/test/streaming-integration.test.ts @@ -222,6 +222,38 @@ function collectTextContent( .join(""); } +function createSendRejectSession(error: Error): CopilotSession { + return { + on() { + return () => {}; + }, + abort: () => Promise.resolve(), + setModel: () => Promise.resolve(), + send: () => Promise.reject(error), + } as unknown as CopilotSession; +} + +function createSendRejectCtx(error: Error): AppContext { + return { + service: { + cwd: process.cwd(), + createSession: () => Promise.resolve(createSendRejectSession(error)), + listModels: () => + Promise.resolve([ + { + id: "test-model", + capabilities: { supports: { reasoningEffort: false } }, + }, + ]), + ping: () => Promise.resolve({ message: "ok", timestamp: Date.now() }), + } as unknown as AppContext["service"], + logger: new Logger("none"), + config, + port: 8080, + stats: new Stats(), + }; +} + function createApp( ctx: AppContext, provider: Provider, @@ -352,6 +384,23 @@ describe("OpenAI streaming integration", () => { "Answer", ); }); + + it("completes stream when session.send() rejects", async () => { + const ctx = createSendRejectCtx(new Error("connection refused")); + app = await createApp(ctx, openaiProvider); + + const res = await app.inject({ + method: "POST", + url: "/v1/chat/completions", + headers: { ...xcodeHeaders, "content-type": "application/json" }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Hi" }], + }, + }); + + expect(res.statusCode).toBe(200); + }); }); describe("Claude streaming integration", () => { @@ -445,6 +494,24 @@ describe("Claude streaming integration", () => { ); expect(collectTextContent(events, "claude")).toBe("Answer"); }); + + it("completes stream when session.send() rejects", async () => { + const ctx = createSendRejectCtx(new Error("connection refused")); + app = await createApp(ctx, claudeProvider); + + const res = await app.inject({ + method: "POST", + url: "/v1/messages", + headers: { ...claudeHeaders, "content-type": "application/json" }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 100, + }, + }); + + expect(res.statusCode).toBe(200); + }); }); describe("Codex streaming integration", () => { @@ -494,6 +561,20 @@ describe("Codex streaming integration", () => { expect(reasoningDelta!.delta).toBe("Deep thought"); expect(collectTextContent(events, "codex")).toBe("Answer"); }); + + it("completes stream when session.send() rejects", async () => { + const ctx = createSendRejectCtx(new Error("connection refused")); + app = await createApp(ctx, codexProvider); + + const res = await app.inject({ + method: "POST", + url: "/v1/responses", + headers: { ...codexHeaders, "content-type": "application/json" }, + payload: { model: "test-model", input: "Hi" }, + }); + + expect(res.statusCode).toBe(200); + }); }); describe("Tool bridge integration — Claude", () => { From b2829b26ff2c2c622d6cafcfd12d04d8792bba95 Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 06:14:24 +0000 Subject: [PATCH 10/10] Fix formatting --- proxy-server/src/config.ts | 7 +++---- proxy-server/src/index.ts | 7 +++---- proxy-server/src/tool-bridge/routes.ts | 18 ++++-------------- 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/proxy-server/src/config.ts b/proxy-server/src/config.ts index 9b20831..7b3c6d0 100644 --- a/proxy-server/src/config.ts +++ b/proxy-server/src/config.ts @@ -82,10 +82,9 @@ async function parseConfigFile( try { raw = JSON5.parse(text); } catch (err) { - throw new Error( - `Failed to parse config file: ${errorMessage(err)}`, - { cause: err }, - ); + throw new Error(`Failed to parse config file: ${errorMessage(err)}`, { + cause: err, + }); } if (!raw || typeof raw !== "object") { diff --git a/proxy-server/src/index.ts b/proxy-server/src/index.ts index b64b027..4c1cb3e 100644 --- a/proxy-server/src/index.ts +++ b/proxy-server/src/index.ts @@ -27,10 +27,9 @@ try { ); version = z.object({ version: z.string() }).parse(raw).version; } catch (err) { - throw new Error( - `Failed to read package.json: ${errorMessage(err)}`, - { cause: err }, - ); + throw new Error(`Failed to read package.json: ${errorMessage(err)}`, { + cause: err, + }); } interface PatchOptions { diff --git a/proxy-server/src/tool-bridge/routes.ts b/proxy-server/src/tool-bridge/routes.ts index f68db0c..ecc416d 100644 --- a/proxy-server/src/tool-bridge/routes.ts +++ b/proxy-server/src/tool-bridge/routes.ts @@ -124,15 +124,11 @@ export function registerRoutes( const { convId } = request.params; const parsed = JsonRpcRequestSchema.safeParse(request.body); if (!parsed.success) { - return reply.send( - jsonRpcError(null, JSONRPC_PARSE_ERROR, "Parse error"), - ); + return reply.send(jsonRpcError(null, JSONRPC_PARSE_ERROR, "Parse error")); } const msg = parsed.data; - logger.debug( - `MCP ${convId}: method="${msg.method}", id=${String(msg.id)}`, - ); + logger.debug(`MCP ${convId}: method="${msg.method}", id=${String(msg.id)}`); if (msg.id === undefined) { logger.debug( @@ -158,11 +154,7 @@ export function registerRoutes( if (!state) { logger.warn(`MCP ${convId} tools/list: conversation not found`); return reply.send( - jsonRpcError( - id, - JSONRPC_INTERNAL_ERROR, - "Conversation not found", - ), + jsonRpcError(id, JSONRPC_INTERNAL_ERROR, "Conversation not found"), ); } const tools = state.toolCache.getCachedTools().map((t) => ({ @@ -170,9 +162,7 @@ export function registerRoutes( description: t.description, inputSchema: t.input_schema, })); - logger.debug( - `MCP ${convId} tools/list: ${String(tools.length)} tools`, - ); + logger.debug(`MCP ${convId} tools/list: ${String(tools.length)} tools`); return reply.send(jsonRpcResult(id, { tools })); }