From d251bb4e318b3203c9fdabc34f47c256982d1797 Mon Sep 17 00:00:00 2001 From: Suyash Srijan Date: Sun, 15 Mar 2026 04:20:20 +0000 Subject: [PATCH] Add oxfmt and editorconfig --- .github/workflows/test.yml | 3 + proxy-server/.editorconfig | 12 + proxy-server/.oxfmtrc.json | 9 + proxy-server/package-lock.json | 374 +++++++++++++++++ proxy-server/package.json | 3 + proxy-server/src/banner.ts | 13 +- proxy-server/src/cli-validators.ts | 13 +- proxy-server/src/config-schema.ts | 34 +- proxy-server/src/config.ts | 21 +- proxy-server/src/conversation-manager.ts | 55 ++- proxy-server/src/index.ts | 70 +++- proxy-server/src/launchd/agent.ts | 26 +- proxy-server/src/providers/claude/provider.ts | 122 ++++-- .../src/providers/claude/streaming.ts | 42 +- .../src/providers/claude/tool-results.ts | 22 +- proxy-server/src/providers/codex/provider.ts | 146 ++++--- proxy-server/src/providers/codex/streaming.ts | 85 +++- proxy-server/src/providers/index.ts | 4 +- proxy-server/src/providers/openai/provider.ts | 22 +- .../src/providers/shared/continuation.ts | 8 +- .../src/providers/shared/session-config.ts | 47 ++- .../src/providers/shared/streaming-core.ts | 36 +- .../shared/streaming-orchestrator.ts | 9 +- .../src/providers/shared/user-agent-guard.ts | 5 +- proxy-server/src/settings-patcher/claude.ts | 28 +- proxy-server/src/settings-patcher/codex.ts | 54 ++- proxy-server/src/settings-patcher/index.ts | 35 +- proxy-server/src/shutdown.ts | 23 +- proxy-server/src/startup.ts | 88 +++- proxy-server/src/tool-bridge/index.ts | 6 +- proxy-server/src/tool-bridge/routes.ts | 66 ++- proxy-server/src/tool-bridge/tool-cache.ts | 11 +- proxy-server/src/tool-bridge/tool-router.ts | 33 +- proxy-server/src/utils/child-process.ts | 5 +- proxy-server/src/utils/type-guards.ts | 6 +- proxy-server/test/cli-validators.test.ts | 55 ++- proxy-server/test/config-schema.test.ts | 6 +- proxy-server/test/config.test.ts | 61 ++- .../test/conversation-manager.test.ts | 42 +- .../handlers/claude-session-reuse.test.ts | 14 +- .../handlers/openai-session-reuse.test.ts | 9 +- proxy-server/test/handlers/responses.test.ts | 29 +- .../test/handlers/session-config.test.ts | 145 +++++-- proxy-server/test/integration/claude.test.ts | 355 ++++++++++------ proxy-server/test/integration/codex.test.ts | 313 +++++++++------ proxy-server/test/integration/openai.test.ts | 380 +++++++++++------- proxy-server/test/integration/setup.ts | 19 +- proxy-server/test/launchd/agent.test.ts | 145 ++++--- proxy-server/test/launchd/socket.test.ts | 18 +- .../test/providers/auto-provider.test.ts | 5 +- proxy-server/test/providers/claude.test.ts | 33 +- proxy-server/test/providers/codex.test.ts | 9 +- .../test/providers/continuation.test.ts | 49 ++- proxy-server/test/providers/openai.test.ts | 8 +- .../providers/streaming-orchestrator.test.ts | 3 +- .../test/providers/tool-results.test.ts | 70 +++- proxy-server/test/settings-patcher.test.ts | 108 ++++- proxy-server/test/shutdown.test.ts | 17 +- .../test/streaming-integration.test.ts | 288 ++++++++++--- proxy-server/test/tool-bridge/index.test.ts | 11 +- .../test/tool-bridge/mcp-routes.test.ts | 85 +++- .../test/tool-bridge/reply-tracker.test.ts | 4 +- proxy-server/test/tool-bridge/state.test.ts | 6 +- .../test/tool-bridge/tool-cache.test.ts | 167 +++++--- .../test/tool-bridge/tool-router.test.ts | 44 +- .../test/utils/anthropic-prompt.test.ts | 13 +- proxy-server/test/utils/prompt.test.ts | 4 +- .../test/utils/responses-prompt.test.ts | 6 +- 68 files changed, 3014 insertions(+), 1043 deletions(-) create mode 100644 proxy-server/.editorconfig create mode 100644 proxy-server/.oxfmtrc.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f142e0..ab42c4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,9 @@ jobs: - name: Type check run: npm run typecheck + - name: Format check + run: npm run fmt:check + - name: Lint run: npm run lint diff --git a/proxy-server/.editorconfig b/proxy-server/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/proxy-server/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/proxy-server/.oxfmtrc.json b/proxy-server/.oxfmtrc.json new file mode 100644 index 0000000..e8673a0 --- /dev/null +++ b/proxy-server/.oxfmtrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "ignorePatterns": ["dist/", "coverage/"] +} diff --git a/proxy-server/package-lock.json b/proxy-server/package-lock.json index 370b724..c5b1313 100644 --- a/proxy-server/package-lock.json +++ b/proxy-server/package-lock.json @@ -25,6 +25,7 @@ "@types/node": "25.5.0", "@types/plist": "3.0.5", "llm-mock-server": "1.0.3", + "oxfmt": "0.40.0", "oxlint": "1.55.0", "patch-package": "8.0.1", "tsx": "4.21.0", @@ -835,6 +836,329 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.40.0.tgz", + "integrity": "sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.40.0.tgz", + "integrity": "sha512-/mbS9UUP/5Vbl2D6osIdcYiP0oie63LKMoTyGj5hyMCK/SFkl3EhtyRAfdjPvuvHC0SXdW6ePaTKkBSq1SNcIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.40.0.tgz", + "integrity": "sha512-wRt8fRdfLiEhnRMBonlIbKrJWixoEmn6KCjKE9PElnrSDSXETGZfPb8ee+nQNTobXkCVvVLytp2o0obAsxl78Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.40.0.tgz", + "integrity": "sha512-fzowhqbOE/NRy+AE5ob0+Y4X243WbWzDb00W+pKwD7d9tOqsAFbtWUwIyqqCoCLxj791m2xXIEeLH/3uz7zCCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.40.0.tgz", + "integrity": "sha512-agZ9ITaqdBjcerRRFEHB8s0OyVcQW8F9ZxsszjxzeSthQ4fcN2MuOtQFWec1ed8/lDa50jSLHVE2/xPmTgtCfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.40.0.tgz", + "integrity": "sha512-ZM2oQ47p28TP1DVIp7HL1QoMUgqlBFHey0ksHct7tMXoU5BqjNvPWw7888azzMt25lnyPODVuye1wvNbvVUFOA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.40.0.tgz", + "integrity": "sha512-RBFPAxRAIsMisKM47Oe6Lwdv6agZYLz02CUhVCD1sOv5ajAcRMrnwCFBPWwGXpazToW2mjnZxFos8TuFjTU15A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.40.0.tgz", + "integrity": "sha512-Nb2XbQ+wV3W2jSIihXdPj7k83eOxeSgYP3N/SRXvQ6ZYPIk6Q86qEh5Gl/7OitX3bQoQrESqm1yMLvZV8/J7dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.40.0.tgz", + "integrity": "sha512-tGmWhLD/0YMotCdfezlT6tC/MJG/wKpo4vnQ3Cq+4eBk/BwNv7EmkD0VkD5F/dYkT3b8FNU01X2e8vvJuWoM1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.40.0.tgz", + "integrity": "sha512-rVbFyM3e7YhkVnp0IVYjaSHfrBWcTRWb60LEcdNAJcE2mbhTpbqKufx0FrhWfoxOrW/+7UJonAOShoFFLigDqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.40.0.tgz", + "integrity": "sha512-3ZqBw14JtWeEoLiioJcXSJz8RQyPE+3jLARnYM1HdPzZG4vk+Ua8CUupt2+d+vSAvMyaQBTN2dZK+kbBS/j5mA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.40.0.tgz", + "integrity": "sha512-JJ4PPSdcbGBjPvb+O7xYm2FmAsKCyuEMYhqatBAHMp/6TA6rVlf9Z/sYPa4/3Bommb+8nndm15SPFRHEPU5qFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.40.0.tgz", + "integrity": "sha512-Kp0zNJoX9Ik77wUya2tpBY3W9f40VUoMQLWVaob5SgCrblH/t2xr/9B2bWHfs0WCefuGmqXcB+t0Lq77sbBmZw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.40.0.tgz", + "integrity": "sha512-7YTCNzleWTaQTqNGUNQ66qVjpoV6DjbCOea+RnpMBly2bpzrI/uu7Rr+2zcgRfNxyjXaFTVQKaRKjqVdeUfeVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.40.0.tgz", + "integrity": "sha512-hWnSzJ0oegeOwfOEeejYXfBqmnRGHusgtHfCPzmvJvHTwy1s3Neo59UKc1CmpE3zxvrCzJoVHos0rr97GHMNPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.40.0.tgz", + "integrity": "sha512-28sJC1lR4qtBJGzSRRbPnSW3GxU2+4YyQFE6rCmsUYqZ5XYH8jg0/w+CvEzQ8TuAQz5zLkcA25nFQGwoU0PT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.40.0.tgz", + "integrity": "sha512-cDkRnyT0dqwF5oIX1Cv59HKCeZQFbWWdUpXa3uvnHFT2iwYSSZspkhgjXjU6iDp5pFPaAEAe9FIbMoTgkTmKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.40.0.tgz", + "integrity": "sha512-7rPemBJjqm5Gkv6ZRCPvK8lE6AqQ/2z31DRdWazyx2ZvaSgL7QGofHXHNouRpPvNsT9yxRNQJgigsWkc+0qg4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.40.0.tgz", + "integrity": "sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxlint/binding-android-arm-eabi": { "version": "1.55.0", "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.55.0.tgz", @@ -3053,6 +3377,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oxfmt": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.40.0.tgz", + "integrity": "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.40.0", + "@oxfmt/binding-android-arm64": "0.40.0", + "@oxfmt/binding-darwin-arm64": "0.40.0", + "@oxfmt/binding-darwin-x64": "0.40.0", + "@oxfmt/binding-freebsd-x64": "0.40.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", + "@oxfmt/binding-linux-arm64-gnu": "0.40.0", + "@oxfmt/binding-linux-arm64-musl": "0.40.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", + "@oxfmt/binding-linux-riscv64-musl": "0.40.0", + "@oxfmt/binding-linux-s390x-gnu": "0.40.0", + "@oxfmt/binding-linux-x64-gnu": "0.40.0", + "@oxfmt/binding-linux-x64-musl": "0.40.0", + "@oxfmt/binding-openharmony-arm64": "0.40.0", + "@oxfmt/binding-win32-arm64-msvc": "0.40.0", + "@oxfmt/binding-win32-ia32-msvc": "0.40.0", + "@oxfmt/binding-win32-x64-msvc": "0.40.0" + } + }, "node_modules/oxlint": { "version": "1.55.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.55.0.tgz", @@ -3574,6 +3938,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", diff --git a/proxy-server/package.json b/proxy-server/package.json index 05120b9..a926901 100644 --- a/proxy-server/package.json +++ b/proxy-server/package.json @@ -26,6 +26,8 @@ "dev": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", + "fmt": "oxfmt --write src/ test/", + "fmt:check": "oxfmt --check src/ test/", "lint": "oxlint", "typecheck": "tsc -p tsconfig.check.json", "prepublishOnly": "npm run build" @@ -43,6 +45,7 @@ "@types/node": "25.5.0", "@types/plist": "3.0.5", "llm-mock-server": "1.0.3", + "oxfmt": "0.40.0", "oxlint": "1.55.0", "patch-package": "8.0.1", "tsx": "4.21.0", diff --git a/proxy-server/src/banner.ts b/proxy-server/src/banner.ts index 5b706c1..fea338f 100644 --- a/proxy-server/src/banner.ts +++ b/proxy-server/src/banner.ts @@ -46,9 +46,8 @@ export interface ProxyBannerInfo { } export function printProxyBanner(info: ProxyBannerInfo): void { - const providerHint = info.proxyFlag === "auto" - ? "" - : ` ${dim(`(--proxy ${info.proxyFlag})`)}`; + const providerHint = + info.proxyFlag === "auto" ? "" : ` ${dim(`(--proxy ${info.proxyFlag})`)}`; const lines = [ "", @@ -65,9 +64,11 @@ export function printProxyBanner(info: ProxyBannerInfo): void { const binaryName = AGENT_BINARY_NAMES[info.proxyFlag]; if (binaryName) { const agentPath = findAgentBinary(info.proxyFlag, info.logger); - lines.push(agentPath - ? ` ${dim("Agent")} ${agentPath}` - : ` ${dim("Agent")} ${dim(`not found (expected at ${AGENTS_DIR}//${binaryName})`)}`); + lines.push( + agentPath + ? ` ${dim("Agent")} ${agentPath}` + : ` ${dim("Agent")} ${dim(`not found (expected at ${AGENTS_DIR}//${binaryName})`)}`, + ); } } lines.push(""); diff --git a/proxy-server/src/cli-validators.ts b/proxy-server/src/cli-validators.ts index 57605fe..972bfcc 100644 --- a/proxy-server/src/cli-validators.ts +++ b/proxy-server/src/cli-validators.ts @@ -1,4 +1,10 @@ -import { parsePort, parseLogLevel, parseIdleTimeout, PROVIDER_NAMES, isProviderName } from "copilot-sdk-proxy"; +import { + parsePort, + parseLogLevel, + parseIdleTimeout, + PROVIDER_NAMES, + isProviderName, +} from "copilot-sdk-proxy"; import type { ProviderName, ProviderMode } from "copilot-sdk-proxy"; export { parsePort, parseLogLevel, parseIdleTimeout, isProviderName }; @@ -19,7 +25,10 @@ export function parseProviderMode(value: string): ProviderMode { const PATCHABLE_PROXIES: ReadonlySet = new Set(["claude", "codex"]); -export function validateAutoPatch(proxy: ProviderName, autoPatch: boolean): void { +export function validateAutoPatch( + proxy: ProviderName, + autoPatch: boolean, +): void { if (autoPatch && !PATCHABLE_PROXIES.has(proxy)) { throw new Error( `--auto-patch is only supported for: ${[...PATCHABLE_PROXIES].join(", ")}`, diff --git a/proxy-server/src/config-schema.ts b/proxy-server/src/config-schema.ts index c4db921..1783ae4 100644 --- a/proxy-server/src/config-schema.ts +++ b/proxy-server/src/config-schema.ts @@ -21,23 +21,35 @@ const ProviderConfigSchema = z.object({ }); export const ServerConfigSchema = z.object({ - openai: ProviderConfigSchema.default({ toolBridge: false, toolBridgeTimeout: 0, mcpServers: {} }), - claude: ProviderConfigSchema.default({ toolBridge: false, toolBridgeTimeout: 0, mcpServers: {} }), - codex: ProviderConfigSchema.default({ toolBridge: false, toolBridgeTimeout: 0, mcpServers: {} }), - allowedCliTools: z.array(z.string()).refine( - (arr) => !arr.includes("*") || arr.length === 1, - 'allowedCliTools: use ["*"] alone to allow all tools, don\'t mix with other entries', - ).default([]), + openai: ProviderConfigSchema.default({ + toolBridge: false, + toolBridgeTimeout: 0, + mcpServers: {}, + }), + claude: ProviderConfigSchema.default({ + toolBridge: false, + toolBridgeTimeout: 0, + mcpServers: {}, + }), + codex: ProviderConfigSchema.default({ + toolBridge: false, + toolBridgeTimeout: 0, + mcpServers: {}, + }), + allowedCliTools: z + .array(z.string()) + .refine( + (arr) => !arr.includes("*") || arr.length === 1, + 'allowedCliTools: use ["*"] alone to allow all tools, don\'t mix with other entries', + ) + .default([]), excludedFilePatterns: z.array(z.string()).default([]), bodyLimit: z .number() .positive() .max(100, "bodyLimit cannot exceed 100") .default(10), - requestTimeout: z - .number() - .min(0, "requestTimeout must be >= 0") - .default(0), + requestTimeout: z.number().min(0, "requestTimeout must be >= 0").default(0), reasoningEffort: ReasoningEffortSchema.optional(), autoApprovePermissions: ApprovalRuleSchema.default(["read", "mcp"]), }); diff --git a/proxy-server/src/config.ts b/proxy-server/src/config.ts index be84731..5f24605 100644 --- a/proxy-server/src/config.ts +++ b/proxy-server/src/config.ts @@ -7,7 +7,12 @@ import JSON5 from "json5"; import { z } from "zod"; import type { Logger, MCPServer } from "copilot-sdk-proxy"; import type { ProviderName } from "copilot-sdk-proxy"; -import { ServerConfigSchema, DEFAULT_CONFIG, BYTES_PER_MIB, MS_PER_MINUTE } from "./config-schema.js"; +import { + ServerConfigSchema, + DEFAULT_CONFIG, + BYTES_PER_MIB, + MS_PER_MINUTE, +} from "./config-schema.js"; import type { ServerConfig } from "./config-schema.js"; import { isErrnoException } from "./utils/type-guards.js"; @@ -95,7 +100,7 @@ async function parseConfigFile( } const path = firstError.path.join("."); throw new Error( - `Invalid config${path ? ` at "${path}"` : ""}: ${firstError.message}` + `Invalid config${path ? ` at "${path}"` : ""}: ${firstError.message}`, ); } @@ -142,9 +147,15 @@ export async function loadAllProviderConfigs( ): Promise { const result = await parseConfigFile(configPath, logger); const providers: Record = { - openai: result ? buildServerConfig(result.data, result.configDir, "openai") : DEFAULT_CONFIG, - claude: result ? buildServerConfig(result.data, result.configDir, "claude") : DEFAULT_CONFIG, - codex: result ? buildServerConfig(result.data, result.configDir, "codex") : DEFAULT_CONFIG, + openai: result + ? buildServerConfig(result.data, result.configDir, "openai") + : DEFAULT_CONFIG, + claude: result + ? buildServerConfig(result.data, result.configDir, "claude") + : DEFAULT_CONFIG, + codex: result + ? buildServerConfig(result.data, result.configDir, "codex") + : DEFAULT_CONFIG, }; // Common fields only, no per-provider toolBridge / mcpServers. const shared: ServerConfig = result diff --git a/proxy-server/src/conversation-manager.ts b/proxy-server/src/conversation-manager.ts index b77b233..81c8e23 100644 --- a/proxy-server/src/conversation-manager.ts +++ b/proxy-server/src/conversation-manager.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; import { ToolBridgeState } from "./tool-bridge/state.js"; -import type { Conversation as CoreConversation, Logger } from "copilot-sdk-proxy"; +import type { + Conversation as CoreConversation, + Logger, +} from "copilot-sdk-proxy"; export interface Conversation extends CoreConversation { state: ToolBridgeState; @@ -13,11 +16,15 @@ export interface ToolStateProvider { } function isConversation(conv: CoreConversation): conv is Conversation { - return "state" in conv && (conv as { state: unknown }).state instanceof ToolBridgeState; + return ( + "state" in conv && + (conv as { state: unknown }).state instanceof ToolBridgeState + ); } export function asConversation(conv: CoreConversation): Conversation { - if (!isConversation(conv)) throw new Error("Expected extended Conversation with state"); + if (!isConversation(conv)) + throw new Error("Expected extended Conversation with state"); return conv; } @@ -43,12 +50,22 @@ export class ConversationManager implements ToolStateProvider { sentMessageCount: 0, isPrimary, model: null, - get sessionActive() { return state.session.sessionActive; }, + get sessionActive() { + return state.session.sessionActive; + }, set sessionActive(active: boolean) { - if (active) { state.session.markSessionActive(); } else { state.session.markSessionInactive(); } + if (active) { + state.session.markSessionActive(); + } else { + state.session.markSessionInactive(); + } + }, + get hadError() { + return state.session.hadError; + }, + set hadError(errored: boolean) { + if (errored) state.session.markSessionErrored(); }, - get hadError() { return state.session.hadError; }, - set hadError(errored: boolean) { if (errored) state.session.markSessionErrored(); }, }; this.conversations.set(id, conversation); @@ -61,7 +78,9 @@ export class ConversationManager implements ToolStateProvider { }); } - this.logger.debug(`Created conversation ${id} (primary=${String(isPrimary)})`); + this.logger.debug( + `Created conversation ${id} (primary=${String(isPrimary)})`, + ); return conversation; } @@ -98,17 +117,23 @@ export class ConversationManager implements ToolStateProvider { const primary = this.getPrimary(); if (primary) { if (primary.state.session.sessionActive) { - this.logger.debug(`Primary ${primary.id} is busy, creating isolated conversation`); + this.logger.debug( + `Primary ${primary.id} is busy, creating isolated conversation`, + ); return { conversation: this.create(), isReuse: false }; } if (primary.state.toolRouter.hasPending) { - this.logger.debug(`Primary ${primary.id} has pending tool calls, creating isolated conversation`); + this.logger.debug( + `Primary ${primary.id} has pending tool calls, creating isolated conversation`, + ); return { conversation: this.create(), isReuse: false }; } if (!primary.session) { // No SDK session yet. Create an isolated conversation so the primary // stays available for the first real request. - this.logger.debug(`Primary ${primary.id} has no session, creating isolated conversation`); + this.logger.debug( + `Primary ${primary.id} has no session, creating isolated conversation`, + ); return { conversation: this.create(), isReuse: false }; } this.logger.debug(`Reusing primary conversation ${primary.id}`); @@ -123,7 +148,9 @@ export class ConversationManager implements ToolStateProvider { for (const [, conv] of this.conversations) { for (const callId of callIds) { if (conv.state.toolRouter.hasPendingToolCall(callId)) { - this.logger.debug(`Continuation matched conversation ${conv.id} via call_id ${callId}`); + this.logger.debug( + `Continuation matched conversation ${conv.id} via call_id ${callId}`, + ); return conv; } } @@ -131,7 +158,9 @@ export class ConversationManager implements ToolStateProvider { for (const [, conv] of this.conversations) { if (conv.state.session.sessionActive) { - this.logger.debug(`Continuation matched conversation ${conv.id} via sessionActive fallback`); + this.logger.debug( + `Continuation matched conversation ${conv.id} via sessionActive fallback`, + ); return conv; } } diff --git a/proxy-server/src/index.ts b/proxy-server/src/index.ts index d57f757..bde4d12 100644 --- a/proxy-server/src/index.ts +++ b/proxy-server/src/index.ts @@ -5,7 +5,13 @@ import { z } from "zod"; import { Command } from "commander"; import { Logger } from "copilot-sdk-proxy"; import { patcherByProxy } from "./settings-patcher/index.js"; -import { parsePort, parseLogLevel, parseProvider, parseIdleTimeout, validateAutoPatch } from "./cli-validators.js"; +import { + parsePort, + parseLogLevel, + parseProvider, + parseIdleTimeout, + validateAutoPatch, +} from "./cli-validators.js"; import { startServer, type StartOptions } from "./startup.js"; import { installAgent, uninstallAgent } from "./launchd/index.js"; @@ -15,7 +21,9 @@ const DEFAULT_CONFIG_PATH = join(PACKAGE_ROOT, "config.json5"); // Can't use a JSON import here because rootDir is src/ and package.json lives at the root let version: string; try { - const raw: unknown = JSON.parse(await readFile(join(PACKAGE_ROOT, "package.json"), "utf-8")); + const raw: unknown = JSON.parse( + await readFile(join(PACKAGE_ROOT, "package.json"), "utf-8"), + ); version = z.object({ version: z.string() }).parse(raw).version; } catch (err) { throw new Error( @@ -84,7 +92,9 @@ interface InstallAgentCliOptions { autoPatch?: true; } -async function installAgentCommand(options: InstallAgentCliOptions): Promise { +async function installAgentCommand( + options: InstallAgentCliOptions, +): Promise { const logLevel = parseLogLevel(options.logLevel); const logger = new Logger(logLevel); const port = parsePort(options.port); @@ -107,7 +117,9 @@ async function installAgentCommand(options: InstallAgentCliOptions): Promise { +async function uninstallAgentCommand(options: { + logLevel: string; +}): Promise { const logLevel = parseLogLevel(options.logLevel); const logger = new Logger(logLevel); await uninstallAgent({ logger }); @@ -122,27 +134,49 @@ const program = new Command() program .option("-p, --port ", "port to listen on", "8080") - .option("--proxy ", "API format: openai, claude, codex (default: all)") + .option( + "--proxy ", + "API format: openai, claude, codex (default: all)", + ) .option("-l, --log-level ", "log verbosity", "info") .option("-c, --config ", "path to config file") .option("--cwd ", "working directory for Copilot sessions") - .option("--auto-patch", "auto-patch settings on start, restore on exit (implied when --proxy is omitted)") - .option("--idle-timeout ", "shut down after N minutes of inactivity", "0") + .option( + "--auto-patch", + "auto-patch settings on start, restore on exit (implied when --proxy is omitted)", + ) + .option( + "--idle-timeout ", + "shut down after N minutes of inactivity", + "0", + ) .option("--launchd", "run in launchd mode (socket activation, no TTY output)") - .action((options: StartOptions) => startServer({ ...options, version, defaultConfigPath: DEFAULT_CONFIG_PATH })); + .action((options: StartOptions) => + startServer({ + ...options, + version, + defaultConfigPath: DEFAULT_CONFIG_PATH, + }), + ); program .command("patch-settings") .description("Patch settings to point to this server, then exit") .option("-p, --port ", "port to write into settings", "8080") - .option("--proxy ", "which provider to patch: claude, codex (default: all)") + .option( + "--proxy ", + "which provider to patch: claude, codex (default: all)", + ) .option("-l, --log-level ", "log verbosity", "info") .action((options: PatchOptions) => patchSettingsCommand(options)); program .command("restore-settings") .description("Restore settings from backup, then exit") - .option("--proxy ", "which provider to restore: claude, codex (default: all)") + .option( + "--proxy ", + "which provider to restore: claude, codex (default: all)", + ) .option("-l, --log-level ", "log verbosity", "info") .action((options: RestoreOptions) => restoreSettingsCommand(options)); @@ -150,12 +184,22 @@ program .command("install-agent") .description("Install a launchd agent with socket activation") .option("-p, --port ", "port to listen on", "8080") - .option("--proxy ", "API format: openai, claude, codex (default: all)") + .option( + "--proxy ", + "API format: openai, claude, codex (default: all)", + ) .option("-l, --log-level ", "log verbosity for the agent", "info") .option("-c, --config ", "path to config file") .option("--cwd ", "working directory for Copilot sessions") - .option("--auto-patch", "patch settings on install, restore on uninstall (implied when --proxy is omitted)") - .option("--idle-timeout ", "shut down agent after N minutes of inactivity", "60") + .option( + "--auto-patch", + "patch settings on install, restore on uninstall (implied when --proxy is omitted)", + ) + .option( + "--idle-timeout ", + "shut down agent after N minutes of inactivity", + "60", + ) .action((options: InstallAgentCliOptions) => installAgentCommand(options)); program diff --git a/proxy-server/src/launchd/agent.ts b/proxy-server/src/launchd/agent.ts index 91b6aac..c0667c2 100644 --- a/proxy-server/src/launchd/agent.ts +++ b/proxy-server/src/launchd/agent.ts @@ -44,8 +44,10 @@ export function generatePlist(options: PlistOptions): string { options.nodePath, options.entryPoint, "--launchd", - "--port", String(options.port), - "--log-level", options.logLevel, + "--port", + String(options.port), + "--log-level", + options.logLevel, ]; if (options.proxy !== "auto") { @@ -122,7 +124,9 @@ export function parsePlistArgs(plistContent: string): ParsedPlistArgs { return { proxy: null, autoPatch: false }; } - const flagArgs = args.slice(2).filter((a): a is string => typeof a === "string"); + const flagArgs = args + .slice(2) + .filter((a): a is string => typeof a === "string"); const cmd = new Command() .exitOverride() @@ -168,7 +172,9 @@ interface InstallAgentOptions { entryPoint?: string | undefined; } -export async function installAgent(options: InstallAgentOptions): Promise { +export async function installAgent( + options: InstallAgentOptions, +): Promise { const { port, proxy, @@ -180,7 +186,9 @@ export async function installAgent(options: InstallAgentOptions): Promise const plistPath = options.plistPath ?? defaultPlistPath(); const nodePath = options.nodePath ?? process.execPath; // import.meta.dirname at runtime is dist/launchd/, so go up two levels to the package root - const entryPoint = options.entryPoint ?? resolve(dirname(dirname(import.meta.dirname)), "dist/index.js"); + const entryPoint = + options.entryPoint ?? + resolve(dirname(dirname(import.meta.dirname)), "dist/index.js"); if (existsSync(plistPath)) { try { @@ -223,7 +231,9 @@ interface UninstallAgentOptions { plistPath?: string | undefined; } -export async function uninstallAgent(options: UninstallAgentOptions): Promise { +export async function uninstallAgent( + options: UninstallAgentOptions, +): Promise { const { logger } = options; const exec = options.exec ?? defaultExec; const plistPath = options.plistPath ?? defaultPlistPath(); @@ -239,7 +249,9 @@ export async function uninstallAgent(options: UninstallAgentOptions): Promise { - const toolResultIds = extractToolResultIds(req.messages); - if (toolResultIds.length === 0) return false; + app.post( + "/v1/messages", + createMessagesHandler(ctx, manager, { + beforeHandler: async (req, reply) => { + const toolResultIds = extractToolResultIds(req.messages); + if (toolResultIds.length === 0) return false; - const existingConv = manager.findByContinuationIds(toolResultIds); - if (!existingConv) return false; + const existingConv = manager.findByContinuationIds(toolResultIds); + if (!existingConv) return false; - return handleContinuation( - asConversation(existingConv), - reply, - logger, - { - startStream: () => { startReply(reply, req.model); }, - resolveResults: () => { resolveToolResults(req.messages, existingConv.state, logger); }, - countMessages: () => req.messages.length, - }, - ); - }, + return handleContinuation( + asConversation(existingConv), + reply, + logger, + { + startStream: () => { + startReply(reply, req.model); + }, + resolveResults: () => { + resolveToolResults(req.messages, existingConv.state, logger); + }, + countMessages: () => req.messages.length, + }, + ); + }, - onConversationReady: (conversation, req) => { - const { state } = asConversation(conversation); - const tools = req.tools; - if (tools?.length) { - state.toolCache.cacheTools(tools); - } - }, + onConversationReady: (conversation, req) => { + const { state } = asConversation(conversation); + const tools = req.tools; + if (tools?.length) { + state.toolCache.cacheTools(tools); + } + }, - transformPrompt: (prompt) => - filterExcludedFiles(prompt, config.excludedFilePatterns), + transformPrompt: (prompt) => + filterExcludedFiles(prompt, config.excludedFilePatterns), - createSessionConfig: (baseOptions, conversation, req) => - createProviderSessionConfig(baseOptions, { conversationId: conversation.id, tools: req.tools, config, logger, port }), + createSessionConfig: (baseOptions, conversation, req) => + createProviderSessionConfig(baseOptions, { + conversationId: conversation.id, + tools: req.tools, + config, + logger, + port, + }), - handleStreaming: async ({ conversation, session, prompt, model, reply, req }) => { - const conv = asConversation(conversation); - const hasBridge = !!req.tools?.length && config.toolBridge; - conv.state.replies.setReply(reply); + handleStreaming: async ({ + conversation, + session, + prompt, + model, + reply, + req, + }) => { + const conv = asConversation(conversation); + const hasBridge = !!req.tools?.length && config.toolBridge; + conv.state.replies.setReply(reply); - await orchestrateStreaming({ - conversation: conv, logger, manager, - messageCount: req.messages.length, - runStreaming: () => handleAnthropicStreaming({ state: conv.state, session, prompt, model, logger, hasBridge, stats }), - }); - }, - })); + await orchestrateStreaming({ + conversation: conv, + logger, + manager, + messageCount: req.messages.length, + runStreaming: () => + handleAnthropicStreaming({ + state: conv.state, + session, + prompt, + model, + logger, + hasBridge, + stats, + }), + }); + }, + }), + ); app.post("/v1/messages/count_tokens", createCountTokensHandler(ctx)); }, diff --git a/proxy-server/src/providers/claude/streaming.ts b/proxy-server/src/providers/claude/streaming.ts index 3a4c86b..858301e 100644 --- a/proxy-server/src/providers/claude/streaming.ts +++ b/proxy-server/src/providers/claude/streaming.ts @@ -1,17 +1,28 @@ import type { FastifyReply } from "fastify"; -import type { CopilotSession, Logger, Stats, ContentBlockStopEvent } from "copilot-sdk-proxy"; +import type { + CopilotSession, + Logger, + Stats, + ContentBlockStopEvent, +} from "copilot-sdk-proxy"; import { sendSSEEvent as sendEvent, startReply, AnthropicProtocol, } from "copilot-sdk-proxy"; import type { ToolBridgeState } from "../../tool-bridge/state.js"; -import type { BridgeStreamProtocol, StrippedToolRequest } from "../shared/streaming-core.js"; +import type { + BridgeStreamProtocol, + StrippedToolRequest, +} from "../shared/streaming-core.js"; import { runSessionStreaming } from "../shared/streaming-core.js"; export { startReply }; -class BridgeAnthropicProtocol extends AnthropicProtocol implements BridgeStreamProtocol { +class BridgeAnthropicProtocol + extends AnthropicProtocol + implements BridgeStreamProtocol +{ private emitToolUseBlocks( r: FastifyReply, toolRequests: StrippedToolRequest[], @@ -21,10 +32,16 @@ class BridgeAnthropicProtocol extends AnthropicProtocol implements BridgeStreamP sendEvent(r, "content_block_start", { type: "content_block_start", index, - content_block: { type: "tool_use", id: tr.toolCallId, name: tr.name, input: {} }, + content_block: { + type: "tool_use", + id: tr.toolCallId, + name: tr.name, + input: {}, + }, }); - const argsJson = tr.arguments != null ? JSON.stringify(tr.arguments) : "{}"; + const argsJson = + tr.arguments != null ? JSON.stringify(tr.arguments) : "{}"; sendEvent(r, "content_block_delta", { type: "content_block_delta", index, @@ -64,12 +81,23 @@ interface AnthropicStreamingOptions { stats: Stats; } -export function handleAnthropicStreaming(opts: AnthropicStreamingOptions): Promise { +export function handleAnthropicStreaming( + opts: AnthropicStreamingOptions, +): Promise { const { state, session, prompt, model, logger, hasBridge, stats } = opts; const reply = state.replies.currentReply; if (!reply) throw new Error("No reply set on bridge state"); startReply(reply, model); const protocol = new BridgeAnthropicProtocol(); - return runSessionStreaming({ state, session, prompt, logger, hasBridge, protocol, initialReply: reply, stats }); + return runSessionStreaming({ + state, + session, + prompt, + logger, + hasBridge, + protocol, + initialReply: reply, + stats, + }); } diff --git a/proxy-server/src/providers/claude/tool-results.ts b/proxy-server/src/providers/claude/tool-results.ts index 703c121..0366bdc 100644 --- a/proxy-server/src/providers/claude/tool-results.ts +++ b/proxy-server/src/providers/claude/tool-results.ts @@ -7,18 +7,26 @@ export function resolveToolResults( logger: Logger, ): void { const lastMsg = messages[messages.length - 1]; - if (!lastMsg || lastMsg.role !== "user" || typeof lastMsg.content === "string") return; + if ( + !lastMsg || + lastMsg.role !== "user" || + typeof lastMsg.content === "string" + ) + return; for (const block of lastMsg.content) { if (block.type === "tool_result") { - const resultText = typeof block.content === "string" - ? block.content - : Array.isArray(block.content) - ? block.content.map((b) => b.text).join("\n") - : ""; + const resultText = + typeof block.content === "string" + ? block.content + : Array.isArray(block.content) + ? block.content.map((b) => b.text).join("\n") + : ""; logger.debug(`Resolving tool result for ${block.tool_use_id}`); if (!state.toolRouter.resolveToolCall(block.tool_use_id, resultText)) { - logger.warn(`No pending MCP request for tool_use_id ${block.tool_use_id}`); + logger.warn( + `No pending MCP request for tool_use_id ${block.tool_use_id}`, + ); } } } diff --git a/proxy-server/src/providers/codex/provider.ts b/proxy-server/src/providers/codex/provider.ts index fccf1cf..e3d4cec 100644 --- a/proxy-server/src/providers/codex/provider.ts +++ b/proxy-server/src/providers/codex/provider.ts @@ -23,67 +23,109 @@ export const codexProvider = { register(app, ctx) { addUserAgentGuard(app, UA_PREFIXES.codex, ctx.logger); - const manager = resolveToolBridgeManager(app, ctx.toolBridgeManager, ctx.logger, ctx.config.toolBridgeTimeoutMs); + const manager = resolveToolBridgeManager( + app, + ctx.toolBridgeManager, + ctx.logger, + ctx.config.toolBridgeTimeoutMs, + ); const { logger, config, port, stats } = ctx; - app.post("/v1/responses", createResponsesHandler(ctx, manager, { - beforeHandler: async (req, reply) => { - const callOutputs = extractFunctionCallOutputs(req.input); - if (callOutputs.length === 0) return false; + app.post( + "/v1/responses", + createResponsesHandler(ctx, manager, { + beforeHandler: async (req, reply) => { + const callOutputs = extractFunctionCallOutputs(req.input); + if (callOutputs.length === 0) return false; - const existingConv = manager.findByContinuationIds( - callOutputs.map((o) => o.call_id), - ); - if (!existingConv) return false; + const existingConv = manager.findByContinuationIds( + callOutputs.map((o) => o.call_id), + ); + if (!existingConv) return false; - return handleContinuation( - asConversation(existingConv), - reply, - logger, - { - startStream: () => startResponseStream(reply, genId("resp"), req.model), - resolveResults: () => { resolveResponsesToolResults(callOutputs, existingConv.state, logger); }, - countMessages: () => Array.isArray(req.input) ? req.input.length : 1, - }, - ); - }, + return handleContinuation( + asConversation(existingConv), + reply, + logger, + { + startStream: () => + startResponseStream(reply, genId("resp"), req.model), + resolveResults: () => { + resolveResponsesToolResults( + callOutputs, + existingConv.state, + logger, + ); + }, + countMessages: () => + Array.isArray(req.input) ? req.input.length : 1, + }, + ); + }, - onConversationReady: (conversation, req) => { - const { state } = asConversation(conversation); - const tools = req.tools ? filterFunctionTools(req.tools) : undefined; - if (tools) state.setFilteredTools(tools); + onConversationReady: (conversation, req) => { + const { state } = asConversation(conversation); + const tools = req.tools ? filterFunctionTools(req.tools) : undefined; + if (tools) state.setFilteredTools(tools); - // Responses API tools use `parameters`, bridge uses `input_schema` - if (tools?.length) { - const bridgeTools = tools.map((t) => ({ - name: t.name, - description: t.description, - input_schema: t.parameters ?? {}, - })); - state.toolCache.cacheTools(bridgeTools); - } - }, + // Responses API tools use `parameters`, bridge uses `input_schema` + if (tools?.length) { + const bridgeTools = tools.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.parameters ?? {}, + })); + state.toolCache.cacheTools(bridgeTools); + } + }, - transformPrompt: (prompt) => - filterExcludedFiles(prompt, config.excludedFilePatterns), + transformPrompt: (prompt) => + filterExcludedFiles(prompt, config.excludedFilePatterns), - createSessionConfig: (baseOptions, conversation) => { - const { state } = asConversation(conversation); - return createProviderSessionConfig(baseOptions, { conversationId: conversation.id, tools: state.filteredTools, config, logger, port }); - }, + createSessionConfig: (baseOptions, conversation) => { + const { state } = asConversation(conversation); + return createProviderSessionConfig(baseOptions, { + conversationId: conversation.id, + tools: state.filteredTools, + config, + logger, + port, + }); + }, - handleStreaming: async ({ conversation, session, prompt, model, reply, req, responseId }) => { - const conv = asConversation(conversation); - const tools = conv.state.filteredTools; - const hasBridge = !!tools?.length && config.toolBridge; - conv.state.replies.setReply(reply); + handleStreaming: async ({ + conversation, + session, + prompt, + model, + reply, + req, + responseId, + }) => { + const conv = asConversation(conversation); + const tools = conv.state.filteredTools; + const hasBridge = !!tools?.length && config.toolBridge; + conv.state.replies.setReply(reply); - await orchestrateStreaming({ - conversation: conv, logger, manager, - messageCount: Array.isArray(req.input) ? req.input.length : 1, - runStreaming: () => handleResponsesStreaming({ state: conv.state, session, prompt, model, logger, hasBridge, responseId, stats }), - }); - }, - })); + await orchestrateStreaming({ + conversation: conv, + logger, + manager, + messageCount: Array.isArray(req.input) ? req.input.length : 1, + runStreaming: () => + handleResponsesStreaming({ + state: conv.state, + session, + prompt, + model, + logger, + hasBridge, + responseId, + stats, + }), + }); + }, + }), + ); }, } satisfies Provider; diff --git a/proxy-server/src/providers/codex/streaming.ts b/proxy-server/src/providers/codex/streaming.ts index 926f12c..4ff4cb0 100644 --- a/proxy-server/src/providers/codex/streaming.ts +++ b/proxy-server/src/providers/codex/streaming.ts @@ -1,5 +1,10 @@ import type { FastifyReply } from "fastify"; -import type { CopilotSession, Logger, Stats, FunctionCallOutputItem } from "copilot-sdk-proxy"; +import type { + CopilotSession, + Logger, + Stats, + FunctionCallOutputItem, +} from "copilot-sdk-proxy"; import { sendSSEEvent as sendEvent, sendSSEComment, @@ -10,12 +15,18 @@ import { } from "copilot-sdk-proxy"; import type { SeqCounter } from "copilot-sdk-proxy"; import type { ToolBridgeState } from "../../tool-bridge/state.js"; -import type { BridgeStreamProtocol, StrippedToolRequest } from "../shared/streaming-core.js"; +import type { + BridgeStreamProtocol, + StrippedToolRequest, +} from "../shared/streaming-core.js"; import { runSessionStreaming } from "../shared/streaming-core.js"; export { startResponseStream }; -class BridgeResponsesProtocol extends ResponsesProtocol implements BridgeStreamProtocol { +class BridgeResponsesProtocol + extends ResponsesProtocol + implements BridgeStreamProtocol +{ private readonly keepaliveInterval: ReturnType; private readonly getReply: () => FastifyReply | null; @@ -42,7 +53,8 @@ class BridgeResponsesProtocol extends ResponsesProtocol implements BridgeStreamP for (const tr of toolRequests) { const callId = tr.toolCallId; const itemId = genId("fc"); - const argsJson = tr.arguments != null ? JSON.stringify(tr.arguments) : "{}"; + const argsJson = + tr.arguments != null ? JSON.stringify(tr.arguments) : "{}"; const fcItem: FunctionCallOutputItem = { type: "function_call", @@ -53,16 +65,29 @@ class BridgeResponsesProtocol extends ResponsesProtocol implements BridgeStreamP status: "in_progress", }; - sendEvent(r, "response.output_item.added", { - output_index: this.outputIndex, - item: fcItem, - }, nextSeq(this.seq)); + sendEvent( + r, + "response.output_item.added", + { + output_index: this.outputIndex, + item: fcItem, + }, + nextSeq(this.seq), + ); - const doneItem: FunctionCallOutputItem = { ...fcItem, status: "completed" }; - sendEvent(r, "response.output_item.done", { - output_index: this.outputIndex, - item: doneItem, - }, nextSeq(this.seq)); + const doneItem: FunctionCallOutputItem = { + ...fcItem, + status: "completed", + }; + sendEvent( + r, + "response.output_item.done", + { + output_index: this.outputIndex, + item: doneItem, + }, + nextSeq(this.seq), + ); this.outputItems.push(doneItem); this.outputIndex++; @@ -101,12 +126,38 @@ interface ResponsesStreamingOptions { stats: Stats; } -export function handleResponsesStreaming(opts: ResponsesStreamingOptions): Promise { - const { state, session, prompt, model, logger, hasBridge, responseId, stats } = opts; +export function handleResponsesStreaming( + opts: ResponsesStreamingOptions, +): Promise { + const { + state, + session, + prompt, + model, + logger, + hasBridge, + responseId, + stats, + } = opts; const reply = state.replies.currentReply; if (!reply) throw new Error("No reply set on bridge state"); const { seq, createdAt } = startResponseStream(reply, responseId, model); - const protocol = new BridgeResponsesProtocol(responseId, model, seq, createdAt, () => state.replies.currentReply); - return runSessionStreaming({ state, session, prompt, logger, hasBridge, protocol, initialReply: reply, stats }); + const protocol = new BridgeResponsesProtocol( + responseId, + model, + seq, + createdAt, + () => state.replies.currentReply, + ); + return runSessionStreaming({ + state, + session, + prompt, + logger, + hasBridge, + protocol, + initialReply: reply, + stats, + }); } diff --git a/proxy-server/src/providers/index.ts b/proxy-server/src/providers/index.ts index 763af70..50fde27 100644 --- a/proxy-server/src/providers/index.ts +++ b/proxy-server/src/providers/index.ts @@ -25,7 +25,9 @@ export function createAutoProvider( register(app, baseCtx) { // One shared manager + MCP route set so we don't hit // Fastify's duplicate-route error across scoped plugins - const maxTimeout = Math.max(...PROVIDER_NAMES.map((n) => configs[n].toolBridgeTimeoutMs)); + const maxTimeout = Math.max( + ...PROVIDER_NAMES.map((n) => configs[n].toolBridgeTimeoutMs), + ); const sharedManager = registerToolBridge(app, baseCtx.logger, maxTimeout); for (const name of PROVIDER_NAMES) { diff --git a/proxy-server/src/providers/openai/provider.ts b/proxy-server/src/providers/openai/provider.ts index f902726..4c284a5 100644 --- a/proxy-server/src/providers/openai/provider.ts +++ b/proxy-server/src/providers/openai/provider.ts @@ -1,5 +1,8 @@ import type { Provider } from "../types.js"; -import { createModelsHandler, createCompletionsHandler } from "copilot-sdk-proxy"; +import { + createModelsHandler, + createCompletionsHandler, +} from "copilot-sdk-proxy"; import { resolveToolBridgeManager } from "../../tool-bridge/index.js"; import { filterExcludedFiles } from "../shared/prompt-utils.js"; import { addUserAgentGuard } from "../shared/user-agent-guard.js"; @@ -12,10 +15,19 @@ export const openaiProvider = { register(app, ctx) { addUserAgentGuard(app, UA_PREFIXES.openai, ctx.logger); - const manager = resolveToolBridgeManager(app, ctx.toolBridgeManager, ctx.logger, ctx.config.toolBridgeTimeoutMs); + const manager = resolveToolBridgeManager( + app, + ctx.toolBridgeManager, + ctx.logger, + ctx.config.toolBridgeTimeoutMs, + ); app.get("/v1/models", createModelsHandler(ctx)); - app.post("/v1/chat/completions", createCompletionsHandler(ctx, manager, { - transformPrompt: (prompt) => filterExcludedFiles(prompt, ctx.config.excludedFilePatterns), - })); + app.post( + "/v1/chat/completions", + createCompletionsHandler(ctx, manager, { + transformPrompt: (prompt) => + filterExcludedFiles(prompt, ctx.config.excludedFilePatterns), + }), + ); }, } satisfies Provider; diff --git a/proxy-server/src/providers/shared/continuation.ts b/proxy-server/src/providers/shared/continuation.ts index 5598e79..692b662 100644 --- a/proxy-server/src/providers/shared/continuation.ts +++ b/proxy-server/src/providers/shared/continuation.ts @@ -16,10 +16,14 @@ export async function handleContinuation( ): Promise { const { state } = existingConv; - logger.info(`Continuation for conversation ${existingConv.id} (hasPending=${String(state.toolRouter.hasPending)}, sessionActive=${String(state.session.sessionActive)})`); + logger.info( + `Continuation for conversation ${existingConv.id} (hasPending=${String(state.toolRouter.hasPending)}, sessionActive=${String(state.session.sessionActive)})`, + ); if (state.session.sessionActive) { - logger.warn(`Conversation ${existingConv.id} is already streaming, cannot handle continuation`); + logger.warn( + `Conversation ${existingConv.id} is already streaming, cannot handle continuation`, + ); return false; } diff --git a/proxy-server/src/providers/shared/session-config.ts b/proxy-server/src/providers/shared/session-config.ts index ed7eae6..55236ff 100644 --- a/proxy-server/src/providers/shared/session-config.ts +++ b/proxy-server/src/providers/shared/session-config.ts @@ -1,21 +1,38 @@ -import type { SessionConfig, Logger, SessionConfigOptions as BaseSessionConfigOptions } from "copilot-sdk-proxy"; +import type { + SessionConfig, + Logger, + SessionConfigOptions as BaseSessionConfigOptions, +} from "copilot-sdk-proxy"; import { createSessionConfig as createBaseSessionConfig } from "copilot-sdk-proxy"; import type { ServerConfig } from "../../config-schema.js"; -import { BRIDGE_SERVER_NAME, BRIDGE_TOOL_PREFIX } from "../../bridge-constants.js"; +import { + BRIDGE_SERVER_NAME, + BRIDGE_TOOL_PREFIX, +} from "../../bridge-constants.js"; const SDK_BUILT_IN_TOOLS: string[] = [ // shell - "bash", "write_bash", "read_bash", "stop_bash", "list_bash", + "bash", + "write_bash", + "read_bash", + "stop_bash", + "list_bash", // file ops - "view", "apply_patch", + "view", + "apply_patch", // search - "rg", "glob", + "rg", + "glob", // agents / task management - "task", "update_todo", "report_intent", + "task", + "update_todo", + "report_intent", // interaction "ask_user", // misc - "skill", "web_fetch", "fetch_copilot_cli_documentation", + "skill", + "web_fetch", + "fetch_copilot_cli_documentation", ]; interface SessionConfigOptions extends BaseSessionConfigOptions { @@ -31,7 +48,11 @@ interface ToolBridgeContext { logger: Logger; } -function resolveToolBridge({ tools, config, logger }: ToolBridgeContext): boolean { +function resolveToolBridge({ + tools, + config, + logger, +}: ToolBridgeContext): boolean { if (tools) { logger.debug(`Tools in request: ${String(tools.length)}`); } @@ -54,7 +75,11 @@ export function createProviderSessionConfig( baseOptions: BaseSessionConfigOptions, ctx: ProviderContext, ): SessionConfig { - const hasBridge = resolveToolBridge({ tools: ctx.tools, config: ctx.config, logger: ctx.logger }); + const hasBridge = resolveToolBridge({ + tools: ctx.tools, + config: ctx.config, + logger: ctx.logger, + }); return createSessionConfig({ ...baseOptions, config: ctx.config, @@ -88,7 +113,9 @@ export function createSessionConfig({ // Hide SDK built-ins so the model uses bridge tools (forwarded to Xcode). const excludedTools = SDK_BUILT_IN_TOOLS.filter( - (t) => !config.allowedCliTools.includes("*") && !config.allowedCliTools.includes(t), + (t) => + !config.allowedCliTools.includes("*") && + !config.allowedCliTools.includes(t), ); if (!hasToolBridge) { diff --git a/proxy-server/src/providers/shared/streaming-core.ts b/proxy-server/src/providers/shared/streaming-core.ts index 8a0093f..37e7729 100644 --- a/proxy-server/src/providers/shared/streaming-core.ts +++ b/proxy-server/src/providers/shared/streaming-core.ts @@ -1,5 +1,10 @@ import type { FastifyReply } from "fastify"; -import type { CopilotSession, Logger, Stats, CommonEventHandler } from "copilot-sdk-proxy"; +import type { + CopilotSession, + Logger, + Stats, + CommonEventHandler, +} from "copilot-sdk-proxy"; import { createCommonEventHandler } from "copilot-sdk-proxy"; import type { ToolBridgeState } from "../../tool-bridge/state.js"; import { isRecord } from "../../utils/type-guards.js"; @@ -7,7 +12,9 @@ import { BRIDGE_TOOL_PREFIX } from "../../bridge-constants.js"; // Xcode sends tool names without the bridge prefix. function stripBridgePrefix(name: string): string { - return name.startsWith(BRIDGE_TOOL_PREFIX) ? name.slice(BRIDGE_TOOL_PREFIX.length) : name; + return name.startsWith(BRIDGE_TOOL_PREFIX) + ? name.slice(BRIDGE_TOOL_PREFIX.length) + : name; } export interface StrippedToolRequest { @@ -36,7 +43,9 @@ interface SessionStreamingOptions { stats: Stats; } -export function runSessionStreaming(opts: SessionStreamingOptions): Promise { +export function runSessionStreaming( + opts: SessionStreamingOptions, +): Promise { return new StreamingHandler(opts).run(); } @@ -122,8 +131,12 @@ class StreamingHandler { : requests; return filtered.map((tr) => { - const resolved = this.state.toolCache.resolveToolName(stripBridgePrefix(tr.name)); - const args: Record = isRecord(tr.arguments) ? tr.arguments : {}; + const resolved = this.state.toolCache.resolveToolName( + stripBridgePrefix(tr.name), + ); + const args: Record = isRecord(tr.arguments) + ? tr.arguments + : {}; return { toolCallId: tr.toolCallId, name: resolved, @@ -132,7 +145,9 @@ class StreamingHandler { }); } - private onMessage(data: { toolRequests?: { toolCallId: string; name: string; arguments?: unknown }[] }): void { + private onMessage(data: { + toolRequests?: { toolCallId: string; name: string; arguments?: unknown }[]; + }): void { if (!data.toolRequests || data.toolRequests.length === 0) { const r = this.getReply(); if (r) this.common.flushDeltas(); @@ -143,7 +158,9 @@ class StreamingHandler { if (stripped.length === 0) return; for (const tr of stripped) { - this.logger.info(`Tool request: name="${tr.name}", id="${tr.toolCallId}"`); + this.logger.info( + `Tool request: name="${tr.name}", id="${tr.toolCallId}"`, + ); this.state.toolRouter.registerExpected(tr.toolCallId, tr.name); } @@ -186,7 +203,10 @@ class StreamingHandler { private setupClientDisconnect(): void { this.initialReply.raw.on("close", () => { - if (!this.sessionDone && this.state.replies.currentReply === this.initialReply) { + if ( + !this.sessionDone && + this.state.replies.currentReply === this.initialReply + ) { this.logger.info("Client disconnected, aborting session"); this.protocol.teardown(); this.protocol.reset(); diff --git a/proxy-server/src/providers/shared/streaming-orchestrator.ts b/proxy-server/src/providers/shared/streaming-orchestrator.ts index 66f52ad..3c407d2 100644 --- a/proxy-server/src/providers/shared/streaming-orchestrator.ts +++ b/proxy-server/src/providers/shared/streaming-orchestrator.ts @@ -1,5 +1,8 @@ import type { Logger } from "copilot-sdk-proxy"; -import type { ConversationManager, Conversation } from "../../conversation-manager.js"; +import type { + ConversationManager, + Conversation, +} from "../../conversation-manager.js"; interface StreamingContext { conversation: Conversation; @@ -9,7 +12,9 @@ interface StreamingContext { runStreaming: () => Promise; } -export async function orchestrateStreaming(ctx: StreamingContext): Promise { +export async function orchestrateStreaming( + ctx: StreamingContext, +): Promise { const { conversation, logger, manager, messageCount } = ctx; const { state } = conversation; diff --git a/proxy-server/src/providers/shared/user-agent-guard.ts b/proxy-server/src/providers/shared/user-agent-guard.ts index a74e540..0c13891 100644 --- a/proxy-server/src/providers/shared/user-agent-guard.ts +++ b/proxy-server/src/providers/shared/user-agent-guard.ts @@ -15,7 +15,10 @@ export function addUserAgentGuard( const ua = request.headers["user-agent"] ?? ""; if (!ua.startsWith(uaPrefix)) { logger.warn(`Rejected request from unexpected user-agent: ${ua}`); - void reply.code(403).type("application/json").send('{"error":"Forbidden"}\n'); + void reply + .code(403) + .type("application/json") + .send('{"error":"Forbidden"}\n'); return; } done(); diff --git a/proxy-server/src/settings-patcher/claude.ts b/proxy-server/src/settings-patcher/claude.ts index a347024..d6e7466 100644 --- a/proxy-server/src/settings-patcher/claude.ts +++ b/proxy-server/src/settings-patcher/claude.ts @@ -1,5 +1,12 @@ import { existsSync } from "node:fs"; -import { readFile, writeFile, rename, unlink, mkdir, copyFile } from "node:fs/promises"; +import { + readFile, + writeFile, + rename, + unlink, + mkdir, + copyFile, +} from "node:fs/promises"; import { join } from "node:path"; import { homedir } from "node:os"; import type { @@ -31,7 +38,10 @@ function isSettings(value: unknown): value is Settings { return typeof value === "object" && value !== null && !Array.isArray(value); } -async function readSettingsFile(path: string, logger?: { warn(msg: string): void }): Promise { +async function readSettingsFile( + path: string, + logger?: { warn(msg: string): void }, +): Promise { if (!existsSync(path)) return null; const content = await readFile(path, "utf-8"); let parsed: unknown; @@ -44,7 +54,9 @@ async function readSettingsFile(path: string, logger?: { warn(msg: string): void return isSettings(parsed) ? parsed : null; } -export async function detectPatchState(options: DetectOptions): Promise { +export async function detectPatchState( + options: DetectOptions, +): Promise { const { logger } = options; const p = options.paths ?? defaultSettingsPaths(); @@ -69,7 +81,9 @@ export async function detectPatchState(options: DetectOptions): Promise { +export async function patchClaudeSettings( + options: PatchOptions, +): Promise { const { logger } = options; const p = options.paths ?? defaultSettingsPaths(); @@ -82,7 +96,7 @@ export async function patchClaudeSettings(options: PatchOptions): Promise let settings: Settings = {}; try { - settings = await readSettingsFile(p.file, logger) ?? {}; + settings = (await readSettingsFile(p.file, logger)) ?? {}; } catch (err) { logger.warn(`Could not read settings.json, starting fresh: ${String(err)}`); } @@ -96,7 +110,9 @@ export async function patchClaudeSettings(options: PatchOptions): Promise await writeFile(p.file, JSON.stringify(settings, null, 2) + "\n", "utf-8"); } -export async function restoreClaudeSettings(options: RestoreOptions): Promise { +export async function restoreClaudeSettings( + options: RestoreOptions, +): Promise { const p = options.paths ?? defaultSettingsPaths(); // Best-effort: log and continue so restore completes as much as possible. diff --git a/proxy-server/src/settings-patcher/codex.ts b/proxy-server/src/settings-patcher/codex.ts index 473e370..a823e3c 100644 --- a/proxy-server/src/settings-patcher/codex.ts +++ b/proxy-server/src/settings-patcher/codex.ts @@ -10,7 +10,12 @@ import { readFile, writeFile, unlink, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { homedir } from "node:os"; import type { Logger } from "copilot-sdk-proxy"; -import type { PatchResult, CodexPatchOptions, CodexRestoreOptions, CodexDetectOptions } from "./types.js"; +import type { + PatchResult, + CodexPatchOptions, + CodexRestoreOptions, + CodexDetectOptions, +} from "./types.js"; import { extractLocalhostPort } from "./url-utils.js"; import { defaultExec, type ExecFn } from "../utils/child-process.js"; import { isRecord } from "../utils/type-guards.js"; @@ -23,8 +28,11 @@ interface EnvBackup { function isEnvBackup(value: unknown): value is EnvBackup { if (!isRecord(value)) return false; return ( - ("OPENAI_BASE_URL" in value && (typeof value.OPENAI_BASE_URL === "string" || value.OPENAI_BASE_URL === null)) && - ("OPENAI_API_KEY" in value && (typeof value.OPENAI_API_KEY === "string" || value.OPENAI_API_KEY === null)) + "OPENAI_BASE_URL" in value && + (typeof value.OPENAI_BASE_URL === "string" || + value.OPENAI_BASE_URL === null) && + "OPENAI_API_KEY" in value && + (typeof value.OPENAI_API_KEY === "string" || value.OPENAI_API_KEY === null) ); } @@ -36,7 +44,10 @@ function defaultCodexBackupPath(): string { ); } -async function launchctlGetenv(exec: ExecFn, name: string): Promise { +async function launchctlGetenv( + exec: ExecFn, + name: string, +): Promise { try { const value = (await exec("launchctl", ["getenv", name])).trim(); return value || null; @@ -46,7 +57,9 @@ async function launchctlGetenv(exec: ExecFn, name: string): Promise { +export async function detectCodexPatchState( + options: CodexDetectOptions, +): Promise { const { logger } = options; const exec = options.exec ?? defaultExec; const backupFile = options.backupFile ?? defaultCodexBackupPath(); @@ -71,7 +84,9 @@ export async function detectCodexPatchState(options: CodexDetectOptions): Promis return { patched: true }; } -export async function patchCodexSettings(options: CodexPatchOptions): Promise { +export async function patchCodexSettings( + options: CodexPatchOptions, +): Promise { const { port } = options; const exec = options.exec ?? defaultExec; const backupFile = options.backupFile ?? defaultCodexBackupPath(); @@ -86,7 +101,11 @@ export async function patchCodexSettings(options: CodexPatchOptions): Promise { } } -async function readEnvBackup(backupFile: string, logger: Logger): Promise { +async function readEnvBackup( + backupFile: string, + logger: Logger, +): Promise { if (!existsSync(backupFile)) return null; const raw = await readFile(backupFile, "utf-8"); @@ -121,7 +143,9 @@ async function readEnvBackup(backupFile: string, logger: Logger): Promise { +export async function restoreCodexSettings( + options: CodexRestoreOptions, +): Promise { const { logger } = options; const exec = options.exec ?? defaultExec; const backupFile = options.backupFile ?? defaultCodexBackupPath(); @@ -135,7 +159,11 @@ export async function restoreCodexSettings(options: CodexRestoreOptions): Promis // Best-effort: restore as many env vars as possible. try { if (backup.OPENAI_BASE_URL != null) { - await exec("launchctl", ["setenv", "OPENAI_BASE_URL", backup.OPENAI_BASE_URL]); + await exec("launchctl", [ + "setenv", + "OPENAI_BASE_URL", + backup.OPENAI_BASE_URL, + ]); } else { await exec("launchctl", ["unsetenv", "OPENAI_BASE_URL"]); } @@ -145,7 +173,11 @@ export async function restoreCodexSettings(options: CodexRestoreOptions): Promis try { if (backup.OPENAI_API_KEY != null) { - await exec("launchctl", ["setenv", "OPENAI_API_KEY", backup.OPENAI_API_KEY]); + await exec("launchctl", [ + "setenv", + "OPENAI_API_KEY", + backup.OPENAI_API_KEY, + ]); } else { await exec("launchctl", ["unsetenv", "OPENAI_API_KEY"]); } diff --git a/proxy-server/src/settings-patcher/index.ts b/proxy-server/src/settings-patcher/index.ts index 7488dfe..3cfe110 100644 --- a/proxy-server/src/settings-patcher/index.ts +++ b/proxy-server/src/settings-patcher/index.ts @@ -1,12 +1,28 @@ import type { Logger } from "copilot-sdk-proxy"; import type { ProviderName, ProviderMode } from "copilot-sdk-proxy"; import type { SettingsPatcher } from "./types.js"; -import { detectPatchState, patchClaudeSettings, restoreClaudeSettings } from "./claude.js"; -import { detectCodexPatchState, patchCodexSettings, restoreCodexSettings } from "./codex.js"; +import { + detectPatchState, + patchClaudeSettings, + restoreClaudeSettings, +} from "./claude.js"; +import { + detectCodexPatchState, + patchCodexSettings, + restoreCodexSettings, +} from "./codex.js"; export const patcherByProxy: Partial> = { - claude: { detect: detectPatchState, patch: patchClaudeSettings, restore: restoreClaudeSettings }, - codex: { detect: detectCodexPatchState, patch: patchCodexSettings, restore: restoreCodexSettings }, + claude: { + detect: detectPatchState, + patch: patchClaudeSettings, + restore: restoreClaudeSettings, + }, + codex: { + detect: detectCodexPatchState, + patch: patchCodexSettings, + restore: restoreCodexSettings, + }, }; function resolvePatchers(proxy: ProviderMode | null) { @@ -15,14 +31,21 @@ function resolvePatchers(proxy: ProviderMode | null) { return p ? [p] : []; } -export async function patchSettings(proxy: ProviderMode | null, port: number, logger: Logger): Promise { +export async function patchSettings( + proxy: ProviderMode | null, + port: number, + logger: Logger, +): Promise { for (const patcher of resolvePatchers(proxy)) { await patcher.patch({ port, logger }); } } // Best-effort: continue restoring other providers if one fails. -export async function restoreSettings(proxy: ProviderMode | null, logger: Logger): Promise { +export async function restoreSettings( + proxy: ProviderMode | null, + logger: Logger, +): Promise { for (const patcher of resolvePatchers(proxy)) { try { await patcher.restore({ logger }); diff --git a/proxy-server/src/shutdown.ts b/proxy-server/src/shutdown.ts index 837441a..f286a83 100644 --- a/proxy-server/src/shutdown.ts +++ b/proxy-server/src/shutdown.ts @@ -19,7 +19,16 @@ interface ShutdownContext { const STOP_TIMEOUT_MS = 3000; export function registerShutdownHandlers(ctx: ShutdownContext): void { - const { app, service, logger, stats, shouldPatch, proxyMode, quiet, idleTimeoutMinutes } = ctx; + const { + app, + service, + logger, + stats, + shouldPatch, + proxyMode, + quiet, + idleTimeoutMinutes, + } = ctx; const shutdown = async (signal: string) => { process.on("uncaughtException", (err: NodeJS.ErrnoException) => { @@ -62,8 +71,12 @@ export function registerShutdownHandlers(ctx: ShutdownContext): void { process.exit(1); }); }; - process.on("SIGINT", () => { onSignal("SIGINT"); }); - process.on("SIGTERM", () => { onSignal("SIGTERM"); }); + process.on("SIGINT", () => { + onSignal("SIGINT"); + }); + process.on("SIGTERM", () => { + onSignal("SIGTERM"); + }); if (idleTimeoutMinutes > 0) { const idleMs = idleTimeoutMinutes * 60_000; @@ -71,7 +84,9 @@ export function registerShutdownHandlers(ctx: ShutdownContext): void { const timer = setInterval(() => { if (Date.now() - ctx.lastActivityRef() >= idleMs) { clearInterval(timer); - logger.info(`Idle for ${String(idleTimeoutMinutes)} minute(s), shutting down`); + logger.info( + `Idle for ${String(idleTimeoutMinutes)} minute(s), shutting down`, + ); onSignal("idle-timeout"); } }, checkInterval); diff --git a/proxy-server/src/startup.ts b/proxy-server/src/startup.ts index 6f773c7..936fcf6 100644 --- a/proxy-server/src/startup.ts +++ b/proxy-server/src/startup.ts @@ -3,11 +3,17 @@ import { createServer, Logger, Stats, - bold, dim, createSpinner, + bold, + dim, + createSpinner, type LogLevel, } from "copilot-sdk-proxy"; import type { AppContext } from "./context.js"; -import { loadConfig, loadAllProviderConfigs, resolveConfigPath } from "./config.js"; +import { + loadConfig, + loadAllProviderConfigs, + resolveConfigPath, +} from "./config.js"; import type { ServerConfig } from "./config-schema.js"; import type { AllProviderConfigs } from "./config.js"; import type { ProviderMode } from "copilot-sdk-proxy"; @@ -55,9 +61,13 @@ function parseOptions(options: StartOptions): ParsedOptions { const logLevel = parseLogLevel(options.logLevel); const logger = new Logger(logLevel); const port = parsePort(options.port); - const proxyMode: ProviderMode = options.proxy ? parseProviderMode(options.proxy) : "auto"; + const proxyMode: ProviderMode = options.proxy + ? parseProviderMode(options.proxy) + : "auto"; - const idleTimeoutMinutes = options.idleTimeout ? parseIdleTimeout(options.idleTimeout) : 0; + const idleTimeoutMinutes = options.idleTimeout + ? parseIdleTimeout(options.idleTimeout) + : 0; const launchdMode = options.launchd === true; const isAuto = proxyMode === "auto"; const shouldPatch = isAuto @@ -67,19 +77,38 @@ function parseOptions(options: StartOptions): ParsedOptions { validateAutoPatch(proxyMode, options.autoPatch === true); } - const configPath = options.config ?? resolveConfigPath(options.cwd, process.cwd(), options.defaultConfigPath); + const configPath = + options.config ?? + resolveConfigPath(options.cwd, process.cwd(), options.defaultConfigPath); const quiet = logLevel === "none" || launchdMode; - return { port, proxyMode, logLevel, logger, quiet, launchdMode, shouldPatch, idleTimeoutMinutes, configPath, cwd: options.cwd }; + return { + port, + proxyMode, + logLevel, + logger, + quiet, + launchdMode, + shouldPatch, + idleTimeoutMinutes, + configPath, + cwd: options.cwd, + }; } -async function loadProvider( - parsed: ParsedOptions, -): Promise<{ provider: Provider; config: ServerConfig; allConfigs?: AllProviderConfigs }> { +async function loadProvider(parsed: ParsedOptions): Promise<{ + provider: Provider; + config: ServerConfig; + allConfigs?: AllProviderConfigs; +}> { const { proxyMode, configPath, logger } = parsed; if (proxyMode === "auto") { const allConfigs = await loadAllProviderConfigs(configPath, logger); - return { provider: createAutoProvider(allConfigs.providers), config: allConfigs.shared, allConfigs }; + return { + provider: createAutoProvider(allConfigs.providers), + config: allConfigs.shared, + allConfigs, + }; } const config = await loadConfig(configPath, logger, proxyMode); return { provider: providers[proxyMode], config }; @@ -99,7 +128,9 @@ async function initializeService( console.log(); } - const bootSpinner = quiet ? null : createSpinner("Initialising Copilot SDK..."); + const bootSpinner = quiet + ? null + : createSpinner("Initialising Copilot SDK..."); await service.start(); bootSpinner?.succeed("Copilot SDK initialised"); @@ -114,7 +145,9 @@ async function initializeService( } const login = auth.login ?? "unknown"; const authType = auth.authType ?? "unknown"; - authSpinner?.succeed(`Authenticated as ${bold(login)} ${dim(`(${authType})`)}`); + authSpinner?.succeed( + `Authenticated as ${bold(login)} ${dim(`(${authType})`)}`, + ); return service; } @@ -125,7 +158,9 @@ async function bindAndListen( ): Promise { const { port, quiet, launchdMode, logger } = parsed; - const listenSpinner = quiet ? null : createSpinner(`Starting server on port ${String(port)}...`); + const listenSpinner = quiet + ? null + : createSpinner(`Starting server on port ${String(port)}...`); const prevPinoLevel = app.log.level; app.log.level = "silent"; @@ -136,14 +171,18 @@ async function bindAndListen( throw new Error("launch_activate_socket returned no file descriptors"); } await app.listen({ fd }); - logger.info(`Listening via launchd socket activation (fd ${String(fd)}, port ${String(port)})`); + logger.info( + `Listening via launchd socket activation (fd ${String(fd)}, port ${String(port)})`, + ); } else { // 127.0.0.1 binding is the auth boundary. Only local processes can reach the server. await app.listen({ port, host: "127.0.0.1" }); } app.log.level = prevPinoLevel; - listenSpinner?.succeed(`Listening on ${bold(`http://localhost:${String(port)}`)}`); + listenSpinner?.succeed( + `Listening on ${bold(`http://localhost:${String(port)}`)}`, + ); } function printBanner( @@ -168,7 +207,11 @@ function printBanner( logger.debug(`Config loaded from ${configPath}`); const mcpCount = allConfigs - ? new Set(Object.values(allConfigs.providers).flatMap((c) => Object.keys(c.mcpServers))).size + ? new Set( + Object.values(allConfigs.providers).flatMap((c) => + Object.keys(c.mcpServers), + ), + ).size : Object.keys(config.mcpServers).length; const cliToolsSummary = config.allowedCliTools.includes("*") ? "all CLI tools allowed" @@ -190,7 +233,9 @@ export async function startServer(options: StartOptions): Promise { patchSpinner?.succeed("Settings patched"); } catch (err) { patchSpinner?.fail(`Failed to patch settings: ${String(err)}`); - logger.warn(`Settings patching failed (continuing without patch): ${String(err)}`); + logger.warn( + `Settings patching failed (continuing without patch): ${String(err)}`, + ); } } @@ -207,8 +252,13 @@ export async function startServer(options: StartOptions): Promise { printBanner(parsed, provider, service, config, allConfigs); registerShutdownHandlers({ - app, service, logger, stats, - shouldPatch, proxyMode, quiet, + app, + service, + logger, + stats, + shouldPatch, + proxyMode, + quiet, lastActivityRef: () => lastActivity, idleTimeoutMinutes: parsed.idleTimeoutMinutes, }); diff --git a/proxy-server/src/tool-bridge/index.ts b/proxy-server/src/tool-bridge/index.ts index 72cd9ba..821addc 100644 --- a/proxy-server/src/tool-bridge/index.ts +++ b/proxy-server/src/tool-bridge/index.ts @@ -5,7 +5,11 @@ import { registerRoutes } from "./routes.js"; export { BRIDGE_SERVER_NAME, BRIDGE_TOOL_PREFIX } from "../bridge-constants.js"; -export function registerToolBridge(app: FastifyInstance, logger: Logger, toolBridgeTimeoutMs = 0): ConversationManager { +export function registerToolBridge( + app: FastifyInstance, + logger: Logger, + toolBridgeTimeoutMs = 0, +): ConversationManager { const manager = new ConversationManager(logger, toolBridgeTimeoutMs); registerRoutes(app, manager, logger); return manager; diff --git a/proxy-server/src/tool-bridge/routes.ts b/proxy-server/src/tool-bridge/routes.ts index a3e77b5..7458a07 100644 --- a/proxy-server/src/tool-bridge/routes.ts +++ b/proxy-server/src/tool-bridge/routes.ts @@ -22,7 +22,11 @@ function jsonRpcResult(id: number | string, result: unknown) { return { jsonrpc: "2.0" as const, id, result }; } -function jsonRpcError(id: number | string | null, code: number, message: string) { +function jsonRpcError( + id: number | string | null, + code: number, + message: string, +) { return { jsonrpc: "2.0" as const, id, error: { code, message } }; } @@ -72,14 +76,20 @@ 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(`MCP ${convId}: notification method="${msg.method}", ignoring`); + logger.debug( + `MCP ${convId}: notification method="${msg.method}", ignoring`, + ); return reply.status(202).send(); } @@ -99,14 +109,22 @@ export function registerRoutes( 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")); + 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`); + logger.debug( + `MCP ${convId} tools/list: ${String(tools.length)} tools`, + ); return reply.send(jsonRpcResult(id, { tools })); } @@ -114,23 +132,37 @@ export function registerRoutes( 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")); + 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 : {}; + const args: Record = isRecord(rawArgs) + ? rawArgs + : {}; if (!name) { - return reply.send(jsonRpcError(id, JSONRPC_INVALID_PARAMS, "Missing tool 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)}`); + 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)}`); + logger.info( + `MCP ${convId} tools/call: name="${name}", args=${JSON.stringify(args)}`, + ); } try { @@ -148,12 +180,20 @@ export function registerRoutes( 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)); + return reply.send( + jsonRpcError(id, JSONRPC_INTERNAL_ERROR, message), + ); } } default: - return reply.send(jsonRpcError(id, JSONRPC_METHOD_NOT_FOUND, `Method not found: ${method}`)); + return reply.send( + jsonRpcError( + id, + JSONRPC_METHOD_NOT_FOUND, + `Method not found: ${method}`, + ), + ); } }, ); diff --git a/proxy-server/src/tool-bridge/tool-cache.ts b/proxy-server/src/tool-bridge/tool-cache.ts index 87281c2..b668f47 100644 --- a/proxy-server/src/tool-bridge/tool-cache.ts +++ b/proxy-server/src/tool-bridge/tool-cache.ts @@ -59,7 +59,11 @@ export class ToolCache { args: Record, ): Record { const tool = this.cachedTools.find((t) => t.name === toolName); - const props = (tool?.input_schema as { properties?: Record } | undefined)?.properties; + const props = ( + tool?.input_schema as + | { properties?: Record } + | undefined + )?.properties; if (!props) return args; const schemaKeys = new Set(Object.keys(props)); @@ -88,7 +92,10 @@ export class ToolCache { return key; } - private resolveValue(value: unknown, schemaProp: SchemaProperty | undefined): unknown { + private resolveValue( + value: unknown, + schemaProp: SchemaProperty | undefined, + ): unknown { if (typeof value !== "string" || !schemaProp?.enum) return value; if (schemaProp.enum.includes(value)) return value; diff --git a/proxy-server/src/tool-bridge/tool-router.ts b/proxy-server/src/tool-bridge/tool-router.ts index c3954d9..3c04bf2 100644 --- a/proxy-server/src/tool-bridge/tool-router.ts +++ b/proxy-server/src/tool-bridge/tool-router.ts @@ -20,7 +20,10 @@ export class ToolRouter { } hasPendingToolCall(toolCallId: string): boolean { - return this.pendingByCallId.has(toolCallId) || this.expectedByCallId.has(toolCallId); + return ( + this.pendingByCallId.has(toolCallId) || + this.expectedByCallId.has(toolCallId) + ); } hasExpectedTool(name: string): boolean { @@ -51,7 +54,9 @@ export class ToolRouter { const toolCallId = queue.shift(); if (queue.length === 0) this.expectedByName.delete(name); if (!toolCallId) { - reject(new Error(`Internal: expected toolCallId was falsy for "${name}"`)); + reject( + new Error(`Internal: expected toolCallId was falsy for "${name}"`), + ); return; } this.expectedByCallId.delete(toolCallId); @@ -103,13 +108,23 @@ export class ToolRouter { resolve: (result: string) => void, reject: (err: Error) => void, ): void { - const timeout = this.timeoutMs > 0 - ? setTimeout(() => { - this.pendingByCallId.delete(toolCallId); - reject(new Error(`Tool call ${toolCallId} timed out after ${String(this.timeoutMs)}ms`)); - }, this.timeoutMs) - : undefined; + const timeout = + this.timeoutMs > 0 + ? setTimeout(() => { + this.pendingByCallId.delete(toolCallId); + reject( + new Error( + `Tool call ${toolCallId} timed out after ${String(this.timeoutMs)}ms`, + ), + ); + }, this.timeoutMs) + : undefined; - this.pendingByCallId.set(toolCallId, { toolCallId, resolve, reject, timeout }); + this.pendingByCallId.set(toolCallId, { + toolCallId, + resolve, + reject, + timeout, + }); } } diff --git a/proxy-server/src/utils/child-process.ts b/proxy-server/src/utils/child-process.ts index cdc2bf1..dd8a0f3 100644 --- a/proxy-server/src/utils/child-process.ts +++ b/proxy-server/src/utils/child-process.ts @@ -5,7 +5,10 @@ const execFileAsync = promisify(execFileCb); export type ExecFn = (cmd: string, args: string[]) => Promise; -export async function defaultExec(cmd: string, args: string[]): Promise { +export async function defaultExec( + cmd: string, + args: string[], +): Promise { const { stdout } = await execFileAsync(cmd, args); return stdout; } diff --git a/proxy-server/src/utils/type-guards.ts b/proxy-server/src/utils/type-guards.ts index a304f7d..c3ae83e 100644 --- a/proxy-server/src/utils/type-guards.ts +++ b/proxy-server/src/utils/type-guards.ts @@ -3,5 +3,9 @@ export function isRecord(value: unknown): value is Record { } export function isErrnoException(err: unknown): err is NodeJS.ErrnoException { - return err instanceof Error && "code" in err && typeof (err as { code: unknown }).code === "string"; + return ( + err instanceof Error && + "code" in err && + typeof (err as { code: unknown }).code === "string" + ); } diff --git a/proxy-server/test/cli-validators.test.ts b/proxy-server/test/cli-validators.test.ts index 51b70e7..ace7ede 100644 --- a/proxy-server/test/cli-validators.test.ts +++ b/proxy-server/test/cli-validators.test.ts @@ -26,34 +26,47 @@ describe("parseProvider", () => { describe("validateAutoPatch", () => { it("allows auto-patch with claude", () => { - expect(() => { validateAutoPatch("claude", true); }).not.toThrow(); + expect(() => { + validateAutoPatch("claude", true); + }).not.toThrow(); }); it("allows auto-patch with codex", () => { - expect(() => { validateAutoPatch("codex", true); }).not.toThrow(); + expect(() => { + validateAutoPatch("codex", true); + }).not.toThrow(); }); it("throws when auto-patch is used with openai", () => { - expect(() => { validateAutoPatch("openai", true); }).toThrow( - "--auto-patch is only supported for: claude, codex", - ); + expect(() => { + validateAutoPatch("openai", true); + }).toThrow("--auto-patch is only supported for: claude, codex"); }); it("allows no auto-patch with openai", () => { - expect(() => { validateAutoPatch("openai", false); }).not.toThrow(); + expect(() => { + validateAutoPatch("openai", false); + }).not.toThrow(); }); it("allows no auto-patch with claude", () => { - expect(() => { validateAutoPatch("claude", false); }).not.toThrow(); + expect(() => { + validateAutoPatch("claude", false); + }).not.toThrow(); }); it("allows no auto-patch with codex", () => { - expect(() => { validateAutoPatch("codex", false); }).not.toThrow(); + expect(() => { + validateAutoPatch("codex", false); + }).not.toThrow(); }); }); describe("subcommand option pass-through", () => { - function buildProgram(): { program: Command; captured: Record> } { + function buildProgram(): { + program: Command; + captured: Record>; + } { const captured: Record> = {}; const program = new Command() @@ -61,28 +74,44 @@ describe("subcommand option pass-through", () => { .passThroughOptions() .option("--proxy ", "API format", "openai") .option("--idle-timeout ", "idle timeout", "0") - .action((opts: Record) => { captured["main"] = opts; }); + .action((opts: Record) => { + captured["main"] = opts; + }); program .command("install-agent") .option("--proxy ", "API format", "openai") .option("--idle-timeout ", "idle timeout", "60") .option("--auto-patch") - .action((opts: Record) => { captured["install-agent"] = opts; }); + .action((opts: Record) => { + captured["install-agent"] = opts; + }); return { program, captured }; } it("routes --proxy to the subcommand, not the parent", async () => { const { program, captured } = buildProgram(); - await program.parseAsync(["node", "test", "install-agent", "--proxy", "claude"]); + await program.parseAsync([ + "node", + "test", + "install-agent", + "--proxy", + "claude", + ]); expect(captured["install-agent"]!.proxy).toBe("claude"); }); it("routes --idle-timeout to the subcommand", async () => { const { program, captured } = buildProgram(); - await program.parseAsync(["node", "test", "install-agent", "--idle-timeout", "30"]); + await program.parseAsync([ + "node", + "test", + "install-agent", + "--idle-timeout", + "30", + ]); expect(captured["install-agent"]!.idleTimeout).toBe("30"); }); diff --git a/proxy-server/test/config-schema.test.ts b/proxy-server/test/config-schema.test.ts index fb3afbb..1376b45 100644 --- a/proxy-server/test/config-schema.test.ts +++ b/proxy-server/test/config-schema.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { ServerConfigSchema, BYTES_PER_MIB, DEFAULT_CONFIG } from "../src/config-schema.js"; +import { + ServerConfigSchema, + BYTES_PER_MIB, + DEFAULT_CONFIG, +} from "../src/config-schema.js"; describe("ServerConfigSchema", () => { it("accepts empty object with defaults", () => { diff --git a/proxy-server/test/config.test.ts b/proxy-server/test/config.test.ts index c77f4a1..f791679 100644 --- a/proxy-server/test/config.test.ts +++ b/proxy-server/test/config.test.ts @@ -26,7 +26,11 @@ function writeConfig(filename: string, content: string): string { describe("loadConfig", () => { it("returns defaults when config file does not exist", async () => { - const config = await loadConfig("/nonexistent/config.json5", logger, "openai"); + const config = await loadConfig( + "/nonexistent/config.json5", + logger, + "openai", + ); expect(config.toolBridge).toBe(false); expect(config.mcpServers).toEqual({}); expect(config.allowedCliTools).toEqual([]); @@ -124,10 +128,7 @@ describe("loadConfig", () => { }); it("loads reasoningEffort", async () => { - const path = writeConfig( - "reason.json5", - `{ reasoningEffort: "high" }`, - ); + const path = writeConfig("reason.json5", `{ reasoningEffort: "high" }`); const config = await loadConfig(path, logger, "openai"); expect(config.reasoningEffort).toBe("high"); }); @@ -216,7 +217,9 @@ describe("loadConfig", () => { describe("config validation", () => { it("rejects invalid bodyLimit (negative)", async () => { const path = writeConfig("bad.json5", `{ bodyLimit: -1 }`); - await expect(loadConfig(path, logger, "openai")).rejects.toThrow(/bodyLimit.*>0/i); + await expect(loadConfig(path, logger, "openai")).rejects.toThrow( + /bodyLimit.*>0/i, + ); }); it("rejects invalid bodyLimit (too large)", async () => { @@ -225,11 +228,10 @@ describe("config validation", () => { }); it("rejects invalid reasoningEffort", async () => { - const path = writeConfig( - "bad.json5", - `{ reasoningEffort: "invalid" }`, + const path = writeConfig("bad.json5", `{ reasoningEffort: "invalid" }`); + await expect(loadConfig(path, logger, "openai")).rejects.toThrow( + /reasoningEffort/i, ); - await expect(loadConfig(path, logger, "openai")).rejects.toThrow(/reasoningEffort/i); }); it("rejects non-array allowedCliTools", async () => { @@ -253,7 +255,9 @@ describe("config validation", () => { "bad.json5", `{ autoApprovePermissions: ["invalid"] }`, ); - await expect(loadConfig(path, logger, "openai")).rejects.toThrow(/invalid/i); + await expect(loadConfig(path, logger, "openai")).rejects.toThrow( + /invalid/i, + ); }); it("rejects invalid MCP server (missing command)", async () => { @@ -261,7 +265,9 @@ describe("config validation", () => { "bad.json5", `{ openai: { mcpServers: { test: { args: [] } } } }`, ); - await expect(loadConfig(path, logger, "openai")).rejects.toThrow(/Invalid/i); + await expect(loadConfig(path, logger, "openai")).rejects.toThrow( + /Invalid/i, + ); }); it("rejects invalid MCP server URL", async () => { @@ -279,11 +285,10 @@ describe("config validation", () => { }); it("rejects invalid toolBridge (non-boolean)", async () => { - const path = writeConfig( - "bad.json5", - `{ claude: { toolBridge: "yes" } }`, + const path = writeConfig("bad.json5", `{ claude: { toolBridge: "yes" } }`); + await expect(loadConfig(path, logger, "claude")).rejects.toThrow( + /Invalid/i, ); - await expect(loadConfig(path, logger, "claude")).rejects.toThrow(/Invalid/i); }); it("uses defaults for missing optional fields", async () => { @@ -302,7 +307,11 @@ describe("resolveConfigPath", () => { const projectDir = mkdtempSync(join(tmpdir(), "project-")); writeFileSync(join(projectDir, "config.json5"), "{}"); writeFileSync(join(tempDir, "config.json5"), "{}"); - const result = resolveConfigPath(projectDir, tempDir, "/fallback/config.json5"); + const result = resolveConfigPath( + projectDir, + tempDir, + "/fallback/config.json5", + ); expect(result).toBe(join(projectDir, "config.json5")); rmSync(projectDir, { recursive: true, force: true }); }); @@ -310,19 +319,31 @@ describe("resolveConfigPath", () => { it("falls back to process cwd when project cwd has no config", () => { const projectDir = mkdtempSync(join(tmpdir(), "project-")); writeFileSync(join(tempDir, "config.json5"), "{}"); - const result = resolveConfigPath(projectDir, tempDir, "/fallback/config.json5"); + const result = resolveConfigPath( + projectDir, + tempDir, + "/fallback/config.json5", + ); expect(result).toBe(join(tempDir, "config.json5")); rmSync(projectDir, { recursive: true, force: true }); }); it("falls back to process cwd when project cwd is undefined", () => { writeFileSync(join(tempDir, "config.json5"), "{}"); - const result = resolveConfigPath(undefined, tempDir, "/fallback/config.json5"); + const result = resolveConfigPath( + undefined, + tempDir, + "/fallback/config.json5", + ); expect(result).toBe(join(tempDir, "config.json5")); }); it("returns default path when neither cwd has config.json5", () => { - const result = resolveConfigPath(undefined, tempDir, "/fallback/config.json5"); + const result = resolveConfigPath( + undefined, + tempDir, + "/fallback/config.json5", + ); expect(result).toBe("/fallback/config.json5"); }); }); diff --git a/proxy-server/test/conversation-manager.test.ts b/proxy-server/test/conversation-manager.test.ts index 8e559fd..8b5f510 100644 --- a/proxy-server/test/conversation-manager.test.ts +++ b/proxy-server/test/conversation-manager.test.ts @@ -96,7 +96,11 @@ describe("ConversationManager", () => { const conv = manager.create(); conv.state.toolRouter.registerExpected("tc-123", "Read"); - conv.state.toolRouter.registerMCPRequest("Read", () => {}, () => {}); + conv.state.toolRouter.registerMCPRequest( + "Read", + () => {}, + () => {}, + ); expect(manager.findByContinuationIds(["tc-123"])).toBe(conv); }); @@ -223,15 +227,31 @@ describe("ConversationManager", () => { 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", () => {}, () => {}); + primary.state.toolRouter.registerMCPRequest( + "Task", + (r) => { + taskResolve = () => r; + }, + (e) => { + taskReject = () => e; + }, + ); + primary.state.toolRouter.registerMCPRequest( + "WebFetch", + () => {}, + () => {}, + ); const { conversation: subagent, isReuse } = manager.findForNewRequest(); expect(isReuse).toBe(false); expect(subagent).not.toBe(primary); - expect(primary.state.toolRouter.hasPendingToolCall("toolu_task")).toBe(true); - expect(primary.state.toolRouter.hasPendingToolCall("toolu_fetch")).toBe(true); + expect(primary.state.toolRouter.hasPendingToolCall("toolu_task")).toBe( + true, + ); + expect(primary.state.toolRouter.hasPendingToolCall("toolu_fetch")).toBe( + true, + ); expect(manager.findByContinuationIds(["toolu_task"])).toBe(primary); void taskResolve; @@ -246,7 +266,11 @@ describe("ConversationManager", () => { primary.state.session.markSessionActive(); primary.state.toolRouter.registerExpected("toolu_1", "Read"); primary.state.session.markSessionInactive(); - primary.state.toolRouter.registerMCPRequest("Read", () => {}, () => {}); + primary.state.toolRouter.registerMCPRequest( + "Read", + () => {}, + () => {}, + ); expect(manager.findForNewRequest().isReuse).toBe(false); @@ -335,7 +359,11 @@ describe("ConversationManager", () => { primary.state.session.markSessionActive(); primary.state.toolRouter.registerExpected("toolu_01abc", "Task"); - primary.state.toolRouter.registerMCPRequest("Task", () => {}, () => {}); + primary.state.toolRouter.registerMCPRequest( + "Task", + () => {}, + () => {}, + ); primary.state.session.markSessionInactive(); const { conversation, isReuse } = manager.findForNewRequest(); diff --git a/proxy-server/test/handlers/claude-session-reuse.test.ts b/proxy-server/test/handlers/claude-session-reuse.test.ts index 47a26e4..b6636fb 100644 --- a/proxy-server/test/handlers/claude-session-reuse.test.ts +++ b/proxy-server/test/handlers/claude-session-reuse.test.ts @@ -18,7 +18,9 @@ const config: ServerConfig = { autoApprovePermissions: true, }; -const claudeCliHeaders = { "user-agent": "claude-cli/2.1.14 (external, sdk-cli)" }; +const claudeCliHeaders = { + "user-agent": "claude-cli/2.1.14 (external, sdk-cli)", +}; function makePayload(model = "claude-sonnet-4-20250514") { return { @@ -102,8 +104,14 @@ describe("Concurrent request handling", () => { expect(createSessionSpy).toHaveBeenCalledTimes(2); - const config1 = createSessionSpy.mock.calls[0]?.[0] as Record; - const config2 = createSessionSpy.mock.calls[1]?.[0] as Record; + const config1 = createSessionSpy.mock.calls[0]?.[0] as Record< + string, + unknown + >; + const config2 = createSessionSpy.mock.calls[1]?.[0] as Record< + string, + unknown + >; expect(config1).toBeDefined(); expect(config2).toBeDefined(); }); diff --git a/proxy-server/test/handlers/openai-session-reuse.test.ts b/proxy-server/test/handlers/openai-session-reuse.test.ts index e6c7d93..d1ce39d 100644 --- a/proxy-server/test/handlers/openai-session-reuse.test.ts +++ b/proxy-server/test/handlers/openai-session-reuse.test.ts @@ -18,7 +18,9 @@ const config: ServerConfig = { autoApprovePermissions: true, }; -const xcodeHeaders = { "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0" }; +const xcodeHeaders = { + "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0", +}; function makePayload(model = "copilot-chat") { return { @@ -51,7 +53,10 @@ describe("OpenAI completions session reuse", () => { const mockService = { cwd: "/test", listModels: vi.fn().mockResolvedValue([ - { id: "copilot-chat", capabilities: { supports: { reasoningEffort: false } } }, + { + id: "copilot-chat", + capabilities: { supports: { reasoningEffort: false } }, + }, ]), createSession: createSessionSpy, }; diff --git a/proxy-server/test/handlers/responses.test.ts b/proxy-server/test/handlers/responses.test.ts index 1f12e0f..35f6fbf 100644 --- a/proxy-server/test/handlers/responses.test.ts +++ b/proxy-server/test/handlers/responses.test.ts @@ -8,7 +8,8 @@ import type { FastifyInstance } from "fastify"; const logger = new Logger("none"); const codexHeaders = { - "user-agent": "Xcode/0.87.0 (Mac OS 26.2.0; arm64) unknown (Xcode; 26.3 (17C518))", + "user-agent": + "Xcode/0.87.0 (Mac OS 26.2.0; arm64) unknown (Xcode; 26.3 (17C518))", }; const baseConfig: ServerConfig = { @@ -22,11 +23,17 @@ const baseConfig: ServerConfig = { autoApprovePermissions: ["read", "mcp"], }; -function makeMockSession(events: Array<{ type: string; data: unknown }> = [{ type: "session.idle", data: {} }]) { +function makeMockSession( + events: Array<{ type: string; data: unknown }> = [ + { type: "session.idle", data: {} }, + ], +) { return { on: (callback: (event: { type: string; data: unknown }) => void) => { for (const event of events) { - queueMicrotask(() => { callback(event); }); + queueMicrotask(() => { + callback(event); + }); } return () => {}; }, @@ -104,7 +111,9 @@ describe("Responses handler — session creation failure", () => { }, }, ]), - createSession: vi.fn().mockRejectedValue(new Error("SDK connection failed")), + createSession: vi + .fn() + .mockRejectedValue(new Error("SDK connection failed")), }; const ctx: AppContext = { @@ -156,11 +165,13 @@ describe("Responses handler — session error event", () => { }, }, ]), - createSession: vi.fn().mockResolvedValue( - makeMockSession([ - { type: "session.error", data: { message: "Rate limit exceeded" } }, - ]), - ), + createSession: vi + .fn() + .mockResolvedValue( + makeMockSession([ + { type: "session.error", data: { message: "Rate limit exceeded" } }, + ]), + ), }; const ctx: AppContext = { diff --git a/proxy-server/test/handlers/session-config.test.ts b/proxy-server/test/handlers/session-config.test.ts index daa6b91..afb3b7d 100644 --- a/proxy-server/test/handlers/session-config.test.ts +++ b/proxy-server/test/handlers/session-config.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect } from "vitest"; -import { createSessionConfig, createProviderSessionConfig } from "../../src/providers/shared/session-config.js"; +import { + createSessionConfig, + createProviderSessionConfig, +} from "../../src/providers/shared/session-config.js"; import { Logger } from "copilot-sdk-proxy"; -import { BYTES_PER_MIB, type ServerConfig, type MCPLocalServer } from "../../src/config-schema.js"; +import { + BYTES_PER_MIB, + type ServerConfig, + type MCPLocalServer, +} from "../../src/config-schema.js"; import type { PermissionRequest } from "copilot-sdk-proxy"; const baseConfig: ServerConfig = { @@ -97,7 +104,13 @@ describe("createSessionConfig", () => { supportsReasoningEffort: false, }); expect(config.mcpServers).toEqual({ - test: { type: "stdio", command: "node", args: ["server.js"], allowedTools: ["tool1"], tools: ["*"] }, + test: { + type: "stdio", + command: "node", + args: ["server.js"], + allowedTools: ["tool1"], + tools: ["*"], + }, }); }); @@ -146,7 +159,10 @@ describe("permission callbacks", () => { config: makeConfig({ autoApprovePermissions: true }), supportsReasoningEffort: false, }); - const result = await config.onPermissionRequest(permissionRequest("shell"), invocation); + const result = await config.onPermissionRequest( + permissionRequest("shell"), + invocation, + ); expect(result).toEqual({ kind: "approved" }); }); @@ -158,7 +174,10 @@ describe("permission callbacks", () => { config: makeConfig({ autoApprovePermissions: false }), supportsReasoningEffort: false, }); - const result = await config.onPermissionRequest(permissionRequest("read"), invocation); + const result = await config.onPermissionRequest( + permissionRequest("read"), + invocation, + ); expect(result).toEqual({ kind: "denied-by-rules", rules: [] }); }); @@ -170,7 +189,10 @@ describe("permission callbacks", () => { config: makeConfig({ autoApprovePermissions: ["read", "write"] }), supportsReasoningEffort: false, }); - const result = await config.onPermissionRequest(permissionRequest("read"), invocation); + const result = await config.onPermissionRequest( + permissionRequest("read"), + invocation, + ); expect(result).toEqual({ kind: "approved" }); }); @@ -182,7 +204,10 @@ describe("permission callbacks", () => { config: makeConfig({ autoApprovePermissions: ["read"] }), supportsReasoningEffort: false, }); - const result = await config.onPermissionRequest(permissionRequest("shell"), invocation); + const result = await config.onPermissionRequest( + permissionRequest("shell"), + invocation, + ); expect(result).toEqual({ kind: "denied-by-rules", rules: [] }); }); }); @@ -196,7 +221,10 @@ describe("tool filtering", () => { config: makeConfig({ allowedCliTools: [], mcpServers: {} }), supportsReasoningEffort: false, }); - const result = await getOnPreToolUse(config)(toolUseInput("anything"), invocation); + const result = await getOnPreToolUse(config)( + toolUseInput("anything"), + invocation, + ); expect(result).toEqual({ permissionDecision: "deny" }); }); @@ -208,9 +236,15 @@ describe("tool filtering", () => { config: makeConfig({ allowedCliTools: ["glob", "grep"] }), supportsReasoningEffort: false, }); - const allowed = await getOnPreToolUse(config)(toolUseInput("glob"), invocation); + const allowed = await getOnPreToolUse(config)( + toolUseInput("glob"), + invocation, + ); expect(allowed).toEqual({ permissionDecision: "allow" }); - const denied = await getOnPreToolUse(config)(toolUseInput("bash"), invocation); + const denied = await getOnPreToolUse(config)( + toolUseInput("bash"), + invocation, + ); expect(denied).toEqual({ permissionDecision: "deny" }); }); @@ -227,9 +261,15 @@ describe("tool filtering", () => { }), supportsReasoningEffort: false, }); - const allowed = await getOnPreToolUse(config)(toolUseInput("XcodeBuild"), invocation); + const allowed = await getOnPreToolUse(config)( + toolUseInput("XcodeBuild"), + invocation, + ); expect(allowed).toEqual({ permissionDecision: "allow" }); - const denied = await getOnPreToolUse(config)(toolUseInput("XcodeTest"), invocation); + const denied = await getOnPreToolUse(config)( + toolUseInput("XcodeTest"), + invocation, + ); expect(denied).toEqual({ permissionDecision: "deny" }); }); @@ -241,7 +281,10 @@ describe("tool filtering", () => { config: makeConfig({ allowedCliTools: ["*"] }), supportsReasoningEffort: false, }); - const result = await getOnPreToolUse(config)(toolUseInput("anything"), invocation); + const result = await getOnPreToolUse(config)( + toolUseInput("anything"), + invocation, + ); expect(result).toEqual({ permissionDecision: "allow" }); }); @@ -258,7 +301,10 @@ describe("tool filtering", () => { }), supportsReasoningEffort: false, }); - const result = await getOnPreToolUse(config)(toolUseInput("anything"), invocation); + const result = await getOnPreToolUse(config)( + toolUseInput("anything"), + invocation, + ); expect(result).toEqual({ permissionDecision: "allow" }); }); @@ -276,13 +322,25 @@ describe("tool filtering", () => { }), supportsReasoningEffort: false, }); - const cliAllowed = await getOnPreToolUse(config)(toolUseInput("glob"), invocation); + const cliAllowed = await getOnPreToolUse(config)( + toolUseInput("glob"), + invocation, + ); expect(cliAllowed).toEqual({ permissionDecision: "allow" }); - const mcp1Allowed = await getOnPreToolUse(config)(toolUseInput("XcodeBuild"), invocation); + const mcp1Allowed = await getOnPreToolUse(config)( + toolUseInput("XcodeBuild"), + invocation, + ); expect(mcp1Allowed).toEqual({ permissionDecision: "allow" }); - const mcp2Allowed = await getOnPreToolUse(config)(toolUseInput("CustomTool"), invocation); + const mcp2Allowed = await getOnPreToolUse(config)( + toolUseInput("CustomTool"), + invocation, + ); expect(mcp2Allowed).toEqual({ permissionDecision: "allow" }); - const denied = await getOnPreToolUse(config)(toolUseInput("NotAllowed"), invocation); + const denied = await getOnPreToolUse(config)( + toolUseInput("NotAllowed"), + invocation, + ); expect(denied).toEqual({ permissionDecision: "deny" }); }); @@ -318,7 +376,10 @@ describe("tool filtering", () => { hasToolBridge: true, port: 8080, }); - const result = await getOnPreToolUse(config)(toolUseInput("xcode-bridge-Read"), invocation); + const result = await getOnPreToolUse(config)( + toolUseInput("xcode-bridge-Read"), + invocation, + ); expect(result).toEqual({ permissionDecision: "allow" }); }); @@ -333,13 +394,19 @@ describe("tool filtering", () => { port: 8080, }); // Bridge tools allowed - const bridge = await getOnPreToolUse(config)(toolUseInput("xcode-bridge-Read"), invocation); + const bridge = await getOnPreToolUse(config)( + toolUseInput("xcode-bridge-Read"), + invocation, + ); expect(bridge).toEqual({ permissionDecision: "allow" }); // CLI tools also allowed (additive) const cli = await getOnPreToolUse(config)(toolUseInput("glob"), invocation); expect(cli).toEqual({ permissionDecision: "allow" }); // Unknown tools still denied - const denied = await getOnPreToolUse(config)(toolUseInput("NotAllowed"), invocation); + const denied = await getOnPreToolUse(config)( + toolUseInput("NotAllowed"), + invocation, + ); expect(denied).toEqual({ permissionDecision: "deny" }); }); @@ -354,7 +421,10 @@ describe("tool filtering", () => { port: 8080, }); // No bridge, so xcode-bridge-* tools are denied - const result = await getOnPreToolUse(config)(toolUseInput("xcode-bridge-Read"), invocation); + const result = await getOnPreToolUse(config)( + toolUseInput("xcode-bridge-Read"), + invocation, + ); expect(result).toEqual({ permissionDecision: "deny" }); // No xcode-bridge MCP server entry expect(config.mcpServers).toEqual({}); @@ -369,7 +439,10 @@ describe("tool filtering", () => { supportsReasoningEffort: false, port: 8080, }); - const result = await getOnPreToolUse(config)(toolUseInput("xcode-bridge-Read"), invocation); + const result = await getOnPreToolUse(config)( + toolUseInput("xcode-bridge-Read"), + invocation, + ); expect(result).toEqual({ permissionDecision: "deny" }); expect(config.mcpServers).toEqual({}); }); @@ -385,7 +458,11 @@ describe("tool filtering", () => { port: 9090, conversationId: "conv-abc-123", }); - const bridge = config.mcpServers?.["xcode-bridge"] as { type: string; url: string; tools: string[] }; + const bridge = config.mcpServers?.["xcode-bridge"] as { + type: string; + url: string; + tools: string[]; + }; expect(bridge.type).toBe("http"); expect(bridge.url).toBe("http://127.0.0.1:9090/mcp/conv-abc-123"); expect(bridge.tools).toEqual(["*"]); @@ -400,7 +477,10 @@ describe("tool filtering", () => { supportsReasoningEffort: false, hasToolBridge: true, }); - const bridge = config.mcpServers?.["xcode-bridge"] as { type: string; url: string }; + const bridge = config.mcpServers?.["xcode-bridge"] as { + type: string; + url: string; + }; expect(bridge.type).toBe("http"); expect(bridge.url).toBe("http://127.0.0.1:8080/mcp/conv-1"); }); @@ -467,8 +547,19 @@ describe("excludedTools", () => { it("subagent request (no tools, bridge enabled) still excludes SDK built-ins", () => { const config = createProviderSessionConfig( - { model: "claude-haiku-4-5-20251001", logger, config: makeConfig({ toolBridge: true }), supportsReasoningEffort: false }, - { conversationId: "sub-1", tools: [], config: makeConfig({ toolBridge: true }), logger, port: 8080 }, + { + model: "claude-haiku-4-5-20251001", + logger, + config: makeConfig({ toolBridge: true }), + supportsReasoningEffort: false, + }, + { + conversationId: "sub-1", + tools: [], + config: makeConfig({ toolBridge: true }), + logger, + port: 8080, + }, ); expect(config.excludedTools).toContain("web_fetch"); expect(config.excludedTools).toContain("bash"); diff --git a/proxy-server/test/integration/claude.test.ts b/proxy-server/test/integration/claude.test.ts index 4abf6e8..aab66a1 100644 --- a/proxy-server/test/integration/claude.test.ts +++ b/proxy-server/test/integration/claude.test.ts @@ -1,18 +1,39 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { claudeProvider } from "../../src/providers/claude/provider.js"; -import { TIMEOUT, CLAUDE_MODEL, startServer, postJSON, parseSSELines, mock } from "./setup.js"; +import { + TIMEOUT, + CLAUDE_MODEL, + startServer, + postJSON, + parseSSELines, + mock, +} from "./setup.js"; const PATH = "/v1/messages"; const UA = { "user-agent": "claude-cli/1.0" }; const msg = (content: string, max_tokens = 100) => ({ - model: CLAUDE_MODEL, messages: [{ role: "user", content }], max_tokens, + model: CLAUDE_MODEL, + messages: [{ role: "user", content }], + max_tokens, }); -const byok = () => ({ type: "anthropic" as const, baseUrl: mock.url, apiKey: "dummy" }); -const post = (baseUrl: string, body: unknown) => postJSON(baseUrl, PATH, body, UA); +const byok = () => ({ + type: "anthropic" as const, + baseUrl: mock.url, + apiKey: "dummy", +}); +const post = (baseUrl: string, body: unknown) => + postJSON(baseUrl, PATH, body, UA); function textFrom(res: { body: string }): string { - return (parseSSELines(res.body) as { type?: string; delta?: { type?: string; text?: string } }[]) - .filter((e) => e.type === "content_block_delta" && e.delta?.type === "text_delta") + return ( + parseSSELines(res.body) as { + type?: string; + delta?: { type?: string; text?: string }; + }[] + ) + .filter( + (e) => e.type === "content_block_delta" && e.delta?.type === "text_delta", + ) .map((e) => e.delta?.text ?? "") .join(""); } @@ -27,138 +48,206 @@ describe("Claude provider", () => { close = () => server.app.close(); }, TIMEOUT); - afterEach(async () => { await close(); }); - - it("streams a basic response with Anthropic SSE events", async () => { - const res = await post(baseUrl, msg("hello")); - - expect(res.status).toBe(200); - expect(res.contentType).toBe("text/event-stream"); - expect(textFrom(res)).toBe("Hello from mock!"); - - const types = (parseSSELines(res.body) as { type?: string }[]).map((e) => e.type); - expect(types).toContain("message_start"); - expect(types).toContain("content_block_start"); - expect(types).toContain("content_block_delta"); - expect(types).toContain("content_block_stop"); - expect(types).toContain("message_delta"); - expect(types).toContain("message_stop"); - }, TIMEOUT); - - it("streams with a system message", async () => { - const res = await post(baseUrl, { - ...msg("capital of France"), - system: "You are helpful.", - }); - - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("The capital of France is Paris."); - }, TIMEOUT); - - it("handles multi-turn conversation", async () => { - const res = await post(baseUrl, { - model: CLAUDE_MODEL, - messages: [ - { role: "user", content: "remember the word banana" }, - { role: "assistant", content: "OK" }, - { role: "user", content: "what word did I ask you to remember?" }, - ], - max_tokens: 100, - }); - - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("The word was banana."); - }, TIMEOUT); - - it("streams response with reasoning reply", async () => { - const res = await post(baseUrl, msg("think about life", 16000)); - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("The answer is 42."); - }, TIMEOUT); - - it("uses fallback for unmatched messages", async () => { - const res = await post(baseUrl, msg("something random")); - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("I'm a mock server."); - }, TIMEOUT); - - it("streams an empty response without errors", async () => { - const res = await post(baseUrl, msg("say nothing")); - expect(res.status).toBe(200); - const types = (parseSSELines(res.body) as { type?: string }[]).map((e) => e.type); - expect(types).toContain("message_stop"); - }, TIMEOUT); - - it("rejects missing max_tokens", async () => { - const res = await post(baseUrl, { - model: CLAUDE_MODEL, messages: [{ role: "user", content: "hello" }], - }); - expect(res.status).toBe(400); - }, TIMEOUT); - - it("rejects missing model", async () => { - const res = await post(baseUrl, { - messages: [{ role: "user", content: "hello" }], max_tokens: 100, - }); - expect(res.status).toBe(400); - }, TIMEOUT); - - it("rejects empty messages array", async () => { - const res = await post(baseUrl, { - model: CLAUDE_MODEL, messages: [], max_tokens: 100, - }); - expect(res.status).toBe(400); - }, TIMEOUT); - - it("rejects requests with wrong user-agent", async () => { - const res = await postJSON(baseUrl, PATH, msg("hello"), { "user-agent": "curl/1.0" }); - expect(res.status).toBe(403); - }, TIMEOUT); - - it("rejects requests with missing user-agent", async () => { - const res = await fetch(`${baseUrl}${PATH}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(msg("hello")), - }); - expect(res.status).toBe(403); - }, TIMEOUT); - - it("rejects non-streaming requests", async () => { - const res = await post(baseUrl, { ...msg("hello"), stream: false }); - expect(res.status).toBe(400); - }, TIMEOUT); -}); + afterEach(async () => { + await close(); + }); + + it( + "streams a basic response with Anthropic SSE events", + async () => { + const res = await post(baseUrl, msg("hello")); + + expect(res.status).toBe(200); + expect(res.contentType).toBe("text/event-stream"); + expect(textFrom(res)).toBe("Hello from mock!"); + + const types = (parseSSELines(res.body) as { type?: string }[]).map( + (e) => e.type, + ); + expect(types).toContain("message_start"); + expect(types).toContain("content_block_start"); + expect(types).toContain("content_block_delta"); + expect(types).toContain("content_block_stop"); + expect(types).toContain("message_delta"); + expect(types).toContain("message_stop"); + }, + TIMEOUT, + ); + + it( + "streams with a system message", + async () => { + const res = await post(baseUrl, { + ...msg("capital of France"), + system: "You are helpful.", + }); -describe("Claude provider - usage stats", () => { - it("records usage stats", async () => { - const server = await startServer(claudeProvider, byok()); - try { - await post(server.baseUrl, msg("hello")); - const snap = server.ctx.stats.snapshot(); - expect(snap.requests).toBe(1); - expect(snap.sessions).toBe(1); - } finally { - await server.app.close(); - } - }, TIMEOUT); + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("The capital of France is Paris."); + }, + TIMEOUT, + ); - it("records multiple requests across turns", async () => { - const server = await startServer(claudeProvider, byok()); - try { - await post(server.baseUrl, msg("hello")); - await post(server.baseUrl, { + it( + "handles multi-turn conversation", + async () => { + const res = await post(baseUrl, { model: CLAUDE_MODEL, messages: [ - { role: "user", content: "hello" }, - { role: "assistant", content: "Hi" }, - { role: "user", content: "capital of France" }, + { role: "user", content: "remember the word banana" }, + { role: "assistant", content: "OK" }, + { role: "user", content: "what word did I ask you to remember?" }, ], max_tokens: 100, }); - expect(server.ctx.stats.snapshot().requests).toBe(2); - } finally { - await server.app.close(); - } - }, TIMEOUT); + + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("The word was banana."); + }, + TIMEOUT, + ); + + it( + "streams response with reasoning reply", + async () => { + const res = await post(baseUrl, msg("think about life", 16000)); + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("The answer is 42."); + }, + TIMEOUT, + ); + + it( + "uses fallback for unmatched messages", + async () => { + const res = await post(baseUrl, msg("something random")); + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("I'm a mock server."); + }, + TIMEOUT, + ); + + it( + "streams an empty response without errors", + async () => { + const res = await post(baseUrl, msg("say nothing")); + expect(res.status).toBe(200); + const types = (parseSSELines(res.body) as { type?: string }[]).map( + (e) => e.type, + ); + expect(types).toContain("message_stop"); + }, + TIMEOUT, + ); + + it( + "rejects missing max_tokens", + async () => { + const res = await post(baseUrl, { + model: CLAUDE_MODEL, + messages: [{ role: "user", content: "hello" }], + }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); + + it( + "rejects missing model", + async () => { + const res = await post(baseUrl, { + messages: [{ role: "user", content: "hello" }], + max_tokens: 100, + }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); + + it( + "rejects empty messages array", + async () => { + const res = await post(baseUrl, { + model: CLAUDE_MODEL, + messages: [], + max_tokens: 100, + }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); + + it( + "rejects requests with wrong user-agent", + async () => { + const res = await postJSON(baseUrl, PATH, msg("hello"), { + "user-agent": "curl/1.0", + }); + expect(res.status).toBe(403); + }, + TIMEOUT, + ); + + it( + "rejects requests with missing user-agent", + async () => { + const res = await fetch(`${baseUrl}${PATH}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(msg("hello")), + }); + expect(res.status).toBe(403); + }, + TIMEOUT, + ); + + it( + "rejects non-streaming requests", + async () => { + const res = await post(baseUrl, { ...msg("hello"), stream: false }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); +}); + +describe("Claude provider - usage stats", () => { + it( + "records usage stats", + async () => { + const server = await startServer(claudeProvider, byok()); + try { + await post(server.baseUrl, msg("hello")); + const snap = server.ctx.stats.snapshot(); + expect(snap.requests).toBe(1); + expect(snap.sessions).toBe(1); + } finally { + await server.app.close(); + } + }, + TIMEOUT, + ); + + it( + "records multiple requests across turns", + async () => { + const server = await startServer(claudeProvider, byok()); + try { + await post(server.baseUrl, msg("hello")); + await post(server.baseUrl, { + model: CLAUDE_MODEL, + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "Hi" }, + { role: "user", content: "capital of France" }, + ], + max_tokens: 100, + }); + expect(server.ctx.stats.snapshot().requests).toBe(2); + } finally { + await server.app.close(); + } + }, + TIMEOUT, + ); }); diff --git a/proxy-server/test/integration/codex.test.ts b/proxy-server/test/integration/codex.test.ts index d19bae0..55a6937 100644 --- a/proxy-server/test/integration/codex.test.ts +++ b/proxy-server/test/integration/codex.test.ts @@ -1,12 +1,27 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { codexProvider } from "../../src/providers/codex/provider.js"; -import { TIMEOUT, OPENAI_MODEL, startServer, postJSON, parseSSELines, mock } from "./setup.js"; +import { + TIMEOUT, + OPENAI_MODEL, + startServer, + postJSON, + parseSSELines, + mock, +} from "./setup.js"; const PATH = "/v1/responses"; const UA = { "user-agent": "Xcode/16000 CFNetwork/1 Darwin/25.0.0" }; -const msg = (input: string | { role: string; content: string }[]) => ({ model: OPENAI_MODEL, input }); -const byok = () => ({ type: "openai" as const, wireApi: "responses" as const, baseUrl: `${mock.url}/v1` }); -const post = (baseUrl: string, body: unknown) => postJSON(baseUrl, PATH, body, UA); +const msg = (input: string | { role: string; content: string }[]) => ({ + model: OPENAI_MODEL, + input, +}); +const byok = () => ({ + type: "openai" as const, + wireApi: "responses" as const, + baseUrl: `${mock.url}/v1`, +}); +const post = (baseUrl: string, body: unknown) => + postJSON(baseUrl, PATH, body, UA); function textFrom(res: { body: string }): string { return (parseSSELines(res.body) as { type?: string; delta?: string }[]) @@ -25,119 +40,185 @@ describe("Codex provider", () => { close = () => server.app.close(); }, TIMEOUT); - afterEach(async () => { await close(); }); - - it("streams a basic response with Responses API events", async () => { - const res = await post(baseUrl, msg("hello")); - - expect(res.status).toBe(200); - expect(res.contentType).toBe("text/event-stream"); - expect(textFrom(res)).toBe("Hello from mock!"); - - const types = (parseSSELines(res.body) as { type?: string }[]).map((e) => e.type).filter(Boolean); - expect(types).toContain("response.created"); - expect(types).toContain("response.output_item.added"); - expect(types).toContain("response.content_part.added"); - expect(types).toContain("response.output_text.delta"); - expect(types).toContain("response.output_text.done"); - expect(types).toContain("response.completed"); - }, TIMEOUT); - - it("streams with instructions", async () => { - const res = await post(baseUrl, { - ...msg("capital of France"), - instructions: "You are helpful.", - }); - - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("The capital of France is Paris."); - }, TIMEOUT); - - it("handles multi-turn via input array", async () => { - const res = await post(baseUrl, msg([ - { role: "user", content: "remember the word banana" }, - { role: "assistant", content: "OK" }, - { role: "user", content: "what word did I ask you to remember?" }, - ])); - - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("The word was banana."); - }, TIMEOUT); - - it("streams response with reasoning reply", async () => { - const res = await post(baseUrl, msg("think about life")); - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("The answer is 42."); - }, TIMEOUT); - - it("uses fallback for unmatched messages", async () => { - const res = await post(baseUrl, msg("something random")); - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("I'm a mock server."); - }, TIMEOUT); - - it("streams an empty response without errors", async () => { - const res = await post(baseUrl, msg("say nothing")); - expect(res.status).toBe(200); - const types = (parseSSELines(res.body) as { type?: string }[]).map((e) => e.type).filter(Boolean); - expect(types).toContain("response.completed"); - }, TIMEOUT); - - it("rejects missing input", async () => { - const res = await post(baseUrl, { model: OPENAI_MODEL }); - expect(res.status).toBe(400); - }, TIMEOUT); - - it("rejects missing model", async () => { - const res = await post(baseUrl, { input: "hello" }); - expect(res.status).toBe(400); - }, TIMEOUT); - - it("rejects requests with wrong user-agent", async () => { - const res = await postJSON(baseUrl, PATH, msg("hello"), { "user-agent": "curl/1.0" }); - expect(res.status).toBe(403); - }, TIMEOUT); - - it("rejects requests with missing user-agent", async () => { - const res = await fetch(`${baseUrl}${PATH}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(msg("hello")), - }); - expect(res.status).toBe(403); - }, TIMEOUT); - - it("rejects non-streaming requests", async () => { - const res = await post(baseUrl, { ...msg("hello"), stream: false }); - expect(res.status).toBe(400); - }, TIMEOUT); + afterEach(async () => { + await close(); + }); + + it( + "streams a basic response with Responses API events", + async () => { + const res = await post(baseUrl, msg("hello")); + + expect(res.status).toBe(200); + expect(res.contentType).toBe("text/event-stream"); + expect(textFrom(res)).toBe("Hello from mock!"); + + const types = (parseSSELines(res.body) as { type?: string }[]) + .map((e) => e.type) + .filter(Boolean); + expect(types).toContain("response.created"); + expect(types).toContain("response.output_item.added"); + expect(types).toContain("response.content_part.added"); + expect(types).toContain("response.output_text.delta"); + expect(types).toContain("response.output_text.done"); + expect(types).toContain("response.completed"); + }, + TIMEOUT, + ); + + it( + "streams with instructions", + async () => { + const res = await post(baseUrl, { + ...msg("capital of France"), + instructions: "You are helpful.", + }); + + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("The capital of France is Paris."); + }, + TIMEOUT, + ); + + it( + "handles multi-turn via input array", + async () => { + const res = await post( + baseUrl, + msg([ + { role: "user", content: "remember the word banana" }, + { role: "assistant", content: "OK" }, + { role: "user", content: "what word did I ask you to remember?" }, + ]), + ); + + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("The word was banana."); + }, + TIMEOUT, + ); + + it( + "streams response with reasoning reply", + async () => { + const res = await post(baseUrl, msg("think about life")); + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("The answer is 42."); + }, + TIMEOUT, + ); + + it( + "uses fallback for unmatched messages", + async () => { + const res = await post(baseUrl, msg("something random")); + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("I'm a mock server."); + }, + TIMEOUT, + ); + + it( + "streams an empty response without errors", + async () => { + const res = await post(baseUrl, msg("say nothing")); + expect(res.status).toBe(200); + const types = (parseSSELines(res.body) as { type?: string }[]) + .map((e) => e.type) + .filter(Boolean); + expect(types).toContain("response.completed"); + }, + TIMEOUT, + ); + + it( + "rejects missing input", + async () => { + const res = await post(baseUrl, { model: OPENAI_MODEL }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); + + it( + "rejects missing model", + async () => { + const res = await post(baseUrl, { input: "hello" }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); + + it( + "rejects requests with wrong user-agent", + async () => { + const res = await postJSON(baseUrl, PATH, msg("hello"), { + "user-agent": "curl/1.0", + }); + expect(res.status).toBe(403); + }, + TIMEOUT, + ); + + it( + "rejects requests with missing user-agent", + async () => { + const res = await fetch(`${baseUrl}${PATH}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(msg("hello")), + }); + expect(res.status).toBe(403); + }, + TIMEOUT, + ); + + it( + "rejects non-streaming requests", + async () => { + const res = await post(baseUrl, { ...msg("hello"), stream: false }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); }); describe("Codex provider - usage stats", () => { - it("records usage stats", async () => { - const server = await startServer(codexProvider, byok()); - try { - await post(server.baseUrl, msg("hello")); - const snap = server.ctx.stats.snapshot(); - expect(snap.requests).toBe(1); - expect(snap.sessions).toBe(1); - } finally { - await server.app.close(); - } - }, TIMEOUT); - - it("records multiple requests across turns", async () => { - const server = await startServer(codexProvider, byok()); - try { - await post(server.baseUrl, msg("hello")); - await post(server.baseUrl, msg([ - { role: "user", content: "hello" }, - { role: "assistant", content: "Hi" }, - { role: "user", content: "capital of France" }, - ])); - expect(server.ctx.stats.snapshot().requests).toBe(2); - } finally { - await server.app.close(); - } - }, TIMEOUT); + it( + "records usage stats", + async () => { + const server = await startServer(codexProvider, byok()); + try { + await post(server.baseUrl, msg("hello")); + const snap = server.ctx.stats.snapshot(); + expect(snap.requests).toBe(1); + expect(snap.sessions).toBe(1); + } finally { + await server.app.close(); + } + }, + TIMEOUT, + ); + + it( + "records multiple requests across turns", + async () => { + const server = await startServer(codexProvider, byok()); + try { + await post(server.baseUrl, msg("hello")); + await post( + server.baseUrl, + msg([ + { role: "user", content: "hello" }, + { role: "assistant", content: "Hi" }, + { role: "user", content: "capital of France" }, + ]), + ); + expect(server.ctx.stats.snapshot().requests).toBe(2); + } finally { + await server.app.close(); + } + }, + TIMEOUT, + ); }); diff --git a/proxy-server/test/integration/openai.test.ts b/proxy-server/test/integration/openai.test.ts index 52ae944..9c4c812 100644 --- a/proxy-server/test/integration/openai.test.ts +++ b/proxy-server/test/integration/openai.test.ts @@ -1,15 +1,30 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { openaiProvider } from "../../src/providers/openai/provider.js"; -import { TIMEOUT, OPENAI_MODEL, startServer, postJSON, parseSSELines, mock } from "./setup.js"; +import { + TIMEOUT, + OPENAI_MODEL, + startServer, + postJSON, + parseSSELines, + mock, +} from "./setup.js"; const PATH = "/v1/chat/completions"; const UA = { "user-agent": "Xcode/16000 CFNetwork/1 Darwin/25.0.0" }; -const msg = (content: string) => ({ model: OPENAI_MODEL, messages: [{ role: "user", content }] }); +const msg = (content: string) => ({ + model: OPENAI_MODEL, + messages: [{ role: "user", content }], +}); const byok = () => ({ type: "openai" as const, baseUrl: `${mock.url}/v1` }); -const post = (baseUrl: string, body: unknown) => postJSON(baseUrl, PATH, body, UA); +const post = (baseUrl: string, body: unknown) => + postJSON(baseUrl, PATH, body, UA); function textFrom(res: { body: string }): string { - return (parseSSELines(res.body) as { choices?: { delta?: { content?: string } }[] }[]) + return ( + parseSSELines(res.body) as { + choices?: { delta?: { content?: string } }[]; + }[] + ) .flatMap((e) => e.choices ?? []) .map((c) => c.delta?.content ?? "") .filter(Boolean) @@ -26,167 +41,240 @@ describe("OpenAI provider", () => { close = () => server.app.close(); }, TIMEOUT); - afterEach(async () => { await close(); }); + afterEach(async () => { + await close(); + }); - it("streams a basic response", async () => { - const res = await post(baseUrl, msg("hello")); + it( + "streams a basic response", + async () => { + const res = await post(baseUrl, msg("hello")); - expect(res.status).toBe(200); - expect(res.contentType).toBe("text/event-stream"); - expect(res.body).toContain("data: [DONE]"); - expect(textFrom(res)).toBe("Hello from mock!"); - }, TIMEOUT); + expect(res.status).toBe(200); + expect(res.contentType).toBe("text/event-stream"); + expect(res.body).toContain("data: [DONE]"); + expect(textFrom(res)).toBe("Hello from mock!"); + }, + TIMEOUT, + ); - it("streams with a system message", async () => { - const res = await post(baseUrl, { - model: OPENAI_MODEL, - messages: [ - { role: "system", content: "You are helpful." }, - { role: "user", content: "capital of France" }, - ], - }); - - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("The capital of France is Paris."); - }, TIMEOUT); + it( + "streams with a system message", + async () => { + const res = await post(baseUrl, { + model: OPENAI_MODEL, + messages: [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "capital of France" }, + ], + }); - it("handles multi-turn conversation", async () => { - const res = await post(baseUrl, { - model: OPENAI_MODEL, - messages: [ - { role: "user", content: "remember the word banana" }, - { role: "assistant", content: "OK" }, - { role: "user", content: "what word did I ask you to remember?" }, - ], - }); - - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("The word was banana."); - }, TIMEOUT); + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("The capital of France is Paris."); + }, + TIMEOUT, + ); - it("streams response with reasoning reply", async () => { - const res = await post(baseUrl, msg("think about life")); - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("The answer is 42."); - }, TIMEOUT); + it( + "handles multi-turn conversation", + async () => { + const res = await post(baseUrl, { + model: OPENAI_MODEL, + messages: [ + { role: "user", content: "remember the word banana" }, + { role: "assistant", content: "OK" }, + { role: "user", content: "what word did I ask you to remember?" }, + ], + }); - it("uses fallback for unmatched messages", async () => { - const res = await post(baseUrl, msg("something random")); - expect(res.status).toBe(200); - expect(textFrom(res)).toBe("I'm a mock server."); - }, TIMEOUT); + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("The word was banana."); + }, + TIMEOUT, + ); - it("streams an empty response without errors", async () => { - const res = await post(baseUrl, msg("say nothing")); - expect(res.status).toBe(200); - expect(res.body).toContain("data: [DONE]"); - }, TIMEOUT); + it( + "streams response with reasoning reply", + async () => { + const res = await post(baseUrl, msg("think about life")); + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("The answer is 42."); + }, + TIMEOUT, + ); - it("rejects non-streaming requests", async () => { - const res = await post(baseUrl, { ...msg("hello"), stream: false }); - expect(res.status).toBe(400); - }, TIMEOUT); + it( + "uses fallback for unmatched messages", + async () => { + const res = await post(baseUrl, msg("something random")); + expect(res.status).toBe(200); + expect(textFrom(res)).toBe("I'm a mock server."); + }, + TIMEOUT, + ); - it("rejects invalid schema", async () => { - const res = await post(baseUrl, { model: OPENAI_MODEL, messages: "not an array" }); - expect(res.status).toBe(400); - }, TIMEOUT); + it( + "streams an empty response without errors", + async () => { + const res = await post(baseUrl, msg("say nothing")); + expect(res.status).toBe(200); + expect(res.body).toContain("data: [DONE]"); + }, + TIMEOUT, + ); - it("rejects missing model", async () => { - const res = await post(baseUrl, { messages: [{ role: "user", content: "hello" }] }); - expect(res.status).toBe(400); - }, TIMEOUT); + it( + "rejects non-streaming requests", + async () => { + const res = await post(baseUrl, { ...msg("hello"), stream: false }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); - it("rejects empty messages array", async () => { - const res = await post(baseUrl, { model: OPENAI_MODEL, messages: [] }); - expect(res.status).toBe(400); - }, TIMEOUT); + it( + "rejects invalid schema", + async () => { + const res = await post(baseUrl, { + model: OPENAI_MODEL, + messages: "not an array", + }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); - it("rejects requests with wrong user-agent", async () => { - const res = await postJSON(baseUrl, PATH, msg("hello"), { "user-agent": "curl/1.0" }); - expect(res.status).toBe(403); - }, TIMEOUT); + it( + "rejects missing model", + async () => { + const res = await post(baseUrl, { + messages: [{ role: "user", content: "hello" }], + }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); - it("rejects requests with missing user-agent", async () => { - const res = await fetch(`${baseUrl}${PATH}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(msg("hello")), - }); - expect(res.status).toBe(403); - }, TIMEOUT); + it( + "rejects empty messages array", + async () => { + const res = await post(baseUrl, { model: OPENAI_MODEL, messages: [] }); + expect(res.status).toBe(400); + }, + TIMEOUT, + ); - it("strips excluded file code blocks from prompt", async () => { - mock.history.clear(); - const server = await startServer(openaiProvider, byok(), { - excludedFilePatterns: ["secret.ts"], - }); - try { - const content = [ - "Here is some code:", - "```swift:main.swift", - "print(\"hello\")", - "```", - "```typescript:secret.ts", - "const API_KEY = \"sk-1234\";", - "```", - "Please review.", - ].join("\n"); - - await post(server.baseUrl, { - model: OPENAI_MODEL, - messages: [{ role: "user", content }], + it( + "rejects requests with wrong user-agent", + async () => { + const res = await postJSON(baseUrl, PATH, msg("hello"), { + "user-agent": "curl/1.0", }); + expect(res.status).toBe(403); + }, + TIMEOUT, + ); - const lastReq = mock.history.last(); - expect(lastReq).toBeDefined(); - const lastMessage = lastReq!.request.lastMessage; - expect(lastMessage).toContain("main.swift"); - expect(lastMessage).not.toContain("secret.ts"); - expect(lastMessage).not.toContain("sk-1234"); - } finally { - await server.app.close(); - } - }, TIMEOUT); + it( + "rejects requests with missing user-agent", + async () => { + const res = await fetch(`${baseUrl}${PATH}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(msg("hello")), + }); + expect(res.status).toBe(403); + }, + TIMEOUT, + ); - it("GET /health returns 200", async () => { - const res = await fetch(`${baseUrl}/health`, { - headers: UA, - }); - expect(res.status).toBe(200); - const json = await res.json(); - expect(json.status).toBe("ok"); - }, TIMEOUT); + it( + "strips excluded file code blocks from prompt", + async () => { + mock.history.clear(); + const server = await startServer(openaiProvider, byok(), { + excludedFilePatterns: ["secret.ts"], + }); + try { + const content = [ + "Here is some code:", + "```swift:main.swift", + 'print("hello")', + "```", + "```typescript:secret.ts", + 'const API_KEY = "sk-1234";', + "```", + "Please review.", + ].join("\n"); + + await post(server.baseUrl, { + model: OPENAI_MODEL, + messages: [{ role: "user", content }], + }); + + const lastReq = mock.history.last(); + expect(lastReq).toBeDefined(); + const lastMessage = lastReq!.request.lastMessage; + expect(lastMessage).toContain("main.swift"); + expect(lastMessage).not.toContain("secret.ts"); + expect(lastMessage).not.toContain("sk-1234"); + } finally { + await server.app.close(); + } + }, + TIMEOUT, + ); + + it( + "GET /health returns 200", + async () => { + const res = await fetch(`${baseUrl}/health`, { + headers: UA, + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.status).toBe("ok"); + }, + TIMEOUT, + ); }); describe("OpenAI provider - usage stats", () => { - it("records usage stats", async () => { - const server = await startServer(openaiProvider, byok()); - try { - await post(server.baseUrl, msg("hello")); - const snap = server.ctx.stats.snapshot(); - expect(snap.requests).toBe(1); - expect(snap.sessions).toBe(1); - } finally { - await server.app.close(); - } - }, TIMEOUT); + it( + "records usage stats", + async () => { + const server = await startServer(openaiProvider, byok()); + try { + await post(server.baseUrl, msg("hello")); + const snap = server.ctx.stats.snapshot(); + expect(snap.requests).toBe(1); + expect(snap.sessions).toBe(1); + } finally { + await server.app.close(); + } + }, + TIMEOUT, + ); - it("records multiple requests across turns", async () => { - const server = await startServer(openaiProvider, byok()); - try { - await post(server.baseUrl, msg("hello")); - await post(server.baseUrl, { - model: OPENAI_MODEL, - messages: [ - { role: "user", content: "hello" }, - { role: "assistant", content: "Hi" }, - { role: "user", content: "capital of France" }, - ], - }); - expect(server.ctx.stats.snapshot().requests).toBe(2); - } finally { - await server.app.close(); - } - }, TIMEOUT); + it( + "records multiple requests across turns", + async () => { + const server = await startServer(openaiProvider, byok()); + try { + await post(server.baseUrl, msg("hello")); + await post(server.baseUrl, { + model: OPENAI_MODEL, + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "Hi" }, + { role: "user", content: "capital of France" }, + ], + }); + expect(server.ctx.stats.snapshot().requests).toBe(2); + } finally { + await server.app.close(); + } + }, + TIMEOUT, + ); }); diff --git a/proxy-server/test/integration/setup.ts b/proxy-server/test/integration/setup.ts index 9e5c9ac..a56771a 100644 --- a/proxy-server/test/integration/setup.ts +++ b/proxy-server/test/integration/setup.ts @@ -44,7 +44,11 @@ afterAll(async () => { await mock.stop(); }, TIMEOUT); -export async function startServer(provider: Provider, byokProvider: SessionConfig["provider"], configOverrides?: Partial) { +export async function startServer( + provider: Provider, + byokProvider: SessionConfig["provider"], + configOverrides?: Partial, +) { const config: ServerConfig = { toolBridge: false, toolBridgeTimeoutMs: 0, @@ -70,13 +74,22 @@ export async function startServer(provider: Provider, byokProvider: SessionConfi return { app, baseUrl: address, ctx }; } -export async function postJSON(baseUrl: string, path: string, body: unknown, extraHeaders?: Record): Promise<{ status: number; body: string; contentType: string | null }> { +export async function postJSON( + baseUrl: string, + path: string, + body: unknown, + extraHeaders?: Record, +): Promise<{ status: number; body: string; contentType: string | null }> { const res = await fetch(`${baseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json", ...extraHeaders }, body: JSON.stringify(body), }); - return { status: res.status, body: await res.text(), contentType: res.headers.get("content-type") }; + return { + status: res.status, + body: await res.text(), + contentType: res.headers.get("content-type"), + }; } export function parseSSELines(body: string): unknown[] { diff --git a/proxy-server/test/launchd/agent.test.ts b/proxy-server/test/launchd/agent.test.ts index f4152f5..5ccd933 100644 --- a/proxy-server/test/launchd/agent.test.ts +++ b/proxy-server/test/launchd/agent.test.ts @@ -70,12 +70,14 @@ describe("generatePlist", () => { }); it("ProgramArguments includes all provided options", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - proxy: "claude", - port: 9090, - logLevel: "debug", - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + proxy: "claude", + port: 9090, + logLevel: "debug", + }), + ); const args = parsed.ProgramArguments; expect(args[args.indexOf("--proxy") + 1]).toBe("claude"); @@ -84,48 +86,58 @@ describe("generatePlist", () => { }); it("includes --config when specified", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - config: "/path/to/config.json5", - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + config: "/path/to/config.json5", + }), + ); const args = parsed.ProgramArguments; expect(args[args.indexOf("--config") + 1]).toBe("/path/to/config.json5"); }); it("includes --cwd when specified", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - cwd: "/path/to/cwd", - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + cwd: "/path/to/cwd", + }), + ); const args = parsed.ProgramArguments; expect(args[args.indexOf("--cwd") + 1]).toBe("/path/to/cwd"); }); it("includes --auto-patch when specified", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - autoPatch: true, - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + autoPatch: true, + }), + ); expect(parsed.ProgramArguments).toContain("--auto-patch"); }); it("does not include --auto-patch when false", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - autoPatch: false, - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + autoPatch: false, + }), + ); expect(parsed.ProgramArguments).not.toContain("--auto-patch"); }); it("includes --idle-timeout when specified", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - idleTimeout: 30, - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + idleTimeout: 30, + }), + ); const args = parsed.ProgramArguments; expect(args).toContain("--idle-timeout"); @@ -133,10 +145,12 @@ describe("generatePlist", () => { }); it("does not include --idle-timeout when zero", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - idleTimeout: 0, - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + idleTimeout: 0, + }), + ); expect(parsed.ProgramArguments).not.toContain("--idle-timeout"); }); @@ -148,10 +162,12 @@ describe("generatePlist", () => { }); it("Sockets.Listeners uses correct port and 127.0.0.1", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - port: 3000, - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + port: 3000, + }), + ); const listeners = parsed.Sockets.Listeners; expect(listeners).toEqual({ @@ -163,13 +179,15 @@ describe("generatePlist", () => { }); it("includes EnvironmentVariables when provided", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - environmentVariables: { - GITHUB_TOKEN: "ghp_test123", - PATH: "/usr/bin", - }, - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + environmentVariables: { + GITHUB_TOKEN: "ghp_test123", + PATH: "/usr/bin", + }, + }), + ); expect(parsed.EnvironmentVariables).toBeDefined(); expect(parsed.EnvironmentVariables!["GITHUB_TOKEN"]).toBe("ghp_test123"); @@ -212,20 +230,26 @@ describe("generatePlist", () => { }); it("round-trips XML special characters in values", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - cwd: "/path/with & chars", - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + cwd: "/path/with & chars", + }), + ); const args = parsed.ProgramArguments; - expect(args[args.indexOf("--cwd") + 1]).toBe("/path/with & chars"); + expect(args[args.indexOf("--cwd") + 1]).toBe( + "/path/with & chars", + ); }); it("includes log paths", () => { - const parsed = parsePlist(generatePlist({ - ...defaultPlistOptions(), - logPaths: { out: "/tmp/out.log", err: "/tmp/err.log" }, - })); + const parsed = parsePlist( + generatePlist({ + ...defaultPlistOptions(), + logPaths: { out: "/tmp/out.log", err: "/tmp/err.log" }, + }), + ); expect(parsed.StandardOutPath).toBe("/tmp/out.log"); expect(parsed.StandardErrorPath).toBe("/tmp/err.log"); @@ -286,7 +310,10 @@ describe("parsePlistArgs", () => { }); }); -function createMockExec(): { exec: ExecFn; calls: Array<{ cmd: string; args: string[] }> } { +function createMockExec(): { + exec: ExecFn; + calls: Array<{ cmd: string; args: string[] }>; +} { const calls: Array<{ cmd: string; args: string[] }> = []; const exec: ExecFn = (cmd: string, args: string[]) => { calls.push({ cmd, args }); @@ -295,7 +322,10 @@ function createMockExec(): { exec: ExecFn; calls: Array<{ cmd: string; args: str return { exec, calls }; } -function createMockExecFailingUnload(): { exec: ExecFn; calls: Array<{ cmd: string; args: string[] }> } { +function createMockExecFailingUnload(): { + exec: ExecFn; + calls: Array<{ cmd: string; args: string[] }>; +} { const calls: Array<{ cmd: string; args: string[] }> = []; const exec: ExecFn = (cmd: string, args: string[]) => { calls.push({ cmd, args }); @@ -495,10 +525,13 @@ describe("uninstallAgent", () => { it("detects --proxy value from existing plist", async () => { const plistPath = join(tempDir, "test.plist"); - writeFileSync(plistPath, generatePlist({ - ...defaultPlistOptions(), - proxy: "claude", - })); + writeFileSync( + plistPath, + generatePlist({ + ...defaultPlistOptions(), + proxy: "claude", + }), + ); const mock = createMockExec(); await uninstallAgent({ logger, exec: mock.exec, plistPath }); diff --git a/proxy-server/test/launchd/socket.test.ts b/proxy-server/test/launchd/socket.test.ts index 17e0f25..38d17e2 100644 --- a/proxy-server/test/launchd/socket.test.ts +++ b/proxy-server/test/launchd/socket.test.ts @@ -30,20 +30,26 @@ describe("activateSocket", () => { it("throws descriptive error when not launched by launchd", () => { const mockActivate: NativeActivateFn = () => { - throw new Error("launch_activate_socket failed: Socket name not found in launchd job (ESRCH)"); + throw new Error( + "launch_activate_socket failed: Socket name not found in launchd job (ESRCH)", + ); }; - expect(() => activateSocket("Listeners", { nativeActivate: mockActivate })) - .toThrow("Socket name not found in launchd job (ESRCH)"); + expect(() => + activateSocket("Listeners", { nativeActivate: mockActivate }), + ).toThrow("Socket name not found in launchd job (ESRCH)"); }); it("throws descriptive error when socket name not found", () => { const mockActivate: NativeActivateFn = () => { - throw new Error("launch_activate_socket failed: No socket with that name (ENOENT)"); + throw new Error( + "launch_activate_socket failed: No socket with that name (ENOENT)", + ); }; - expect(() => activateSocket("WrongName", { nativeActivate: mockActivate })) - .toThrow("No socket with that name (ENOENT)"); + expect(() => + activateSocket("WrongName", { nativeActivate: mockActivate }), + ).toThrow("No socket with that name (ENOENT)"); }); it("returns empty array when native function returns empty", () => { diff --git a/proxy-server/test/providers/auto-provider.test.ts b/proxy-server/test/providers/auto-provider.test.ts index 3db8852..6eaacd4 100644 --- a/proxy-server/test/providers/auto-provider.test.ts +++ b/proxy-server/test/providers/auto-provider.test.ts @@ -12,7 +12,10 @@ describe("providers", () => { it("each provider has name and routes", () => { for (const [key, provider] of Object.entries(providers)) { expect(provider.name, `${key} should have a name`).toBeTruthy(); - expect(provider.routes.length, `${key} should have routes`).toBeGreaterThan(0); + expect( + provider.routes.length, + `${key} should have routes`, + ).toBeGreaterThan(0); } }); }); diff --git a/proxy-server/test/providers/claude.test.ts b/proxy-server/test/providers/claude.test.ts index 062d642..0d08194 100644 --- a/proxy-server/test/providers/claude.test.ts +++ b/proxy-server/test/providers/claude.test.ts @@ -26,7 +26,9 @@ const ctx: AppContext = { stats: new Stats(), }; -const claudeCliHeaders = { "user-agent": "claude-cli/2.1.14 (external, sdk-cli)" }; +const claudeCliHeaders = { + "user-agent": "claude-cli/2.1.14 (external, sdk-cli)", +}; let app: FastifyInstance; @@ -44,7 +46,11 @@ describe("Anthropic provider — user-agent check", () => { method: "POST", url: "/v1/messages", headers: claudeCliHeaders, - payload: { model: "claude-sonnet-4-20250514", max_tokens: 1024, messages: [] }, + payload: { + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [], + }, }); // 400 from validation (empty messages), not 403 expect(res.statusCode).toBe(400); @@ -55,7 +61,11 @@ describe("Anthropic provider — user-agent check", () => { method: "POST", url: "/v1/messages", headers: { "user-agent": "curl/8.0" }, - payload: { model: "claude-sonnet-4-20250514", max_tokens: 1024, messages: [] }, + payload: { + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [], + }, }); expect(res.statusCode).toBe(403); }); @@ -65,7 +75,11 @@ describe("Anthropic provider — user-agent check", () => { method: "POST", url: "/v1/messages", headers: {}, - payload: { model: "claude-sonnet-4-20250514", max_tokens: 1024, messages: [] }, + payload: { + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [], + }, }); expect(res.statusCode).toBe(403); }); @@ -88,7 +102,10 @@ describe("Anthropic provider — /v1/messages validation", () => { method: "POST", url: "/v1/messages", headers: claudeCliHeaders, - payload: { max_tokens: 1024, messages: [{ role: "user", content: "Hello" }] }, + payload: { + max_tokens: 1024, + messages: [{ role: "user", content: "Hello" }], + }, }); expect(res.statusCode).toBe(400); const body = res.json(); @@ -101,7 +118,11 @@ describe("Anthropic provider — /v1/messages validation", () => { method: "POST", url: "/v1/messages", headers: claudeCliHeaders, - payload: { model: "claude-sonnet-4-20250514", max_tokens: 1024, messages: [] }, + payload: { + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [], + }, }); expect(res.statusCode).toBe(400); const body = res.json(); diff --git a/proxy-server/test/providers/codex.test.ts b/proxy-server/test/providers/codex.test.ts index a22aa91..33110df 100644 --- a/proxy-server/test/providers/codex.test.ts +++ b/proxy-server/test/providers/codex.test.ts @@ -26,7 +26,10 @@ const ctx: AppContext = { stats: new Stats(), }; -const codexHeaders = { "user-agent": "Xcode/0.87.0 (Mac OS 26.2.0; arm64) unknown (Xcode; 26.3 (17C518))" }; +const codexHeaders = { + "user-agent": + "Xcode/0.87.0 (Mac OS 26.2.0; arm64) unknown (Xcode; 26.3 (17C518))", +}; let app: FastifyInstance; @@ -210,9 +213,7 @@ describe("Codex provider — /v1/responses happy path", () => { headers: codexHeaders, payload: { model: "gpt-4o", - input: [ - { role: "user", content: "Hello" }, - ], + input: [{ role: "user", content: "Hello" }], }, }); diff --git a/proxy-server/test/providers/continuation.test.ts b/proxy-server/test/providers/continuation.test.ts index f034cbd..9c8c7f2 100644 --- a/proxy-server/test/providers/continuation.test.ts +++ b/proxy-server/test/providers/continuation.test.ts @@ -16,12 +16,19 @@ function createMockConversation(): Conversation { sentMessageCount: 0, isPrimary: false, model: null, - get sessionActive() { return state.session.sessionActive; }, + get sessionActive() { + return state.session.sessionActive; + }, set sessionActive(active: boolean) { - if (active) state.session.markSessionActive(); else state.session.markSessionInactive(); + if (active) state.session.markSessionActive(); + else state.session.markSessionInactive(); + }, + get hadError() { + return state.session.hadError; + }, + set hadError(errored: boolean) { + if (errored) state.session.markSessionErrored(); }, - get hadError() { return state.session.hadError; }, - set hadError(errored: boolean) { if (errored) state.session.markSessionErrored(); }, }; } @@ -39,14 +46,22 @@ describe("handleContinuation", () => { const reply = createMockReply(); const order: string[] = []; - const startStream = vi.fn(() => { order.push("start"); }); + const startStream = vi.fn(() => { + order.push("start"); + }); const resolveResults = vi.fn(() => { order.push("resolve"); - queueMicrotask(() => { conv.state.replies.notifyStreamingDone(); }); + queueMicrotask(() => { + conv.state.replies.notifyStreamingDone(); + }); }); const countMessages = vi.fn(() => 3); - const result = await handleContinuation(conv, reply, logger, { startStream, resolveResults, countMessages }); + const result = await handleContinuation(conv, reply, logger, { + startStream, + resolveResults, + countMessages, + }); expect(result).toBe(true); expect(order).toEqual(["start", "resolve"]); @@ -58,10 +73,16 @@ describe("handleContinuation", () => { const reply = createMockReply(); const resolveResults = vi.fn(() => { - queueMicrotask(() => { conv.state.replies.notifyStreamingDone(); }); + queueMicrotask(() => { + conv.state.replies.notifyStreamingDone(); + }); }); - await handleContinuation(conv, reply, logger, { startStream: vi.fn(), resolveResults, countMessages: () => 1 }); + await handleContinuation(conv, reply, logger, { + startStream: vi.fn(), + resolveResults, + countMessages: () => 1, + }); expect(conv.sentMessageCount).toBe(1); }); @@ -86,10 +107,16 @@ describe("handleContinuation", () => { const reply = createMockReply(); const resolveResults = vi.fn(() => { - queueMicrotask(() => { conv.state.replies.notifyStreamingDone(); }); + queueMicrotask(() => { + conv.state.replies.notifyStreamingDone(); + }); }); - const result = await handleContinuation(conv, reply, logger, { startStream: vi.fn(), resolveResults, countMessages: () => 5 }); + const result = await handleContinuation(conv, reply, logger, { + startStream: vi.fn(), + resolveResults, + countMessages: () => 5, + }); expect(result).toBe(true); }); }); diff --git a/proxy-server/test/providers/openai.test.ts b/proxy-server/test/providers/openai.test.ts index 39853f5..fd7fba5 100644 --- a/proxy-server/test/providers/openai.test.ts +++ b/proxy-server/test/providers/openai.test.ts @@ -41,7 +41,9 @@ describe("OpenAI provider — user-agent check", () => { const res = await app.inject({ method: "GET", url: "/v1/models", - headers: { "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0" }, + headers: { + "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0", + }, }); expect(res.statusCode).not.toBe(403); }); @@ -79,7 +81,9 @@ describe("OpenAI provider — route isolation", () => { }); }); -const xcodeHeaders = { "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0" }; +const xcodeHeaders = { + "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0", +}; describe("OpenAI provider — /v1/chat/completions validation", () => { it("returns 400 for missing model", async () => { diff --git a/proxy-server/test/providers/streaming-orchestrator.test.ts b/proxy-server/test/providers/streaming-orchestrator.test.ts index d63187d..f002526 100644 --- a/proxy-server/test/providers/streaming-orchestrator.test.ts +++ b/proxy-server/test/providers/streaming-orchestrator.test.ts @@ -29,7 +29,8 @@ function createContext(overrides: { manager, messageCount: overrides.messageCount ?? 5, tools: undefined, - runStreaming: overrides.runStreaming ?? vi.fn().mockResolvedValue(undefined), + runStreaming: + overrides.runStreaming ?? vi.fn().mockResolvedValue(undefined), }; } diff --git a/proxy-server/test/providers/tool-results.test.ts b/proxy-server/test/providers/tool-results.test.ts index dac18a4..9689b4b 100644 --- a/proxy-server/test/providers/tool-results.test.ts +++ b/proxy-server/test/providers/tool-results.test.ts @@ -6,7 +6,11 @@ import { resolveResponsesToolResults } from "../../src/providers/codex/tool-resu const logger = new Logger("none"); -function setupPending(state: ToolBridgeState, callId: string, toolName: string): ReturnType { +function setupPending( + state: ToolBridgeState, + callId: string, + toolName: string, +): ReturnType { state.toolRouter.registerExpected(callId, toolName); const resolve = vi.fn(); state.toolRouter.registerMCPRequest(toolName, resolve, vi.fn()); @@ -19,7 +23,18 @@ describe("resolveToolResults (Claude)", () => { const resolve = setupPending(state, "tool-1", "Read"); resolveToolResults( - [{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool-1", content: "result text" }] }], + [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "result text", + }, + ], + }, + ], state, logger, ); @@ -33,7 +48,21 @@ describe("resolveToolResults (Claude)", () => { const resolve = setupPending(state, "tool-1", "Read"); resolveToolResults( - [{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool-1", content: [{ type: "text", text: "line1" }, { type: "text", text: "line2" }] }] }], + [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: [ + { type: "text", text: "line1" }, + { type: "text", text: "line2" }, + ], + }, + ], + }, + ], state, logger, ); @@ -56,11 +85,7 @@ describe("resolveToolResults (Claude)", () => { it("ignores string content in last message", () => { const state = new ToolBridgeState(); const warnSpy = vi.spyOn(logger, "warn"); - resolveToolResults( - [{ role: "user", content: "just text" }], - state, - logger, - ); + resolveToolResults([{ role: "user", content: "just text" }], state, logger); expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); @@ -78,7 +103,18 @@ describe("resolveToolResults (Claude)", () => { const warnSpy = vi.spyOn(logger, "warn"); resolveToolResults( - [{ role: "user", content: [{ type: "tool_result", tool_use_id: "unknown-id", content: "result" }] }], + [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "unknown-id", + content: "result", + }, + ], + }, + ], state, logger, ); @@ -94,7 +130,13 @@ describe("resolveResponsesToolResults (Codex)", () => { const resolve = setupPending(state, "call-1", "Read"); resolveResponsesToolResults( - [{ type: "function_call_output", call_id: "call-1", output: "result text" }], + [ + { + type: "function_call_output", + call_id: "call-1", + output: "result text", + }, + ], state, logger, ); @@ -107,7 +149,13 @@ describe("resolveResponsesToolResults (Codex)", () => { const warnSpy = vi.spyOn(logger, "warn"); resolveResponsesToolResults( - [{ type: "function_call_output", call_id: "unknown-id", output: "result" }], + [ + { + type: "function_call_output", + call_id: "unknown-id", + output: "result", + }, + ], state, logger, ); diff --git a/proxy-server/test/settings-patcher.test.ts b/proxy-server/test/settings-patcher.test.ts index 9752b18..abc1960 100644 --- a/proxy-server/test/settings-patcher.test.ts +++ b/proxy-server/test/settings-patcher.test.ts @@ -11,8 +11,16 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { Logger } from "copilot-sdk-proxy"; import { patcherByProxy } from "../src/settings-patcher/index.js"; -import { patchClaudeSettings, restoreClaudeSettings, detectPatchState } from "../src/settings-patcher/claude.js"; -import { patchCodexSettings, restoreCodexSettings, detectCodexPatchState } from "../src/settings-patcher/codex.js"; +import { + patchClaudeSettings, + restoreClaudeSettings, + detectPatchState, +} from "../src/settings-patcher/claude.js"; +import { + patchCodexSettings, + restoreCodexSettings, + detectCodexPatchState, +} from "../src/settings-patcher/codex.js"; import type { Settings, SettingsPaths } from "../src/settings-patcher/types.js"; import type { ExecFn } from "../src/utils/child-process.js"; @@ -111,7 +119,12 @@ describe("patchClaudeSettings", () => { }); it("uses custom auth token when provided", async () => { - await patchClaudeSettings({ port: 8080, logger, authToken: "custom-token", paths }); + await patchClaudeSettings({ + port: 8080, + logger, + authToken: "custom-token", + paths, + }); const settings = readSettings(); expect(settings.env?.ANTHROPIC_AUTH_TOKEN).toBe("custom-token"); @@ -251,7 +264,9 @@ describe("full lifecycle", () => { }); it("user edits between sessions are preserved", async () => { - const original: Settings = { env: { ANTHROPIC_BASE_URL: ORIGINAL_ANTHROPIC_URL } }; + const original: Settings = { + env: { ANTHROPIC_BASE_URL: ORIGINAL_ANTHROPIC_URL }, + }; writeSettings(original); await patchClaudeSettings({ port: 8080, logger, paths }); @@ -294,7 +309,9 @@ function createMockLaunchctl(): { exec: ExecFn; env: Map } { if (val === undefined) return Promise.reject(new Error("not set")); return Promise.resolve(val + "\n"); } - return Promise.reject(new Error(`unexpected launchctl subcommand: ${String(sub)}`)); + return Promise.reject( + new Error(`unexpected launchctl subcommand: ${String(sub)}`), + ); }; return { exec, env }; } @@ -309,14 +326,24 @@ describe("patchCodexSettings", () => { }); it("sets OPENAI_BASE_URL and OPENAI_API_KEY", async () => { - await patchCodexSettings({ port: 8080, logger, exec: mock.exec, backupFile }); + await patchCodexSettings({ + port: 8080, + logger, + exec: mock.exec, + backupFile, + }); expect(mock.env.get("OPENAI_BASE_URL")).toBe(codexBaseUrl(8080)); expect(mock.env.get("OPENAI_API_KEY")).toBe("xcode-copilot"); }); it("creates backup file with null values when no previous env vars", async () => { - await patchCodexSettings({ port: 8080, logger, exec: mock.exec, backupFile }); + await patchCodexSettings({ + port: 8080, + logger, + exec: mock.exec, + backupFile, + }); expect(existsSync(backupFile)).toBe(true); const backup = JSON.parse(readFileSync(backupFile, "utf-8")); @@ -328,7 +355,12 @@ describe("patchCodexSettings", () => { mock.env.set("OPENAI_BASE_URL", "https://api.openai.com/v1"); mock.env.set("OPENAI_API_KEY", "sk-real-key"); - await patchCodexSettings({ port: 3000, logger, exec: mock.exec, backupFile }); + await patchCodexSettings({ + port: 3000, + logger, + exec: mock.exec, + backupFile, + }); const backup = JSON.parse(readFileSync(backupFile, "utf-8")); expect(backup.OPENAI_BASE_URL).toBe("https://api.openai.com/v1"); @@ -342,13 +374,23 @@ describe("patchCodexSettings", () => { it("does not overwrite backup on second patch (crash recovery)", async () => { mock.env.set("OPENAI_API_KEY", "sk-original"); - await patchCodexSettings({ port: 8080, logger, exec: mock.exec, backupFile }); + await patchCodexSettings({ + port: 8080, + logger, + exec: mock.exec, + backupFile, + }); const firstBackup = readFileSync(backupFile, "utf-8"); expect(JSON.parse(firstBackup).OPENAI_API_KEY).toBe("sk-original"); // Second patch (simulating restart after crash) - await patchCodexSettings({ port: 9090, logger, exec: mock.exec, backupFile }); + await patchCodexSettings({ + port: 9090, + logger, + exec: mock.exec, + backupFile, + }); // Backup still has the original value const secondBackup = readFileSync(backupFile, "utf-8"); @@ -372,7 +414,12 @@ describe("restoreCodexSettings", () => { mock.env.set("OPENAI_BASE_URL", "https://api.openai.com/v1"); mock.env.set("OPENAI_API_KEY", "sk-real-key"); - await patchCodexSettings({ port: 8080, logger, exec: mock.exec, backupFile }); + await patchCodexSettings({ + port: 8080, + logger, + exec: mock.exec, + backupFile, + }); await restoreCodexSettings({ logger, exec: mock.exec, backupFile }); expect(mock.env.get("OPENAI_BASE_URL")).toBe("https://api.openai.com/v1"); @@ -381,7 +428,12 @@ describe("restoreCodexSettings", () => { }); it("unsets env vars when backup has null values", async () => { - await patchCodexSettings({ port: 8080, logger, exec: mock.exec, backupFile }); + await patchCodexSettings({ + port: 8080, + logger, + exec: mock.exec, + backupFile, + }); expect(mock.env.has("OPENAI_BASE_URL")).toBe(true); expect(mock.env.has("OPENAI_API_KEY")).toBe(true); @@ -412,23 +464,45 @@ describe("detectCodexPatchState", () => { }); it("returns unpatched when no backup exists", async () => { - const result = await detectCodexPatchState({ logger, exec: mock.exec, backupFile }); + const result = await detectCodexPatchState({ + logger, + exec: mock.exec, + backupFile, + }); expect(result.patched).toBe(false); }); it("returns patched with port when backup exists", async () => { - await patchCodexSettings({ port: 8080, logger, exec: mock.exec, backupFile }); + await patchCodexSettings({ + port: 8080, + logger, + exec: mock.exec, + backupFile, + }); - const result = await detectCodexPatchState({ logger, exec: mock.exec, backupFile }); + const result = await detectCodexPatchState({ + logger, + exec: mock.exec, + backupFile, + }); expect(result.patched).toBe(true); expect(result.port).toBe(8080); }); it("returns unpatched after restore", async () => { - await patchCodexSettings({ port: 8080, logger, exec: mock.exec, backupFile }); + await patchCodexSettings({ + port: 8080, + logger, + exec: mock.exec, + backupFile, + }); await restoreCodexSettings({ logger, exec: mock.exec, backupFile }); - const result = await detectCodexPatchState({ logger, exec: mock.exec, backupFile }); + const result = await detectCodexPatchState({ + logger, + exec: mock.exec, + backupFile, + }); expect(result.patched).toBe(false); }); }); diff --git a/proxy-server/test/shutdown.test.ts b/proxy-server/test/shutdown.test.ts index a7fde87..2e90309 100644 --- a/proxy-server/test/shutdown.test.ts +++ b/proxy-server/test/shutdown.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { registerShutdownHandlers } from "../src/shutdown.js"; -function createMockContext(overrides?: Partial[0]>) { +function createMockContext( + overrides?: Partial[0]>, +) { return { app: { close: vi.fn().mockResolvedValue(undefined) }, service: { stop: vi.fn().mockResolvedValue(undefined) }, @@ -23,7 +25,10 @@ describe("registerShutdownHandlers", () => { beforeEach(() => { listeners.clear(); - vi.spyOn(process, "on").mockImplementation(((event: string, fn: SignalHandler) => { + vi.spyOn(process, "on").mockImplementation((( + event: string, + fn: SignalHandler, + ) => { const fns = listeners.get(event) ?? []; fns.push(fn); listeners.set(event, fns); @@ -54,9 +59,11 @@ describe("registerShutdownHandlers", () => { }); it("sets idle timer when idleTimeoutMinutes > 0", () => { - const spy = vi.spyOn(global, "setInterval").mockReturnValue( - { unref: vi.fn() } as unknown as ReturnType, - ); + const spy = vi + .spyOn(global, "setInterval") + .mockReturnValue({ unref: vi.fn() } as unknown as ReturnType< + typeof setInterval + >); const ctx = createMockContext({ idleTimeoutMinutes: 5 }); registerShutdownHandlers(ctx); diff --git a/proxy-server/test/streaming-integration.test.ts b/proxy-server/test/streaming-integration.test.ts index 55b97bc..99266a0 100644 --- a/proxy-server/test/streaming-integration.test.ts +++ b/proxy-server/test/streaming-integration.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, afterEach } from "vitest"; import type { FastifyInstance } from "fastify"; -import type { SessionEvent, SessionEventHandler, CopilotSession } from "@github/copilot-sdk"; +import type { + SessionEvent, + SessionEventHandler, + CopilotSession, +} from "@github/copilot-sdk"; import { createServer, Logger, Stats } from "copilot-sdk-proxy"; import { claudeProvider } from "../src/providers/claude/provider.js"; import { codexProvider } from "../src/providers/codex/provider.js"; @@ -10,9 +14,15 @@ 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"; -const BASE_EVENT = { id: "e1", timestamp: new Date().toISOString(), parentId: null }; +const BASE_EVENT = { + id: "e1", + timestamp: new Date().toISOString(), + parentId: null, +}; -type EventSequence = (emit: (type: string, data: Record) => void) => void; +type EventSequence = ( + emit: (type: string, data: Record) => void, +) => void; function createMockSession(sequence: EventSequence): CopilotSession { let handler: SessionEventHandler | null = null; @@ -24,7 +34,9 @@ function createMockSession(sequence: EventSequence): CopilotSession { return { on(h: SessionEventHandler) { handler = h; - return () => { handler = null; }; + return () => { + handler = null; + }; }, abort: () => Promise.resolve(), setModel: () => Promise.resolve(), @@ -44,9 +56,15 @@ function standardSequence(opts: { return (emit) => { if (opts.reasoning) { for (const text of opts.reasoning) { - emit("assistant.reasoning_delta", { reasoningId: "r1", deltaContent: text }); + emit("assistant.reasoning_delta", { + reasoningId: "r1", + deltaContent: text, + }); } - emit("assistant.reasoning", { reasoningId: "r1", content: opts.reasoning.join("") }); + emit("assistant.reasoning", { + reasoningId: "r1", + content: opts.reasoning.join(""), + }); } if (opts.compaction) { @@ -80,16 +98,26 @@ function standardSequence(opts: { toolRequests: [], }); - emit("assistant.usage", { inputTokens: 10, outputTokens: 5, model: "test-model" }); + emit("assistant.usage", { + inputTokens: 10, + outputTokens: 5, + model: "test-model", + }); emit("session.idle", {}); }; } -function errorSequence(opts: { deltasBeforeError?: string[]; errorMessage: string }): EventSequence { +function errorSequence(opts: { + deltasBeforeError?: string[]; + errorMessage: string; +}): EventSequence { return (emit) => { if (opts.deltasBeforeError) { for (const text of opts.deltasBeforeError) { - emit("assistant.message_delta", { messageId: "m1", deltaContent: text }); + emit("assistant.message_delta", { + messageId: "m1", + deltaContent: text, + }); } } emit("session.error", { message: opts.errorMessage }); @@ -136,14 +164,21 @@ const toolBridgeConfig: ServerConfig = { toolBridge: true, }; -function createCtx(sequence: EventSequence, overrideConfig?: ServerConfig): AppContext { +function createCtx( + sequence: EventSequence, + overrideConfig?: ServerConfig, +): AppContext { return { service: { cwd: process.cwd(), createSession: () => Promise.resolve(createMockSession(sequence)), - listModels: () => Promise.resolve([ - { id: "test-model", capabilities: { supports: { reasoningEffort: false } } }, - ]), + 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"), @@ -153,7 +188,10 @@ function createCtx(sequence: EventSequence, overrideConfig?: ServerConfig): AppC }; } -function collectTextContent(events: unknown[], provider: "openai" | "claude" | "codex"): string { +function collectTextContent( + events: unknown[], + provider: "openai" | "claude" | "codex", +): string { if (provider === "openai") { return (events as { choices?: { delta?: { content?: string } }[] }[]) .flatMap((e) => e.choices ?? []) @@ -163,8 +201,13 @@ function collectTextContent(events: unknown[], provider: "openai" | "claude" | " } if (provider === "claude") { - return (events as { type?: string; delta?: { type?: string; text?: string } }[]) - .filter((e) => e.type === "content_block_delta" && e.delta?.type === "text_delta") + return ( + events as { type?: string; delta?: { type?: string; text?: string } }[] + ) + .filter( + (e) => + e.type === "content_block_delta" && e.delta?.type === "text_delta", + ) .map((e) => e.delta?.text ?? "") .join(""); } @@ -175,18 +218,27 @@ function collectTextContent(events: unknown[], provider: "openai" | "claude" | " .join(""); } -async function createApp(ctx: AppContext, provider: Provider): Promise { +async function createApp( + ctx: AppContext, + provider: Provider, +): Promise { return createServer(ctx, provider); } const claudeHeaders = { "user-agent": "claude-cli/1.0" }; -const codexHeaders = { "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0" }; -const xcodeHeaders = { "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0" }; +const codexHeaders = { + "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0", +}; +const xcodeHeaders = { + "user-agent": "Xcode/24577 CFNetwork/3860.300.31 Darwin/25.2.0", +}; describe("OpenAI streaming integration", () => { let app: FastifyInstance; - afterEach(async () => { await app.close(); }); + afterEach(async () => { + await app.close(); + }); it("handles session error", async () => { const ctx = createCtx(errorSequence({ errorMessage: "backend exploded" })); @@ -196,7 +248,10 @@ describe("OpenAI streaming integration", () => { method: "POST", url: "/v1/chat/completions", headers: { ...xcodeHeaders, "content-type": "application/json" }, - payload: { model: "test-model", messages: [{ role: "user", content: "Hi" }] }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Hi" }], + }, }); expect(res.statusCode).toBe(200); @@ -204,17 +259,22 @@ describe("OpenAI streaming integration", () => { }); it("handles session error after partial deltas", async () => { - const ctx = createCtx(errorSequence({ - deltasBeforeError: ["Partial"], - errorMessage: "connection lost", - })); + const ctx = createCtx( + errorSequence({ + deltasBeforeError: ["Partial"], + errorMessage: "connection lost", + }), + ); 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" }] }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Hi" }], + }, }); // Stream still completes (HTTP 200 was already sent) @@ -222,33 +282,45 @@ describe("OpenAI streaming integration", () => { }); it("streams with compaction mid-session", async () => { - const ctx = createCtx(standardSequence({ deltas: ["Compacted"], compaction: true })); + const ctx = createCtx( + standardSequence({ deltas: ["Compacted"], compaction: true }), + ); 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" }] }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Hi" }], + }, }); expect(res.statusCode).toBe(200); - expect(collectTextContent(parseSSELines(res.body), "openai")).toBe("Compacted"); + expect(collectTextContent(parseSSELines(res.body), "openai")).toBe( + "Compacted", + ); expect(res.body).toContain("data: [DONE]"); }); it("streams with tool execution events", async () => { - const ctx = createCtx(standardSequence({ - deltas: ["Done"], - toolCall: { id: "tc1", name: "read_file", args: { path: "/tmp" } }, - })); + const ctx = createCtx( + standardSequence({ + deltas: ["Done"], + toolCall: { id: "tc1", name: "read_file", args: { path: "/tmp" } }, + }), + ); 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: "Read file" }] }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Read file" }], + }, }); expect(res.statusCode).toBe(200); @@ -256,35 +328,50 @@ describe("OpenAI streaming integration", () => { }); it("streams with reasoning deltas", async () => { - const ctx = createCtx(standardSequence({ deltas: ["Answer"], reasoning: ["Let me", " think"] })); + const ctx = createCtx( + standardSequence({ deltas: ["Answer"], reasoning: ["Let me", " think"] }), + ); 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: "Think hard" }] }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Think hard" }], + }, }); expect(res.statusCode).toBe(200); - expect(collectTextContent(parseSSELines(res.body), "openai")).toBe("Answer"); + expect(collectTextContent(parseSSELines(res.body), "openai")).toBe( + "Answer", + ); }); }); describe("Claude streaming integration", () => { let app: FastifyInstance; - afterEach(async () => { await app.close(); }); + afterEach(async () => { + await app.close(); + }); it("streams with compaction mid-session", async () => { - const ctx = createCtx(standardSequence({ deltas: ["OK"], compaction: true })); + const ctx = createCtx( + standardSequence({ deltas: ["OK"], compaction: true }), + ); 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 }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 100, + }, }); expect(res.statusCode).toBe(200); @@ -299,41 +386,59 @@ describe("Claude streaming integration", () => { method: "POST", url: "/v1/messages", headers: { ...claudeHeaders, "content-type": "application/json" }, - payload: { model: "test-model", messages: [{ role: "user", content: "Hi" }], max_tokens: 100 }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 100, + }, }); expect(res.statusCode).toBe(200); const events = parseSSELines(res.body) as Record[]; const messageDelta = events.find( - (e) => e.type === "message_delta" && (e.delta as Record).stop_reason === "end_turn", + (e) => + e.type === "message_delta" && + (e.delta as Record).stop_reason === "end_turn", ); expect(messageDelta).toBeDefined(); }); it("streams reasoning as thinking blocks", async () => { - const ctx = createCtx(standardSequence({ deltas: ["Answer"], reasoning: ["Thinking..."] })); + const ctx = createCtx( + standardSequence({ deltas: ["Answer"], reasoning: ["Thinking..."] }), + ); 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: "Think" }], max_tokens: 100 }, + payload: { + model: "test-model", + messages: [{ role: "user", content: "Think" }], + max_tokens: 100, + }, }); expect(res.statusCode).toBe(200); const events = parseSSELines(res.body) as Record[]; const thinkingStart = events.find( - (e) => e.type === "content_block_start" && (e.content_block as Record).type === "thinking", + (e) => + e.type === "content_block_start" && + (e.content_block as Record).type === "thinking", ); expect(thinkingStart).toBeDefined(); const thinkingDelta = events.find( - (e) => e.type === "content_block_delta" && (e.delta as Record).type === "thinking_delta", + (e) => + e.type === "content_block_delta" && + (e.delta as Record).type === "thinking_delta", ); expect(thinkingDelta).toBeDefined(); - expect((thinkingDelta!.delta as Record).thinking).toBe("Thinking..."); + expect((thinkingDelta!.delta as Record).thinking).toBe( + "Thinking...", + ); expect(collectTextContent(events, "claude")).toBe("Answer"); }); }); @@ -341,7 +446,9 @@ describe("Claude streaming integration", () => { describe("Codex streaming integration", () => { let app: FastifyInstance; - afterEach(async () => { await app.close(); }); + afterEach(async () => { + await app.close(); + }); it("handles session error with failed status", async () => { const ctx = createCtx(errorSequence({ errorMessage: "timeout" })); @@ -361,7 +468,9 @@ describe("Codex streaming integration", () => { }); it("streams reasoning as reasoning summary events", async () => { - const ctx = createCtx(standardSequence({ deltas: ["Answer"], reasoning: ["Deep thought"] })); + const ctx = createCtx( + standardSequence({ deltas: ["Answer"], reasoning: ["Deep thought"] }), + ); app = await createApp(ctx, codexProvider); const res = await app.inject({ @@ -374,7 +483,9 @@ describe("Codex streaming integration", () => { expect(res.statusCode).toBe(200); const events = parseSSELines(res.body) as Record[]; - const reasoningDelta = events.find((e) => e.type === "response.reasoning_summary_text.delta"); + const reasoningDelta = events.find( + (e) => e.type === "response.reasoning_summary_text.delta", + ); expect(reasoningDelta).toBeDefined(); expect(reasoningDelta!.delta).toBe("Deep thought"); expect(collectTextContent(events, "codex")).toBe("Answer"); @@ -384,14 +495,20 @@ describe("Codex streaming integration", () => { describe("Tool bridge integration — Claude", () => { let app: FastifyInstance; - afterEach(async () => { await app.close(); }); + afterEach(async () => { + await app.close(); + }); it("emits tool_use blocks when model requests bridge tools", async () => { const ctx = createCtx( toolRequestSequence({ deltas: ["Let me check"], toolRequests: [ - { toolCallId: "tc1", name: `${BRIDGE_TOOL_PREFIX}XcodeRead`, arguments: { path: "/src" } }, + { + toolCallId: "tc1", + name: `${BRIDGE_TOOL_PREFIX}XcodeRead`, + arguments: { path: "/src" }, + }, ], }), toolBridgeConfig, @@ -406,7 +523,13 @@ describe("Tool bridge integration — Claude", () => { model: "test-model", messages: [{ role: "user", content: "Read my code" }], max_tokens: 100, - tools: [{ name: "XcodeRead", description: "Read file", input_schema: { type: "object" } }], + tools: [ + { + name: "XcodeRead", + description: "Read file", + input_schema: { type: "object" }, + }, + ], }, }); @@ -414,7 +537,9 @@ describe("Tool bridge integration — Claude", () => { const events = parseSSELines(res.body) as Record[]; const toolUseStart = events.find( - (e) => e.type === "content_block_start" && (e.content_block as Record).type === "tool_use", + (e) => + e.type === "content_block_start" && + (e.content_block as Record).type === "tool_use", ); expect(toolUseStart).toBeDefined(); @@ -423,7 +548,9 @@ describe("Tool bridge integration — Claude", () => { expect(block.name).toBe("XcodeRead"); const inputDelta = events.find( - (e) => e.type === "content_block_delta" && (e.delta as Record).type === "input_json_delta", + (e) => + e.type === "content_block_delta" && + (e.delta as Record).type === "input_json_delta", ); expect(inputDelta).toBeDefined(); }); @@ -432,14 +559,20 @@ describe("Tool bridge integration — Claude", () => { describe("Tool bridge integration — Codex", () => { let app: FastifyInstance; - afterEach(async () => { await app.close(); }); + afterEach(async () => { + await app.close(); + }); it("emits function_call items when model requests bridge tools", async () => { const ctx = createCtx( toolRequestSequence({ deltas: ["Let me check"], toolRequests: [ - { toolCallId: "tc1", name: `${BRIDGE_TOOL_PREFIX}XcodeRead`, arguments: { path: "/src" } }, + { + toolCallId: "tc1", + name: `${BRIDGE_TOOL_PREFIX}XcodeRead`, + arguments: { path: "/src" }, + }, ], }), toolBridgeConfig, @@ -453,7 +586,14 @@ describe("Tool bridge integration — Codex", () => { payload: { model: "test-model", input: "Read my code", - tools: [{ type: "function", name: "XcodeRead", description: "Read file", parameters: { type: "object" } }], + tools: [ + { + type: "function", + name: "XcodeRead", + description: "Read file", + parameters: { type: "object" }, + }, + ], }, }); @@ -462,7 +602,9 @@ describe("Tool bridge integration — Codex", () => { // The first output_item.added is the "message" item, not the function_call const fcAdded = events.find( - (e) => e.type === "response.output_item.added" && (e as { item?: { type?: string } }).item?.type === "function_call", + (e) => + e.type === "response.output_item.added" && + (e as { item?: { type?: string } }).item?.type === "function_call", ); expect(fcAdded).toBeDefined(); @@ -475,10 +617,15 @@ describe("Tool bridge integration — Codex", () => { describe("MCP routes", () => { let app: FastifyInstance; - afterEach(async () => { await app.close(); }); + afterEach(async () => { + await app.close(); + }); it("responds to initialize with protocol version and capabilities", async () => { - const ctx = createCtx(standardSequence({ deltas: ["x"] }), toolBridgeConfig); + const ctx = createCtx( + standardSequence({ deltas: ["x"] }), + toolBridgeConfig, + ); app = await createApp(ctx, claudeProvider); const res = await app.inject({ @@ -503,7 +650,10 @@ describe("MCP routes", () => { }); it("returns method not found for unknown methods", async () => { - const ctx = createCtx(standardSequence({ deltas: ["x"] }), toolBridgeConfig); + const ctx = createCtx( + standardSequence({ deltas: ["x"] }), + toolBridgeConfig, + ); app = await createApp(ctx, claudeProvider); const res = await app.inject({ @@ -523,7 +673,10 @@ describe("MCP routes", () => { }); it("returns parse error for invalid JSON-RPC", async () => { - const ctx = createCtx(standardSequence({ deltas: ["x"] }), toolBridgeConfig); + const ctx = createCtx( + standardSequence({ deltas: ["x"] }), + toolBridgeConfig, + ); app = await createApp(ctx, claudeProvider); const res = await app.inject({ @@ -539,7 +692,10 @@ describe("MCP routes", () => { }); it("accepts notifications (no id) with 202", async () => { - const ctx = createCtx(standardSequence({ deltas: ["x"] }), toolBridgeConfig); + const ctx = createCtx( + standardSequence({ deltas: ["x"] }), + toolBridgeConfig, + ); app = await createApp(ctx, claudeProvider); const res = await app.inject({ @@ -561,7 +717,9 @@ describe("MCP routes", () => { describe("GET /health", () => { let app: FastifyInstance; - afterEach(async () => { await app.close(); }); + afterEach(async () => { + await app.close(); + }); it("returns 200 with status ok when ping succeeds", async () => { const ctx = createCtx(standardSequence({ deltas: ["x"] })); diff --git a/proxy-server/test/tool-bridge/index.test.ts b/proxy-server/test/tool-bridge/index.test.ts index 4948610..4a00c81 100644 --- a/proxy-server/test/tool-bridge/index.test.ts +++ b/proxy-server/test/tool-bridge/index.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from "vitest"; import Fastify from "fastify"; import { Logger } from "copilot-sdk-proxy"; -import { registerToolBridge, resolveToolBridgeManager } from "../../src/tool-bridge/index.js"; +import { + registerToolBridge, + resolveToolBridgeManager, +} from "../../src/tool-bridge/index.js"; const logger = new Logger("none"); @@ -14,7 +17,11 @@ describe("registerToolBridge", () => { expect(manager).toBeDefined(); expect(typeof manager.create).toBe("function"); - const resp = await app.inject({ method: "POST", url: "/mcp/test-conv", payload: {} }); + const resp = await app.inject({ + method: "POST", + url: "/mcp/test-conv", + payload: {}, + }); expect(resp.statusCode).not.toBe(404); await app.close(); diff --git a/proxy-server/test/tool-bridge/mcp-routes.test.ts b/proxy-server/test/tool-bridge/mcp-routes.test.ts index b53ecfc..037344a 100644 --- a/proxy-server/test/tool-bridge/mcp-routes.test.ts +++ b/proxy-server/test/tool-bridge/mcp-routes.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; import Fastify from "fastify"; import type { FastifyInstance } from "fastify"; import { registerRoutes } from "../../src/tool-bridge/routes.js"; -import { JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, JSONRPC_METHOD_NOT_FOUND } from "../../src/tool-bridge/constants.js"; +import { + JSONRPC_INTERNAL_ERROR, + JSONRPC_INVALID_PARAMS, + JSONRPC_METHOD_NOT_FOUND, +} from "../../src/tool-bridge/constants.js"; import { ConversationManager } from "../../src/conversation-manager.js"; import { Logger } from "copilot-sdk-proxy"; @@ -22,8 +26,17 @@ afterAll(async () => { await app.close(); }); -function jsonRpc(method: string, id?: number | string, params?: Record) { - return { jsonrpc: "2.0", method, ...(id !== undefined && { id }), ...(params && { params }) } as Record; +function jsonRpc( + method: string, + id?: number | string, + params?: Record, +) { + return { + jsonrpc: "2.0", + method, + ...(id !== undefined && { id }), + ...(params && { params }), + } as Record; } describe("POST /mcp/:convId — initialize", () => { @@ -58,8 +71,16 @@ describe("POST /mcp/:convId — tools/list", () => { it("returns cached tools for existing conversation", async () => { const conv = manager.create(); conv.state.toolCache.cacheTools([ - { name: "mcp__xcode-tools__XcodeRead", description: "Read a file", input_schema: { type: "object", properties: {} } }, - { name: "mcp__xcode-tools__XcodeWrite", description: "Write a file", input_schema: { type: "object", properties: {} } }, + { + name: "mcp__xcode-tools__XcodeRead", + description: "Read a file", + input_schema: { type: "object", properties: {} }, + }, + { + name: "mcp__xcode-tools__XcodeWrite", + description: "Write a file", + input_schema: { type: "object", properties: {} }, + }, ]); const res = await app.inject({ @@ -72,16 +93,31 @@ describe("POST /mcp/:convId — tools/list", () => { expect(body.id).toBe(2); expect(body.result.tools).toHaveLength(2); expect(body.result.tools[0].name).toBe("XcodeRead"); - expect(body.result.tools[0].inputSchema).toEqual({ type: "object", properties: {} }); + expect(body.result.tools[0].inputSchema).toEqual({ + type: "object", + properties: {}, + }); expect(body.result.tools[1].name).toBe("XcodeWrite"); }); it("strips mcp__{server}__ prefix from tool names", async () => { const conv = manager.create(); conv.state.toolCache.cacheTools([ - { name: "mcp__xcode-tools__XcodeRead", description: "Read", input_schema: { type: "object", properties: {} } }, - { name: "mcp__xcode-tools__XcodeWrite", description: "Write", input_schema: { type: "object", properties: {} } }, - { name: "Glob", description: "Glob", input_schema: { type: "object", properties: {} } }, + { + name: "mcp__xcode-tools__XcodeRead", + description: "Read", + input_schema: { type: "object", properties: {} }, + }, + { + name: "mcp__xcode-tools__XcodeWrite", + description: "Write", + input_schema: { type: "object", properties: {} }, + }, + { + name: "Glob", + description: "Glob", + input_schema: { type: "object", properties: {} }, + }, ]); const res = await app.inject({ @@ -114,7 +150,11 @@ describe("POST /mcp/:convId — tools/call", () => { it("calls registerMCPRequest and returns result", async () => { const conv = manager.create(); conv.state.toolCache.cacheTools([ - { name: "Read", description: "Read a file", input_schema: { type: "object", properties: {} } }, + { + name: "Read", + description: "Read a file", + input_schema: { type: "object", properties: {} }, + }, ]); vi.spyOn(conv.state.toolRouter, "registerMCPRequest").mockImplementation( @@ -126,18 +166,27 @@ describe("POST /mcp/:convId — tools/call", () => { const res = await app.inject({ method: "POST", url: `/mcp/${conv.id}`, - payload: jsonRpc("tools/call", 4, { name: "Read", arguments: { path: "/test.txt" } }), + payload: jsonRpc("tools/call", 4, { + name: "Read", + arguments: { path: "/test.txt" }, + }), }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.id).toBe(4); - expect(body.result.content).toEqual([{ type: "text", text: "file contents here" }]); + expect(body.result.content).toEqual([ + { type: "text", text: "file contents here" }, + ]); }); it("returns error when registerMCPRequest rejects", async () => { const conv = manager.create(); conv.state.toolCache.cacheTools([ - { name: "Read", description: "Read a file", input_schema: { type: "object", properties: {} } }, + { + name: "Read", + description: "Read a file", + input_schema: { type: "object", properties: {} }, + }, ]); vi.spyOn(conv.state.toolRouter, "registerMCPRequest").mockImplementation( @@ -187,7 +236,11 @@ describe("POST /mcp/:convId — tools/call", () => { it("resolves hallucinated tool names", async () => { const conv = manager.create(); conv.state.toolCache.cacheTools([ - { name: "mcp__xcode-tools__XcodeRead", description: "Read", input_schema: { type: "object", properties: {} } }, + { + name: "mcp__xcode-tools__XcodeRead", + description: "Read", + input_schema: { type: "object", properties: {} }, + }, ]); vi.spyOn(conv.state.toolRouter, "registerMCPRequest").mockImplementation( @@ -203,7 +256,9 @@ describe("POST /mcp/:convId — tools/call", () => { }); expect(res.statusCode).toBe(200); const body = res.json(); - expect(body.result.content[0].text).toBe("called: mcp__xcode-tools__XcodeRead"); + expect(body.result.content[0].text).toBe( + "called: mcp__xcode-tools__XcodeRead", + ); }); }); diff --git a/proxy-server/test/tool-bridge/reply-tracker.test.ts b/proxy-server/test/tool-bridge/reply-tracker.test.ts index 64c8246..acc9d47 100644 --- a/proxy-server/test/tool-bridge/reply-tracker.test.ts +++ b/proxy-server/test/tool-bridge/reply-tracker.test.ts @@ -27,7 +27,9 @@ describe("ReplyTracker", () => { it("notifyStreamingDone is safe to call without a waiter", () => { const tracker = new ReplyTracker(); - expect(() => { tracker.notifyStreamingDone(); }).not.toThrow(); + expect(() => { + tracker.notifyStreamingDone(); + }).not.toThrow(); }); it("supports multiple concurrent waiters", async () => { diff --git a/proxy-server/test/tool-bridge/state.test.ts b/proxy-server/test/tool-bridge/state.test.ts index 95f1bd3..93f67b7 100644 --- a/proxy-server/test/tool-bridge/state.test.ts +++ b/proxy-server/test/tool-bridge/state.test.ts @@ -5,7 +5,11 @@ describe("ToolBridgeState", () => { it("exposes toolCache for caching and resolving tools", () => { const state = new ToolBridgeState(); const tools = [ - { name: "Read", description: "", input_schema: { type: "object" as const, properties: {} } }, + { + name: "Read", + description: "", + input_schema: { type: "object" as const, properties: {} }, + }, ]; state.toolCache.cacheTools(tools); expect(state.toolCache.getCachedTools()).toBe(tools); diff --git a/proxy-server/test/tool-bridge/tool-cache.test.ts b/proxy-server/test/tool-bridge/tool-cache.test.ts index c015a80..cbfd8a7 100644 --- a/proxy-server/test/tool-bridge/tool-cache.test.ts +++ b/proxy-server/test/tool-bridge/tool-cache.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest"; import { ToolCache } from "../../src/tool-bridge/tool-cache.js"; function makeTool(name: string) { - return { name, description: "", input_schema: { type: "object" as const, properties: {} } }; + return { + name, + description: "", + input_schema: { type: "object" as const, properties: {} }, + }; } describe("ToolCache", () => { @@ -23,13 +27,17 @@ describe("ToolCache", () => { it("returns exact match unchanged", () => { const cache = new ToolCache(); cache.cacheTools([makeTool("mcp__xcode-tools__XcodeRead")]); - expect(cache.resolveToolName("mcp__xcode-tools__XcodeRead")).toBe("mcp__xcode-tools__XcodeRead"); + expect(cache.resolveToolName("mcp__xcode-tools__XcodeRead")).toBe( + "mcp__xcode-tools__XcodeRead", + ); }); it("resolves a hallucinated short name via suffix match", () => { const cache = new ToolCache(); cache.cacheTools([makeTool("mcp__xcode-tools__XcodeRead")]); - expect(cache.resolveToolName("XcodeRead")).toBe("mcp__xcode-tools__XcodeRead"); + expect(cache.resolveToolName("XcodeRead")).toBe( + "mcp__xcode-tools__XcodeRead", + ); }); it("returns name as-is when no cached tools match", () => { @@ -59,80 +67,114 @@ describe("ToolCache", () => { }); describe("normalizeArgs", () => { - function makeToolWithSchema(name: string, properties: Record) { - return { name, description: "", input_schema: { type: "object" as const, properties } }; + function makeToolWithSchema( + name: string, + properties: Record, + ) { + return { + name, + description: "", + input_schema: { type: "object" as const, properties }, + }; } it("returns args unchanged when all keys match the schema", () => { const cache = new ToolCache(); - cache.cacheTools([makeToolWithSchema("Grep", { - pattern: { type: "string" }, - "-i": { type: "boolean" }, - })]); + cache.cacheTools([ + makeToolWithSchema("Grep", { + pattern: { type: "string" }, + "-i": { type: "boolean" }, + }), + ]); const args = { pattern: "foo", "-i": true }; expect(cache.normalizeArgs("Grep", args)).toEqual(args); }); it("converts camelCase keys to snake_case", () => { const cache = new ToolCache(); - cache.cacheTools([makeToolWithSchema("Grep", { - output_mode: { type: "string" }, - head_limit: { type: "number" }, - })]); - expect(cache.normalizeArgs("Grep", { outputMode: "content", headLimit: 10 })) - .toEqual({ output_mode: "content", head_limit: 10 }); + cache.cacheTools([ + makeToolWithSchema("Grep", { + output_mode: { type: "string" }, + head_limit: { type: "number" }, + }), + ]); + expect( + cache.normalizeArgs("Grep", { outputMode: "content", headLimit: 10 }), + ).toEqual({ output_mode: "content", head_limit: 10 }); }); it("converts snake_case keys to camelCase", () => { const cache = new ToolCache(); - cache.cacheTools([makeToolWithSchema("XcodeRead", { - filePath: { type: "string" }, - })]); - expect(cache.normalizeArgs("XcodeRead", { file_path: "/foo.swift" })) - .toEqual({ filePath: "/foo.swift" }); + cache.cacheTools([ + makeToolWithSchema("XcodeRead", { + filePath: { type: "string" }, + }), + ]); + expect( + cache.normalizeArgs("XcodeRead", { file_path: "/foo.swift" }), + ).toEqual({ filePath: "/foo.swift" }); }); it("resolves CLI flag aliases", () => { const cache = new ToolCache(); - cache.cacheTools([makeToolWithSchema("Grep", { - "-i": { type: "boolean" }, - "-n": { type: "boolean" }, - "-A": { type: "number" }, - "-B": { type: "number" }, - })]); - expect(cache.normalizeArgs("Grep", { - ignoreCase: true, - lineNumbers: true, - afterContext: 3, - beforeContext: 2, - })).toEqual({ "-i": true, "-n": true, "-A": 3, "-B": 2 }); + cache.cacheTools([ + makeToolWithSchema("Grep", { + "-i": { type: "boolean" }, + "-n": { type: "boolean" }, + "-A": { type: "number" }, + "-B": { type: "number" }, + }), + ]); + expect( + cache.normalizeArgs("Grep", { + ignoreCase: true, + lineNumbers: true, + afterContext: 3, + beforeContext: 2, + }), + ).toEqual({ "-i": true, "-n": true, "-A": 3, "-B": 2 }); }); it("normalizes camelCase enum values to snake_case", () => { const cache = new ToolCache(); - cache.cacheTools([makeToolWithSchema("Grep", { - output_mode: { type: "string", enum: ["content", "files_with_matches", "count"] }, - })]); - expect(cache.normalizeArgs("Grep", { outputMode: "filesWithMatches" })) - .toEqual({ output_mode: "files_with_matches" }); + cache.cacheTools([ + makeToolWithSchema("Grep", { + output_mode: { + type: "string", + enum: ["content", "files_with_matches", "count"], + }, + }), + ]); + expect( + cache.normalizeArgs("Grep", { outputMode: "filesWithMatches" }), + ).toEqual({ output_mode: "files_with_matches" }); }); it("leaves enum values alone when they already match", () => { const cache = new ToolCache(); - cache.cacheTools([makeToolWithSchema("Grep", { - output_mode: { type: "string", enum: ["content", "files_with_matches", "count"] }, - })]); - expect(cache.normalizeArgs("Grep", { output_mode: "content" })) - .toEqual({ output_mode: "content" }); + cache.cacheTools([ + makeToolWithSchema("Grep", { + output_mode: { + type: "string", + enum: ["content", "files_with_matches", "count"], + }, + }), + ]); + expect(cache.normalizeArgs("Grep", { output_mode: "content" })).toEqual({ + output_mode: "content", + }); }); it("passes through unknown keys unchanged", () => { const cache = new ToolCache(); - cache.cacheTools([makeToolWithSchema("Grep", { - pattern: { type: "string" }, - })]); - expect(cache.normalizeArgs("Grep", { pattern: "foo", weird: 42 })) - .toEqual({ pattern: "foo", weird: 42 }); + cache.cacheTools([ + makeToolWithSchema("Grep", { + pattern: { type: "string" }, + }), + ]); + expect( + cache.normalizeArgs("Grep", { pattern: "foo", weird: 42 }), + ).toEqual({ pattern: "foo", weird: 42 }); }); it("returns args unchanged when tool has no schema properties", () => { @@ -150,18 +192,25 @@ describe("ToolCache", () => { it("handles mixed correct and incorrect keys together", () => { const cache = new ToolCache(); - cache.cacheTools([makeToolWithSchema("Grep", { - pattern: { type: "string" }, - "-i": { type: "boolean" }, - output_mode: { type: "string", enum: ["content", "files_with_matches", "count"] }, - glob: { type: "string" }, - })]); - expect(cache.normalizeArgs("Grep", { - pattern: "test", - ignoreCase: true, - outputMode: "filesWithMatches", - glob: "*.ts", - })).toEqual({ + cache.cacheTools([ + makeToolWithSchema("Grep", { + pattern: { type: "string" }, + "-i": { type: "boolean" }, + output_mode: { + type: "string", + enum: ["content", "files_with_matches", "count"], + }, + glob: { type: "string" }, + }), + ]); + expect( + cache.normalizeArgs("Grep", { + pattern: "test", + ignoreCase: true, + outputMode: "filesWithMatches", + glob: "*.ts", + }), + ).toEqual({ pattern: "test", "-i": true, output_mode: "files_with_matches", diff --git a/proxy-server/test/tool-bridge/tool-router.test.ts b/proxy-server/test/tool-bridge/tool-router.test.ts index afa6f3f..a489fc0 100644 --- a/proxy-server/test/tool-bridge/tool-router.test.ts +++ b/proxy-server/test/tool-bridge/tool-router.test.ts @@ -10,7 +10,11 @@ describe("ToolRouter", () => { it("returns true when tool call is in pendingByCallId", () => { const router = new ToolRouter(); router.registerExpected("tc-1", "Read"); - router.registerMCPRequest("Read", () => {}, () => {}); + router.registerMCPRequest( + "Read", + () => {}, + () => {}, + ); expect(router.hasPendingToolCall("tc-1")).toBe(true); }); @@ -41,7 +45,11 @@ describe("ToolRouter", () => { it("returns false after queue is fully drained", () => { const router = new ToolRouter(); router.registerExpected("tc-1", "Glob"); - router.registerMCPRequest("Glob", () => {}, () => {}); + router.registerMCPRequest( + "Glob", + () => {}, + () => {}, + ); expect(router.hasExpectedTool("Glob")).toBe(false); }); @@ -58,11 +66,19 @@ describe("ToolRouter", () => { router.registerExpected("tc-first", "Read"); router.registerExpected("tc-second", "Read"); - router.registerMCPRequest("Read", () => {}, () => {}); + router.registerMCPRequest( + "Read", + () => {}, + () => {}, + ); expect(router.hasPendingToolCall("tc-first")).toBe(true); expect(router.hasPendingToolCall("tc-second")).toBe(true); - router.registerMCPRequest("Read", () => {}, () => {}); + router.registerMCPRequest( + "Read", + () => {}, + () => {}, + ); expect(router.hasPendingToolCall("tc-second")).toBe(true); expect(router.hasExpectedTool("Read")).toBe(false); }); @@ -145,7 +161,11 @@ describe("ToolRouter", () => { it("removes the tool call from pending after resolution", () => { const router = new ToolRouter(); router.registerExpected("tc-1", "Read"); - router.registerMCPRequest("Read", () => {}, () => {}); + router.registerMCPRequest( + "Read", + () => {}, + () => {}, + ); router.resolveToolCall("tc-1", "ok"); expect(router.hasPendingToolCall("tc-1")).toBe(false); expect(router.hasPending).toBe(false); @@ -166,7 +186,11 @@ describe("ToolRouter", () => { it("returns true with pending MCP requests only", () => { const router = new ToolRouter(); router.registerExpected("tc-1", "Read"); - router.registerMCPRequest("Read", () => {}, () => {}); + router.registerMCPRequest( + "Read", + () => {}, + () => {}, + ); expect(router.hasPending).toBe(true); }); }); @@ -197,7 +221,9 @@ describe("ToolRouter", () => { vi.advanceTimersByTime(1); expect(reject).toHaveBeenCalledOnce(); - expect((reject.mock.calls[0]![0] as Error).message).toContain("timed out"); + expect((reject.mock.calls[0]![0] as Error).message).toContain( + "timed out", + ); expect(router.hasPendingToolCall("tc-1")).toBe(false); vi.useRealTimers(); }); @@ -237,7 +263,9 @@ describe("ToolRouter", () => { it("is safe to call on empty state", () => { const router = new ToolRouter(); - expect(() => { router.rejectAll("noop"); }).not.toThrow(); + expect(() => { + router.rejectAll("noop"); + }).not.toThrow(); }); }); }); diff --git a/proxy-server/test/utils/anthropic-prompt.test.ts b/proxy-server/test/utils/anthropic-prompt.test.ts index 761c313..43f6d9c 100644 --- a/proxy-server/test/utils/anthropic-prompt.test.ts +++ b/proxy-server/test/utils/anthropic-prompt.test.ts @@ -1,15 +1,18 @@ import { describe, it, expect } from "vitest"; -import { formatAnthropicPrompt, type AnthropicMessage } from "copilot-sdk-proxy"; +import { + formatAnthropicPrompt, + type AnthropicMessage, +} from "copilot-sdk-proxy"; import { filterExcludedFiles } from "../../src/providers/shared/prompt-utils.js"; describe("filterExcludedFiles (Anthropic)", () => { it("filters excluded file patterns from user content", () => { const fence = "```"; const userText = `Here are the results:\n${fence}swift:MockHelper.swift\nclass MockHelper {}\n${fence}\n${fence}swift:Real.swift\nlet x = 1\n${fence}\n`; - const messages: AnthropicMessage[] = [ - { role: "user", content: userText }, - ]; - const result = filterExcludedFiles(formatAnthropicPrompt(messages), ["mock"]); + const messages: AnthropicMessage[] = [{ role: "user", content: userText }]; + const result = filterExcludedFiles(formatAnthropicPrompt(messages), [ + "mock", + ]); expect(result).not.toContain("MockHelper"); expect(result).toContain("Real.swift"); }); diff --git a/proxy-server/test/utils/prompt.test.ts b/proxy-server/test/utils/prompt.test.ts index 27364e6..4874afe 100644 --- a/proxy-server/test/utils/prompt.test.ts +++ b/proxy-server/test/utils/prompt.test.ts @@ -32,7 +32,9 @@ describe("filterExcludedFiles", () => { }); it("no code blocks at all", () => { - expect(filterExcludedFiles("just plain text", mockPatterns)).toBe("just plain text"); + expect(filterExcludedFiles("just plain text", mockPatterns)).toBe( + "just plain text", + ); }); it("empty string", () => { diff --git a/proxy-server/test/utils/responses-prompt.test.ts b/proxy-server/test/utils/responses-prompt.test.ts index 165e382..5a01ae5 100644 --- a/proxy-server/test/utils/responses-prompt.test.ts +++ b/proxy-server/test/utils/responses-prompt.test.ts @@ -5,8 +5,8 @@ import { filterExcludedFiles } from "../../src/providers/shared/prompt-utils.js" describe("filterExcludedFiles (Responses)", () => { it("applies excluded file patterns to user content", () => { const input = "```swift:Generated.swift\nsome code\n```\nreal content"; - expect(filterExcludedFiles(formatResponsesPrompt(input), ["Generated"])).toBe( - "[User]: real content", - ); + expect( + filterExcludedFiles(formatResponsesPrompt(input), ["Generated"]), + ).toBe("[User]: real content"); }); });