Compare commits

..

1 Commits

Author SHA1 Message Date
opencode d2bd7eaad5 release: v1.15.11 2026-05-27 04:00:59 +00:00
209 changed files with 2027 additions and 13448 deletions
+38 -47
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -121,7 +121,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -148,7 +148,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -214,7 +214,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.12",
"version": "1.15.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -275,7 +275,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -330,7 +330,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -344,7 +344,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -374,7 +374,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -390,7 +390,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -403,7 +403,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -421,7 +421,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.12",
"version": "1.15.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -478,7 +478,6 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
@@ -520,7 +519,6 @@
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"ws": "8.21.0",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -561,7 +559,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -577,9 +575,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16",
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15",
},
"optionalPeers": [
"@opentui/core",
@@ -599,7 +597,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -614,7 +612,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -627,9 +625,8 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -651,7 +648,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -670,7 +667,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -710,7 +707,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -759,7 +756,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -827,9 +824,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1719,23 +1716,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
"@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5141,7 +5138,7 @@
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
@@ -5499,8 +5496,6 @@
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
@@ -5963,8 +5958,6 @@
"miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
"miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -6081,8 +6074,6 @@
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+2 -2
View File
@@ -209,8 +209,8 @@ const ingestConfig = new sst.Linkable("LakeIngestConfig", {
const ingestService = new sst.aws.Service("LakeIngestService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "1 vCPU",
memory: "4 GB",
cpu: "0.5 vCPU",
memory: "1 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
+3 -1
View File
@@ -5,8 +5,10 @@ export const domain = (() => {
})()
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
// Dev owns the shared AWS lake/stats infra for all non-production stages.
export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage
// Temporarily omit AWS infra so SST removes the lake/stats resources.
export const deployAws = false
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=",
"aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=",
"aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=",
"x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw="
"x86_64-linux": "sha256-FT8N4SBP7OmVu73OwNyPJvBoxFd2+IXzNnFubB8y6J0=",
"aarch64-linux": "sha256-rGst01voY1/qr/fIMEgfL2zep/+W2RhjJh5pKs+/P3s=",
"aarch64-darwin": "sha256-+oTdR3CuR88Ra3QQsDn7ixNwJf0YjOxySTSiRsPPUOg=",
"x86_64-darwin": "sha256-3ZkB83KWtp6N7u9xhzmCouv27UuisXYvr2SBap4k++E="
}
}
+4 -4
View File
@@ -10,7 +10,7 @@
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:stats": "bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
@@ -38,9 +38,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.12",
"version": "1.15.11",
"description": "",
"type": "module",
"exports": {
+6 -19
View File
@@ -14,7 +14,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createResource,
createSignal,
@@ -41,7 +40,7 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
@@ -49,6 +48,11 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { ServersProvider } from "./context/servers"
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
document.body.classList.remove("text-12-regular")
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
}
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -93,26 +97,9 @@ function QueryProvider(props: ParentProps) {
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function BodyDesignClass() {
const settings = useSettings()
createEffect(() => {
if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns()
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
})
return null
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
<BodyDesignClass />
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
+4 -2
View File
@@ -79,6 +79,8 @@ import { pathKey } from "@/utils/path-key"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
interface PromptInputProps {
class?: string
variant?: "dock" | "new-session"
@@ -1454,7 +1456,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<Switch>
<Match when={settings.general.newLayoutDesigns()}>
<Match when={USE_V2_INPUT}>
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
@@ -1526,7 +1528,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
@@ -45,6 +45,8 @@ const OPEN_APPS = [
"sublime-text",
] as const
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
@@ -155,11 +157,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
const isDesktopV2 = platform.platform === "desktop" && USE_V2_TITLEBAR
const search = createMemo(() => (isDesktopV2 ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2 ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2 ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2 ? settings.general.showStatus() : true))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -399,18 +399,6 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -814,7 +802,7 @@ export const SettingsGeneral: Component = () => {
<DisplaySection />
<Show when={desktop()}>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<AdvancedSection />
</Show>
</div>
+11 -9
View File
@@ -55,6 +55,7 @@ const legacyTitlebarHeight = 40
const v2TitlebarHeight = 44
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
@@ -74,7 +75,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -85,7 +85,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
return undefined
@@ -119,7 +119,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const nav = createMemo(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const version = props.update?.version()
return {
@@ -222,8 +222,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<header
classList={{
"shrink-0 relative overflow-hidden flex flex-row": true,
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
"h-10 bg-background-base": !useV2Titlebar(),
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
"h-10 bg-background-base": !USE_V2_TITLEBAR,
}}
style={{
"min-height": minHeight(),
@@ -239,7 +239,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
onDblClick={maximize}
>
<Switch>
<Match when={useV2Titlebar()}>
<Match when={USE_V2_TITLEBAR}>
{(_) => {
const serverSync = useServerSync()
const navigate = useNavigate()
@@ -490,6 +490,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
project={projectForSession(tab.info, projects(), projectByID())}
directory={tab.dir}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
@@ -736,13 +737,14 @@ function TabNavItem(props: {
title: string
project?: LocalProject
directory: string
hideClose?: boolean
onClose: () => void
}) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={isActive()}
>
<a
@@ -753,7 +755,7 @@ function TabNavItem(props: {
<span class="text-clip leading-5">{props.title}</span>
</a>
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
@@ -764,7 +766,7 @@ function TabNavItem(props: {
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
onClick={props.onClose}
icon={<IconV2 name="xmark-small" />}
/>
-6
View File
@@ -33,7 +33,6 @@ export interface Settings {
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
newLayoutDesigns?: boolean
}
updates: {
startup: boolean
@@ -55,7 +54,6 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -244,10 +242,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
-2
View File
@@ -791,8 +791,6 @@ export const dict = {
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
+104 -109
View File
@@ -31,8 +31,8 @@ import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useServers } from "@/context/servers"
import { useSettings } from "@/context/settings"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const HOME_SESSION_LIMIT = 15
const HOME_ROW =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
@@ -52,12 +52,8 @@ type HomeSessionGroup = {
}
export default function Home() {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
if (USE_HOME_DESIGN) return <HomeDesign />
return <LegacyHome />
}
function HomeDesign() {
@@ -72,11 +68,13 @@ function HomeDesign() {
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
const projects = createMemo(() => layout.projects.list())
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
const selectedProject = createMemo(
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
)
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const project = selectedProject()
if (!project) return [...projects().flatMap((project) => directories(project))]
if (!project) return []
return directories(project)
})
const search = createMemo(() => state.search.trim())
@@ -91,8 +89,8 @@ function HomeDesign() {
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const records = createMemo(() => {
return [
const records = createMemo(() =>
[
...new Map(
projectDirectories()
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
@@ -114,8 +112,8 @@ function HomeDesign() {
if (!value) return true
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
})
.slice(0, HOME_SESSION_LIMIT)
})
.slice(0, HOME_SESSION_LIMIT),
)
const groups = createMemo(() => groupSessions(records(), language))
function selectProject(directory: string) {
@@ -222,7 +220,7 @@ function HomeDesign() {
aria-label={language.t("sidebar.project.recentSessions")}
>
<Show
when={projectDirectories().length > 0}
when={selectedProject()}
fallback={
<HomeEmptyState
icon="folder-add-left"
@@ -300,8 +298,8 @@ function HomeProjectColumn(props: {
const layout = useLayout()
const projects = createMemo(() => layout.projects.list())
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px] gap-4" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<IconButtonV2
data-action="home-add-project"
@@ -313,8 +311,8 @@ function HomeProjectColumn(props: {
aria-label={props.language.t("home.project.add")}
/>
</div>
<Show
when={servers.list().length > 1}
<For
each={servers.list()}
fallback={
<ProjectList
projects={projects()}
@@ -330,63 +328,61 @@ function HomeProjectColumn(props: {
/>
}
>
<For each={servers.list()}>
{(server) => {
const key = ServerConnection.key(server)
const healthy = () => !!servers.health[key]?.healthy
const [open, setOpen] = createSignal(true)
{(server) => {
const key = ServerConnection.key(server)
const healthy = () => !!servers.health[key]?.healthy
const [open, setOpen] = createSignal(true)
return (
<div class="max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="relative h-7 group">
<button
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
disabled={!healthy()}
onClick={() => setOpen((o) => !o)}
>
<div class="size-4 flex items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<div class="flex flex-row items-center gap-1">
<span>{server.displayName ?? new URL(server.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={open()}
/>
</Show>
</div>
</button>
<IconButtonV2
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
name="out"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
/>
</div>
<Show when={healthy() && open()}>
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
</Show>
return (
<div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="relative h-7 group">
<button
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
disabled={!healthy()}
onClick={() => setOpen((o) => !o)}
>
<div class="size-4 flex items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<div class="flex flex-row items-center gap-1">
<span>{server.displayName ?? new URL(server.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={open()}
/>
</Show>
</div>
</button>
<IconButtonV2
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
name="out"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
/>
</div>
)
}}
</For>
</Show>
<div class="flex min-w-0 flex-col gap-1">
<Show when={healthy() && open()}>
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
</Show>
</div>
)
}}
</For>
<div class="mt-4 flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
@@ -420,14 +416,12 @@ function HomeProjectRow(props: {
language: ReturnType<typeof useLanguage>
}) {
const name = createMemo(() => displayName(props.project))
const [menuOpen, setMenuOpen] = createSignal(false)
return (
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px]">
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 peer`}
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
@@ -436,37 +430,7 @@ function HomeProjectRow(props: {
<HomeProjectAvatar project={props.project} />
<span>{name()}</span>
</button>
<div
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 peer-focus-visible:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={menuOpen()}
>
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={menuOpen()} onOpenChange={setMenuOpen}>
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
@@ -478,6 +442,37 @@ function HomeProjectRow(props: {
props.openNewSession(props.project.worktree)
}}
/>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="menu" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
<Icon name="new-session" size="small" />
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
<Icon name="edit" size="small" />
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
<Icon name="circle-check" size="small" />
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
<Icon name="close" size="small" />
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
</div>
)
@@ -566,7 +561,7 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
variant="ghost"
size="normal"
icon="edit"
class="h-7 px-2 text-v2-text-text-muted"
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
onClick={onNewSession()}
>
{language.t("command.session.new")}
+174 -174
View File
@@ -89,6 +89,8 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
@@ -127,7 +129,6 @@ export default function Layout(props: ParentProps) {
const command = useCommand()
const theme = useTheme()
const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
@@ -153,7 +154,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory && !newDesign(),
autoselect: !initialDirectory && !USE_NEW_DESIGN,
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -1141,7 +1142,7 @@ export default function Layout(props: ParentProps) {
},
]
if (!newDesign())
if (!USE_NEW_DESIGN)
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
@@ -1820,7 +1821,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
})
@@ -2362,180 +2363,179 @@ export default function Layout(props: ParentProps) {
/>
)
return (
<Show
when={!newDesign()}
fallback={
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
}
>
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
if (USE_NEW_DESIGN) {
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
</Show>
)
}
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
<Toast.Region />
</div>
)
}
+4 -4
View File
@@ -75,6 +75,7 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
@@ -197,7 +198,6 @@ export default function Page() {
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => {
if (!prompt.ready()) return
@@ -265,7 +265,7 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -1798,14 +1798,14 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
import { shouldUseV2NewSessionPage } from "./new-session-layout"
describe("shouldUseV2NewSessionPage", () => {
test("keeps disabled pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
test("keeps prod session pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
})
test("uses the v2 layout only for enabled new-session pages", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
test("uses the v2 layout only for non-prod new-session pages", () => {
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
})
})
@@ -1,3 +1,3 @@
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
return input.newLayoutDesigns && !input.sessionID
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
return input.channel !== "prod" && !input.sessionID
}
@@ -28,6 +28,8 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
@@ -58,7 +60,7 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view, params } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
@@ -203,7 +205,7 @@ export function SessionSidePanel(props: {
})
return (
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
@@ -20,6 +20,8 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
@@ -70,7 +72,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
const messages = () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.12",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"scripts": {
@@ -2,7 +2,6 @@ import { action, json, query, useAction, useSubmission } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { withActor } from "~/context/auth.withActor"
import { Modal } from "~/component/modal"
import { IconCheck, IconCopy } from "~/component/icon"
@@ -10,7 +9,6 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
import "./go-referral.css"
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
@@ -27,21 +25,7 @@ const emptyUsagePreview = {
export const queryGoReferral = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const event = getRequestEvent()
const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null)
if (referralCode) {
await Referral.createFromAccount({
accountID: Actor.account(),
referralCode,
}).catch((error) => {
console.error("Referral create failed", error)
})
event?.response.headers.append("set-cookie", clearReferralCookie())
}
return Referral.summary()
}, workspaceID)
return withActor(() => Referral.summary(), workspaceID)
}, "go.referral.get")
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
@@ -81,8 +65,6 @@ function rewardDescriptionKey(source: GoReferralReward["source"]) {
function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) {
if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const
if (reward.status === "pending" && reward.source === "inviter")
return "workspace.referral.reward.source.pendingInviter" as const
if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const
return "workspace.referral.reward.action.view" as const
}
@@ -1,9 +1,11 @@
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth"
import { i18n } from "~/i18n"
import { localeFromRequest, route } from "~/lib/language"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
@@ -17,6 +19,7 @@ export async function GET(input: APIEvent) {
if (result.err) throw new Error(result.err.message)
const decoded = AuthClient.decode(result.tokens.access, {} as any)
if (decoded.err) throw new Error(decoded.err.message)
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
const session = await useAuthSession()
const id = decoded.subject.properties.accountID
await session.update((value) => {
@@ -32,8 +35,15 @@ export async function GET(input: APIEvent) {
current: id,
}
})
if (decoded.subject.properties.newAccount && referralCode) {
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
console.error("Referral create failed", error)
})
}
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
return redirect(route(locale, next))
const response = redirect(route(locale, next))
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
return response
} catch (e: any) {
return new Response(
JSON.stringify({
+1 -2
View File
@@ -64,12 +64,11 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "280ms" },
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" },
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", req: 10200, d: "360ms" },
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
]
@@ -4,6 +4,7 @@ import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode-ai/console-core/key.js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./key-section.module.css"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { useI18n } from "~/context/i18n"
@@ -123,6 +124,7 @@ export function KeySection() {
<th>{i18n.t("workspace.keys.table.name")}</th>
<th>{i18n.t("workspace.keys.table.key")}</th>
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
<th></th>
</tr>
</thead>
@@ -154,6 +156,9 @@ export function KeySection() {
</Show>
</td>
<td data-slot="key-user-email">{key.email}</td>
<td data-slot="key-last-used" title={key.timeUsed ? formatDateUTC(key.timeUsed) : undefined}>
{key.timeUsed ? formatDateForTable(key.timeUsed) : "-"}
</td>
<td data-slot="key-actions">
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
@@ -506,6 +506,7 @@ export async function handler(
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
let topPriority = Infinity
const providers = allProviders
.filter((provider) => !provider.disabled)
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
@@ -989,6 +990,10 @@ export async function handler(
return undefined
})(),
}),
db
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
...(() => {
if (billingSource === "subscription") {
const plan = authInfo.billing.subscription!.plan
@@ -1,3 +1,5 @@
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { FreeUsageLimitError } from "./error"
import { logger } from "./logger"
import { buildRateLimitKey, getRedis } from "./redis"
@@ -20,6 +22,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
const lifetimeInterval = ""
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
const retryAfter = getRetryAfterDay(now)
const redis = getRedis()
@@ -29,12 +32,33 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
return {
check: async () => {
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const dailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
const [counts, rows] = await Promise.all([
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
Database.use((tx) =>
tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(
and(
eq(IpRateLimitTable.ip, ip),
isDefaultModel
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
: inArray(IpRateLimitTable.interval, [dailyInterval]),
),
),
),
])
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
@@ -44,7 +68,18 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
pipeline.incr(dailyKey)
pipeline.expire(dailyKey, retryAfter)
if (isNew) pipeline.incr(lifetimeKey)
await pipeline.exec()
await Promise.all([
pipeline.exec().catch(() => {}),
Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values([
{ ip, interval: dailyInterval, count: 1 },
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
])
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
),
])
},
}
}
@@ -1,5 +1,6 @@
import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error"
import { buildRateLimitKey, getRedis } from "./redis"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
@@ -18,20 +19,26 @@ export function createRateLimiter(
.replace(/[^0-9]/g, "")
.substring(0, 12)
const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}`
const redis = getRedis()
const key = buildRateLimitKey("key", zenApiKey, interval)
return {
check: async () => {
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
const rows = await Database.use((tx) =>
tx
.select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count })
.from(KeyRateLimitTable)
.where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))),
).then((rows) => rows[0])
const count = rows?.count ?? 0
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
},
track: async () => {
const pipeline = redis.pipeline()
pipeline.incr(key)
pipeline.expire(key, 60)
await pipeline.exec()
await Database.use((tx) =>
tx
.insert(KeyRateLimitTable)
.values({ key: zenApiKey, interval, count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }),
)
},
}
}
-1
View File
@@ -17,7 +17,6 @@ export default defineConfig({
],
server: {
allowedHosts: true,
port: 3001,
},
build: {
rollupOptions: {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.12",
"version": "1.15.11",
"private": true,
"type": "module",
"license": "MIT",
+4 -24
View File
@@ -1,8 +1,8 @@
import { z } from "zod"
import { and, asc, eq, inArray, isNull, sql, Database } from "./drizzle"
import { and, asc, eq, isNull, sql, Database } from "./drizzle"
import { Actor } from "./actor"
import { Identifier } from "./identifier"
import { LiteTable, PaymentTable } from "./schema/billing.sql"
import { LiteTable } from "./schema/billing.sql"
import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
import { AuthTable } from "./schema/auth.sql"
import { UserTable } from "./schema/user.sql"
@@ -318,26 +318,6 @@ export namespace Referral {
.then((rows) => rows[0])
if (selfReferral) throw new Error("Self-referral is not allowed")
const workspaceIDs = await tx
.select({ workspaceID: UserTable.workspaceID })
.from(UserTable)
.where(and(eq(UserTable.accountID, input.accountID), isNull(UserTable.timeDeleted)))
.then((rows) => rows.map((row) => row.workspaceID))
if (workspaceIDs.length === 0) return
const litePayment = await tx
.select({ id: PaymentTable.id })
.from(PaymentTable)
.where(
and(
inArray(PaymentTable.workspaceID, workspaceIDs),
isNull(PaymentTable.timeDeleted),
sql`JSON_UNQUOTE(JSON_EXTRACT(${PaymentTable.enrichment}, '$.type')) = 'lite'`,
),
)
.then((rows) => rows[0])
if (litePayment) return
const referralID = Identifier.create("referral")
await tx.insert(ReferralTable).ignore().values({
workspaceID: code.workspaceID,
@@ -375,7 +355,7 @@ export namespace Referral {
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) return
if (!referral) throw new Error("Referral not found")
const result = await tx
.insert(ReferralRewardTable)
@@ -393,7 +373,7 @@ export namespace Referral {
},
])
if (result.rowsAffected === 0) return
if (result.rowsAffected === 0) throw new Error("Referral already completed")
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.12",
"version": "1.15.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.12",
"version": "1.15.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.15.12",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.12",
"version": "1.15.11",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.12",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.12",
"version": "1.15.11",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.12",
"version": "1.15.11",
"private": true,
"type": "module",
"license": "MIT",
-1
View File
@@ -29,7 +29,6 @@ export default defineConfig({
server: {
host: "0.0.0.0",
allowedHosts: true,
port: 3002,
},
worker: {
format: "es",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.12"
version = "1.15.11"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.12/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.12",
"version": "1.15.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.12",
"version": "1.15.11",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.12",
"version": "1.15.11",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.12",
"version": "1.15.11",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -122,7 +122,6 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
@@ -164,7 +163,6 @@
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"ws": "8.21.0",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:"
+2 -10
View File
@@ -11,7 +11,6 @@ import type {
} from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { ACPNextSession } from "./session"
import { ACPNextPermission } from "./permission"
import {
duplicateRunningToolUpdate,
errorToolUpdate,
@@ -23,8 +22,7 @@ import {
const log = Log.create({ service: "acp-next-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
type Connection = Pick<AgentSideConnection, "sessionUpdate">
type GlobalEventEnvelope = {
payload?: Event
}
@@ -42,7 +40,6 @@ export class Subscription {
private readonly abort = new AbortController()
private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>()
private readonly permission: ACPNextPermission.Handler
private started = false
constructor(
@@ -51,9 +48,7 @@ export class Subscription {
connection: Connection
session: ACPNextSession.Interface
},
) {
this.permission = new ACPNextPermission.Handler(input)
}
) {}
start() {
if (this.started) return
@@ -70,9 +65,6 @@ export class Subscription {
async handle(event: Event) {
switch (event.type) {
case "permission.asked":
this.permission.handle(event)
return
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
@@ -1,145 +0,0 @@
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem"
import type { ACPNextSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
const log = Log.create({ service: "acp-next-permission" })
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject"
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
const permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
export class Handler {
private readonly queues = new Map<string, Promise<void>>()
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPNextSession.Interface
},
) {}
handle(event: PermissionEvent) {
const permission = event.properties
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
const next = previous
.then(() => this.process(event))
.catch((error: unknown) => {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => {
if (this.queues.get(permission.sessionID) === next) {
this.queues.delete(permission.sessionID)
}
})
this.queues.set(permission.sessionID, next)
}
private async process(event: PermissionEvent) {
const permission = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
if (!session) return
if (!this.input.connection.requestPermission) {
log.error("ACP connection cannot request permission", {
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return
}
const result = await this.input.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options: permissionOptions,
})
.catch(async (error: unknown) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return undefined
})
if (!result) return
const reply = selectedReply(result)
if (reply !== "once" && reply !== "always") {
await this.reply(permission.id, "reject", session.cwd)
return
}
if (permission.permission === "edit") {
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => {
log.error("failed to write proposed edit through ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
})
}
await this.reply(permission.id, reply, session.cwd)
}
private async reply(requestID: string, reply: Reply, directory: string) {
await this.input.sdk.permission.reply({
requestID,
reply,
directory,
})
}
private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
const filepath = stringValue(metadata.filepath)
const diff = stringValue(metadata.diff)
if (!filepath || !diff || !this.input.connection.writeTextFile) return
const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff)
if (next === false) {
log.error("Failed to apply unified diff (context mismatch)")
return
}
void this.input.connection.writeTextFile({
sessionId,
path: filepath,
content: next,
})
}
}
function selectedReply(result: RequestPermissionResponse): Reply {
if (result.outcome.outcome !== "selected") return "reject"
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
return "reject"
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPNextPermission from "./permission"
-42
View File
@@ -1,42 +0,0 @@
const enabled = process.env.OPENCODE_ACP_PROFILE === "1"
const started = performance.now()
export function mark(name: string, fields?: Record<string, string | number | boolean | undefined>) {
if (!enabled) return
write(`${name}.mark`, performance.now() - started, fields)
}
export function duration(
name: string,
startedAt: number,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return
write(name, performance.now() - startedAt, fields)
}
export async function measure<T>(
name: string,
fn: () => Promise<T>,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return fn()
const start = performance.now()
try {
return await fn()
} finally {
write(name, performance.now() - start, fields)
}
}
function write(name: string, durationMs: number, fields?: Record<string, string | number | boolean | undefined>) {
const extra = fields
? Object.entries(fields)
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
.map(([key, value]) => `${key}=${value}`)
.join(" ")
: ""
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
}
export * as ACPNextProfile from "./profile"
+98 -338
View File
@@ -35,12 +35,9 @@ import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPNextError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
import { promptContentToParts } from "./content"
import { Directory } from "./directory"
import { ACPNextEvent } from "./event"
import { ACPNextSession } from "./session"
import { UsageService } from "./usage"
import { ACPNextProfile } from "./profile"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
@@ -49,8 +46,6 @@ export const AuthMethodID = "opencode-login"
const log = Log.create({ service: "acp-next-service" })
export type Error = ACPNextError.Error
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
export type Interface = {
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
@@ -74,23 +69,20 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AC
export function make(input: {
sdk: OpencodeClient
connection?: ServiceConnection
connection?: Pick<AgentSideConnection, "sessionUpdate">
directory?: Directory.Interface
session?: ACPNextSession.Interface
usage?: UsageService.Interface
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
}): Interface {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>()
const sessionSnapshots = new Map<string, Directory.Snapshot>()
const events = input.connection
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined
if (events) input.eventSubscription?.(events)
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
const started = performance.now()
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -107,7 +99,7 @@ export function make(input: {
}
}
const response = {
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
@@ -132,8 +124,6 @@ export function make(input: {
version: InstallationVersion,
},
}
ACPNextProfile.duration("acp.initialize", started)
return response
})
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
@@ -144,28 +134,15 @@ export function make(input: {
})
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
const started = performance.now()
const snapshot = yield* directoryService.get(cwd)
ACPNextProfile.duration("acp.directory.snapshot", started)
return snapshot
})
const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) {
const snapshot = sessionSnapshots.get(state.id)
if (snapshot) return snapshot
const loaded = yield* directorySnapshot(state.cwd)
sessionSnapshots.set(state.id, loaded)
return loaded
return yield* directoryService.get(cwd)
})
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
const started = performance.now()
const snapshot = yield* directorySnapshot(params.cwd)
const selected = selectDefaultModel(snapshot)
const variant = selectVariant(snapshot, selected)
const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined
const created = yield* profiledRequest(
"acp.newSession.session.create",
const created = yield* request(
() =>
input.sdk.session.create(
{
@@ -189,12 +166,11 @@ export function make(input: {
variant,
modeId,
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
const response = {
return {
sessionId: state.id,
configOptions: configOptions(snapshot, {
model: state.model ?? selected,
@@ -202,8 +178,6 @@ export function make(input: {
modeId: state.modeId,
}),
}
ACPNextProfile.duration("acp.newSession", started)
return response
})
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
@@ -230,7 +204,6 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -259,35 +232,21 @@ export function make(input: {
),
"session",
)
const serverEntries = sessions.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
)
const liveEntries = (yield* session.list(params.cwd ?? undefined))
.filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id))
.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.cwd,
updatedAt: item.createdAt.toISOString(),
}),
)
const sorted = [...liveEntries, ...serverEntries].toSorted(
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(),
)
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
const filtered =
cursor === undefined || !Number.isFinite(cursor)
? sorted
: sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor)
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
const page = filtered.slice(0, limit)
const last = page.at(-1)
return {
sessions: page,
...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}),
sessions: page.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
),
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
}
})
@@ -315,7 +274,6 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -333,7 +291,6 @@ export function make(input: {
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
const removed = yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId)
sessionSnapshots.delete(params.sessionId)
if (!removed) return {}
yield* request(
@@ -377,7 +334,6 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -397,7 +353,7 @@ export function make(input: {
params: SetSessionConfigOptionRequest,
) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current)
const snapshot = yield* directorySnapshot(current.cwd)
if (typeof params.value !== "string") {
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
}
@@ -452,7 +408,7 @@ export function make(input: {
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current)
const snapshot = yield* directorySnapshot(current.cwd)
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
}
@@ -462,7 +418,7 @@ export function make(input: {
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current)
const snapshot = yield* directorySnapshot(current.cwd)
const selected = yield* parseSelectedModel(snapshot, params.modelId)
yield* session
.setVariant(
@@ -487,81 +443,8 @@ export function make(input: {
setSessionConfigOption,
setSessionMode,
setSessionModel,
prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const selected = current.model ?? selectDefaultModel(snapshot)
if (!current.model) {
yield* session.setModel(params.sessionId, selected)
}
const variant = current.variant ?? selectVariant(snapshot, selected)
const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined)
const parts = promptContentToParts(params.prompt)
const command = detectSlashCommand(parts)
if (!command) {
const response = yield* request(
() =>
input.sdk.session.prompt(
{
sessionID: current.id,
model: {
providerID: selected.providerID,
modelID: selected.modelID,
},
...(variant ? { variant } : {}),
parts,
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
}
const known = snapshot.availableCommands.find((item) => item.name === command.name)
if (known) {
const response = yield* request(
() =>
input.sdk.session.command(
{
sessionID: current.id,
command: known.name,
arguments: command.args,
model: `${selected.providerID}/${selected.modelID}`,
...(variant ? { variant } : {}),
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
}
if (command.name === "compact") {
yield* request(
() =>
input.sdk.session.summarize(
{
sessionID: current.id,
directory: current.cwd,
providerID: selected.providerID,
modelID: selected.modelID,
},
{ throwOnError: true },
),
"session",
)
}
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(undefined, params.messageId)
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
}),
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
@@ -590,91 +473,6 @@ function makeDirectoryService(sdk: OpencodeClient) {
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
}
function makeUsageService(sdk: OpencodeClient) {
const limits = new Map<string, Promise<number | undefined>>()
const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(
function* (params) {
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
const current = limits.get(key)
if (current) return yield* Effect.promise(() => current)
const next = sdk.config
.providers({ directory: params.directory }, { throwOnError: true })
.then((response) => {
const providers = Object.fromEntries(
(response.data?.providers ?? []).map((provider) => [provider.id, provider]),
) as Record<ProviderID, Provider.Info>
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
})
.catch((error: unknown) => {
log.error("failed to get providers for usage context limit", { error })
return undefined
})
limits.set(key, next)
return yield* Effect.promise(() => next)
},
)
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(
function* (params) {
const messages = yield* request(
() =>
sdk.session.messages(
{
sessionID: params.sessionID,
directory: params.directory,
},
{ throwOnError: true },
),
"session",
).pipe(
Effect.map((messages) => messages as readonly UsageService.SessionMessage[]),
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
),
)
if (!messages) return
const message = UsageService.latestAssistantMessage(messages)
if (!message?.providerID || !message.modelID) return
const size = yield* contextLimit({
directory: params.directory,
providerID: ProviderID.make(message.providerID),
modelID: ModelID.make(message.modelID),
})
if (!size) return
yield* Effect.promise(() =>
params.connection
.sessionUpdate({
sessionId: params.sessionID,
update: {
sessionUpdate: "usage_update",
used: message.tokens.input + message.tokens.cache.read,
size,
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
},
})
.catch((error) => {
log.error("failed to send usage update", { error })
}),
)
},
)
return UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit,
sendUpdate,
})
}
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
if (!subscription) return Effect.void
return Effect.promise(async () => {
@@ -707,8 +505,6 @@ type MessageInfo = {
readonly agent?: Message["agent"]
}
type AssistantInfo = UsageService.AssistantTokenCost | undefined
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({
try: async () => {
@@ -723,79 +519,66 @@ function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
})
}
function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return request(() => ACPNextProfile.measure(name, fn), service)
}
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
return ACPNextProfile.measure("acp.directory.load", async () => {
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([
ACPNextProfile.measure("acp.directory.provider.list", () =>
sdk.config.providers({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () =>
sdk.app.agents({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.command.list", () =>
sdk.command.list({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
ACPNextProfile.measure("acp.directory.defaultModel.config", () =>
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
),
])
const providersData = providersResponse.data!
const agents = agentsResponse.data!
const commandsData = commandsResponse.data!
const skills = skillsResponse.data!
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
ProviderID,
Provider.Info
>
const defaultModelStarted = performance.now()
const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers)
ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
name: agent.name,
...(agent.description ? { description: agent.description } : {}),
}))
const commands = [
...commandsData,
...skills
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
.map((skill) => ({
name: skill.name,
description: skill.description,
source: "skill" as const,
template: skill.content,
hints: [],
})),
] as Command.Info[]
const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([
sdk.config.providers({ directory }, { throwOnError: true }),
sdk.app.agents({ directory }, { throwOnError: true }),
sdk.command.list({ directory }, { throwOnError: true }),
sdk.app.skills({ directory }, { throwOnError: true }),
])
const providersData = providersResponse.data!
const agents = agentsResponse.data!
const commandsData = commandsResponse.data!
const skills = skillsResponse.data!
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
ProviderID,
Provider.Info
>
const defaultModel = await defaultModelFromSdk(sdk, directory, providers)
const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
name: agent.name,
...(agent.description ? { description: agent.description } : {}),
}))
const commands = [
...commandsData,
...skills
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
.map((skill) => ({
name: skill.name,
description: skill.description,
source: "skill" as const,
template: skill.content,
hints: [],
})),
] as Command.Info[]
return Directory.build({
directory,
providers,
modes,
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel ? { defaultModel } : {}),
})
return Directory.build({
directory,
providers,
modes,
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel ? { defaultModel } : {}),
})
}
function defaultModelFromConfig(
configuredModel: string | undefined,
async function defaultModelFromSdk(
sdk: OpencodeClient,
directory: string,
providers: Record<ProviderID, Provider.Info>,
): Directory.DefaultModel | undefined {
const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined
): Promise<Directory.DefaultModel | undefined> {
const configured = await sdk.config
.get({ directory }, { throwOnError: true })
.then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined))
.catch(() => undefined)
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
// First-session ACP startup must not scan historical sessions just to infer
// a default. Configured model, opencode provider, then sorted best model keep
// the protocol response deterministic without extra session/message reads.
const lastUsed = await lastUsedModel(sdk, directory, providers)
if (lastUsed) return lastUsed
const opencodeProvider = providers[ProviderID.make("opencode")]
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
@@ -805,6 +588,30 @@ function defaultModelFromConfig(
if (configured) return configured
}
async function lastUsedModel(
sdk: OpencodeClient,
directory: string,
providers: Record<ProviderID, Provider.Info>,
): Promise<Directory.DefaultModel | undefined> {
const session = await sdk.session
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
.then((response) => response.data?.[0])
.catch(() => undefined)
if (!session) return
const lastUser = await sdk.session
.messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true })
.then((response) => response.data?.findLast((message) => message.info.role === "user")?.info)
.catch(() => undefined)
if (lastUser?.role !== "user") return
if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return
return {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
}
}
function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
const model = snapshot.modelOptions[0]
@@ -812,43 +619,6 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
}
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
const text = parts
.filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("")
.trim()
if (!text.startsWith("/")) return
const [name, ...rest] = text.slice(1).split(/\s+/)
if (!name) return
return { name, args: rest.join(" ").trim() }
}
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
return {
stopReason: "end_turn",
...(info ? { usage: UsageService.buildUsage(info) } : {}),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
}
function sendUsageUpdate(
usage: UsageService.Interface | undefined,
sdk: OpencodeClient,
connection: ServiceConnection | undefined,
sessionID: string,
directory: string,
) {
if (!connection) return Effect.void
return (usage ?? makeUsageService(sdk)).sendUpdate({
connection,
sessionID,
directory,
})
}
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
const variants = Directory.variants(snapshot, model)
if (!variants) return
@@ -919,7 +689,6 @@ function registerMcpServers(
sessionId: string,
servers: readonly McpServer[],
) {
const started = performance.now()
const current = registered.get(sessionId) ?? new Set<string>()
registered.set(sessionId, current)
const pending = new Set<string>()
@@ -951,16 +720,7 @@ function registerMcpServers(
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.tap(() =>
Effect.sync(() =>
ACPNextProfile.duration("acp.mcp.register", started, {
count: pending.size,
}),
),
),
Effect.asVoid,
)
).pipe(Effect.asVoid)
}
function mcpRegistrationKey(name: string, config: ReturnType<typeof mcpConfig>) {
@@ -60,7 +60,6 @@ export type PartMetadataLookupInput = {
export type Interface = {
readonly create: (input: StoreInput) => Effect.Effect<Info>
readonly load: (input: StoreInput) => Effect.Effect<Info>
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
@@ -169,12 +168,6 @@ export const layer = Layer.effect(
return Service.of({
create: store,
load: store,
list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) {
return [...(yield* Ref.get(sessions)).values()]
.filter((session) => !cwd || session.cwd === cwd)
.map(snapshot)
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}),
get,
tryGet,
remove,
+1 -1
View File
@@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
case "read":
case "edit":
case "write":
return locationFrom(input.filePath ?? input.filepath)
return locationFrom(input.filePath)
case "grep":
case "glob":
+1 -6
View File
@@ -9,7 +9,6 @@ import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ACPNextProfile } from "@/acp-next/profile"
const log = Log.create({ service: "acp-command" })
@@ -24,13 +23,10 @@ export const AcpCommand = effectCmd({
})
},
handler: Effect.fn("Cli.acp")(function* (args) {
ACPNextProfile.mark("cli.acp.handler")
process.env.OPENCODE_CLIENT = "acp"
const flags = yield* RuntimeFlags.Service
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() =>
ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
)
const server = yield* Effect.promise(() => Server.listen(opts))
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
@@ -64,7 +60,6 @@ export const AcpCommand = effectCmd({
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
new AgentSideConnection((conn) => {
ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext })
return agent.create(conn, { sdk })
}, stream)
+2 -21
View File
@@ -41,7 +41,6 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -80,7 +79,8 @@ import {
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
const appGlobalBindingCommands = [
const appBindingCommands = [
"command.palette.show",
"session.list",
"session.new",
"session.quick_switch.1",
@@ -92,10 +92,6 @@ const appGlobalBindingCommands = [
"session.quick_switch.7",
"session.quick_switch.8",
"session.quick_switch.9",
] as const
const appBindingCommands = [
"command.palette.show",
"model.list",
"model.cycle_recent",
"model.cycle_recent_reverse",
@@ -115,7 +111,6 @@ const appBindingCommands = [
"theme.mode.lock",
"help.show",
"docs.open",
"workspace.list",
"app.debug",
"app.console",
"app.heap_snapshot",
@@ -608,16 +603,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
name: "workspace.list",
title: "Manage workspaces",
category: "Workspace",
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "workspaces",
run: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
@@ -944,10 +929,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
}))
useBindings(() => ({
bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: () => {
@@ -1,112 +0,0 @@
import type { Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useProject } from "@tui/context/project"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { createMemo, createSignal, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
type WorkspaceOption = { workspace: Workspace }
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const project = useProject()
const { theme } = useTheme()
const [deleting, setDeleting] = createSignal<string>()
const [removing, setRemoving] = createSignal<string>()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const current = createMemo(() => {
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
return project.workspace.current()
})
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
project.workspace
.list()
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((workspace) => {
const status = project.workspace.status(workspace.id)
return {
title:
removing() === workspace.id
? "Deleting..."
: deleting() === workspace.id
? `Delete ${workspace.name}? Press delete again`
: workspace.name,
value: { workspace },
footer: workspace.type,
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}></text>,
}
}),
)
function showDetails(workspace: Workspace) {
setExpanded(workspace.id, (open) => !open)
}
async function remove(workspace: Workspace) {
if (removing()) return
if (deleting() !== workspace.id) {
setDeleting(workspace.id)
return
}
setDeleting(undefined)
setRemoving(workspace.id)
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
error: err,
}))
if (result?.error) {
setRemoving(undefined)
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return
}
if (current() === workspace.id) {
project.workspace.set(undefined)
route.navigate({ type: "home" })
}
await project.workspace.sync()
await sync.bootstrap({ fatal: false }).catch(() => undefined)
setRemoving(undefined)
}
onMount(() => {
dialog.setSize("large")
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
void project.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
options={options()}
onMove={(option) => {
setDeleting(undefined)
}}
onSelect={(option) => showDetails(option.value.workspace)}
actions={[
{
command: "session.delete",
title: "delete",
onTrigger: (option) => void remove(option.value.workspace),
},
]}
/>
)
}
@@ -512,10 +512,7 @@ export function Prompt(props: PromptProps) {
const content = await Editor.open({
value,
renderer,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
process.cwd(),
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
if (!content) return
@@ -442,25 +442,16 @@ function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: bool
: theme.warning
return (
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title}>
<span>: </span>
<span>{props.title}</span>
</Show>
</text>
</Match>
</Switch>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title}>
<span>: </span>
<span>{props.title}</span>
</Show>
</text>
)
}
@@ -54,7 +54,6 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useDialog } from "../../ui/dialog"
import { DialogAlert } from "../../ui/dialog-alert"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history"
@@ -134,6 +133,12 @@ const sessionBindingCommands = [
"session.toggle.actions",
"session.toggle.scrollbar",
"session.toggle.generic_tool_output",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"session.messages_last_user",
@@ -148,17 +153,6 @@ const sessionBindingCommands = [
"session.child.previous",
] as const
const sessionGlobalBindingCommands = [
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
] as const
const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const
const context = createContext<{
width: number
sessionID: string
@@ -415,19 +409,15 @@ export function Session() {
const local = useLocal()
function enterChild(sessionID: string) {
navigate({
type: "session",
sessionID,
})
const status = sync.data.session_status[sessionID]
if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message)
}
function moveFirstChild() {
if (children().length === 1) return
const next = children().find((x) => !!x.parentID)
if (next) enterChild(next.id)
if (next) {
navigate({
type: "session",
sessionID: next.id,
})
}
}
function moveChild(direction: number) {
@@ -438,7 +428,12 @@ export function Session() {
if (next >= sessions.length) next = 0
if (next < 0) next = sessions.length - 1
if (sessions[next]) enterChild(sessions[next].id)
if (sessions[next]) {
navigate({
type: "session",
sessionID: sessions[next].id,
})
}
}
function childSessionHandler(func: () => void) {
@@ -973,10 +968,7 @@ export function Session() {
await Editor.open({
value: transcript,
renderer,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
process.cwd(),
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
} else {
const exportDir = process.cwd()
@@ -989,10 +981,7 @@ export function Session() {
const result = await Editor.open({
value: transcript,
renderer,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
process.cwd(),
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
if (result !== undefined) {
await Filesystem.write(filepath, result)
@@ -1012,8 +1001,8 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
dialog.clear()
moveFirstChild()
dialog.clear()
},
},
{
@@ -1040,8 +1029,8 @@ export function Session() {
hidden: true,
enabled: !!session()?.parentID,
run: childSessionHandler(() => {
dialog.clear()
moveChild(1)
dialog.clear()
}),
},
{
@@ -1051,8 +1040,8 @@ export function Session() {
hidden: true,
enabled: !!session()?.parentID,
run: childSessionHandler(() => {
dialog.clear()
moveChild(-1)
dialog.clear()
}),
},
])
@@ -1072,15 +1061,6 @@ export function Session() {
commands: sessionCommands(),
}))
useBindings(() => ({
bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
}))
useBindings(() => ({
enabled: () => renderer.currentFocusedEditor === null,
bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
@@ -1595,33 +1575,24 @@ function ReasoningHeader(props: {
: theme.warning
return (
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
</Match>
</Switch>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
)
}
@@ -1781,7 +1752,6 @@ function GenericTool(props: ToolProps<any>) {
function InlineTool(props: {
icon: string
iconColor?: RGBA
color?: RGBA
complete: any
pending: string
spinner?: boolean
@@ -1803,7 +1773,6 @@ function InlineTool(props: {
})
const fg = createMemo(() => {
if (props.color) return props.color
if (permission()) return theme.warning
if (hover() && props.onClick) return theme.text
if (props.complete) return theme.textMuted
@@ -2088,10 +2057,8 @@ function WebSearch(props: ToolProps<typeof WebSearchTool>) {
}
function Task(props: ToolProps<typeof TaskTool>) {
const { theme } = useTheme()
const { navigate } = useRoute()
const sync = useSync()
const dialog = useDialog()
onMount(() => {
if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
@@ -2113,11 +2080,6 @@ function Task(props: ToolProps<typeof TaskTool>) {
)
const isRunning = createMemo(() => props.part.state.status === "running")
const retry = createMemo(() => {
const status = sync.data.session_status[props.metadata.sessionId ?? ""]
if (status?.type !== "retry") return
return status
})
const duration = createMemo(() => {
const first = messages().find((x) => x.role === "user")?.time.created
@@ -2132,10 +2094,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
const retrying = retry()
if (isRunning() && retrying) {
content.push(`${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`)
} else if (isRunning() && tools().length > 0) {
if (isRunning() && tools().length > 0) {
// content[0] += ` · ${tools().length} toolcalls`
if (current()) {
const state = current()!.state
@@ -2158,7 +2117,6 @@ function Task(props: ToolProps<typeof TaskTool>) {
return (
<InlineTool
icon="│"
color={retry() ? theme.error : undefined}
spinner={isRunning()}
complete={props.input.description}
pending="Delegating..."
@@ -2167,8 +2125,6 @@ function Task(props: ToolProps<typeof TaskTool>) {
if (props.metadata.sessionId) {
navigate({ type: "session", sessionID: props.metadata.sessionId })
}
const status = retry()
if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
}}
>
{content()}
@@ -51,7 +51,6 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
details?: string[]
footer?: JSX.Element | string
category?: string
categoryView?: JSX.Element
@@ -168,7 +167,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (!category) return acc
return acc + (i > 0 ? 2 : 1)
}, 0)
return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
return flat().length + headers
})
const dimensions = useTerminalDimensions()
@@ -427,7 +426,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return (
<box
id={JSON.stringify(option.value)}
flexDirection="column"
flexDirection="row"
position="relative"
onMouseMove={() => {
setStore("input", "mouse")
@@ -447,37 +446,24 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
>
<box
flexDirection="row"
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
>
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option
title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
<For each={option.details}>
{(detail) => (
<box paddingLeft={3} paddingRight={3}>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
</text>
</box>
)}
</For>
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option
title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
)
}}
@@ -54,7 +54,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}
@@ -1,11 +1,10 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as Log from "@opencode-ai/core/util/log"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OAUTH_DUMMY_KEY } from "../../auth"
import { OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool"
const log = Log.create({ service: "plugin.codex" })
@@ -29,12 +28,20 @@ interface PkceCodes {
}
async function generatePKCE(): Promise<PkceCodes> {
const verifier = generateRandomString(43)
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const hash = await crypto.subtle.digest("SHA-256", data)
const challenge = base64UrlEncode(hash)
return { verifier, challenge }
}
function generateRandomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)))
const bytes = crypto.getRandomValues(new Uint8Array(length))
return Array.from(bytes)
.map((b) => chars[b % chars.length])
.join("")
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
return { verifier, challenge }
}
function base64UrlEncode(buffer: ArrayBuffer): string {
@@ -43,6 +50,10 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
}
export interface IdTokenClaims {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
@@ -109,7 +120,6 @@ interface TokenResponse {
interface CodexAuthPluginOptions {
issuer?: string
codexApiEndpoint?: string
experimentalWebSockets?: boolean
}
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
@@ -361,14 +371,8 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
const issuer = options.issuer ?? ISSUER
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
let websocketFetchInstalled = false
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
return {
async dispose() {
for (const websocketFetch of websocketFetches) websocketFetch.close()
websocketFetches.length = 0
},
provider: {
id: "openai",
async models(provider, ctx) {
@@ -406,14 +410,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
provider: "openai",
async loader(getAuth) {
const auth = await getAuth()
const websocketFetch = options.experimentalWebSockets
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
: undefined
if (websocketFetch) {
websocketFetches.push(websocketFetch)
websocketFetchInstalled = true
}
if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {}
if (auth.type !== "oauth") return {}
let refreshPromise:
| Promise<{
@@ -425,6 +422,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
// Remove dummy API key authorization header
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.delete("authorization")
@@ -438,11 +436,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
}
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth")
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
// Cast to include accountId field
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
// Check if token needs refresh
if (!currentAuth.access || currentAuth.expires < Date.now()) {
if (!refreshPromise) {
log.info("refreshing codex access token")
@@ -474,6 +473,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
authWithAccount.accountId = refreshed.accountId
}
// Build headers
const headers = new Headers()
if (init?.headers) {
if (init.headers instanceof Headers) {
@@ -488,11 +488,16 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
}
}
}
// Set authorization header with access token
headers.set("authorization", `Bearer ${currentAuth.access}`)
// Set ChatGPT-Account-Id header for organization subscriptions
if (authWithAccount.accountId) {
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
}
// Rewrite URL to Codex endpoint
const parsed =
requestInput instanceof URL
? requestInput
@@ -502,12 +507,10 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
? new URL(codexApiEndpoint)
: parsed
const requestInit = {
return fetch(url, {
...init,
headers,
}
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
})
},
}
},
@@ -518,7 +521,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
authorize: async () => {
const { redirectUri } = await startOAuthServer()
const pkce = await generatePKCE()
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const state = generateState()
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
const callbackPromise = waitForOAuthCallback(pkce, state)
@@ -636,10 +639,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
output.headers.originator = "opencode"
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
output.headers["session-id"] = input.sessionID
// Temporary fetch-layer hack: title generation currently shares the conversation
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
// context can be passed directly instead of smuggled through headers.
if (websocketFetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true"
},
"chat.params": async (input, output) => {
if (input.model.providerID !== "openai") return
+13 -24
View File
@@ -10,7 +10,7 @@ import { Bus } from "../bus"
import * as Log from "@opencode-ai/core/util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { ServerAuth } from "@/server/auth"
import { CodexAuthPlugin } from "./openai/codex"
import { CodexAuthPlugin } from "./codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
@@ -29,7 +29,6 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro
import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
const log = Log.create({ service: "plugin" })
@@ -58,28 +57,18 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) {
return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel)
}
// Built-in plugins that are directly imported (not installed from npm)
function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
return [
// Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in.
(input) =>
CodexAuthPlugin(input, {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
CopilotAuthPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
XaiAuthPlugin,
]
}
const INTERNAL_PLUGINS: PluginInstance[] = [
CodexAuthPlugin,
CopilotAuthPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
XaiAuthPlugin,
]
function isServerPlugin(value: unknown): value is PluginInstance {
return typeof value === "function"
@@ -162,7 +151,7 @@ export const layer = Layer.effect(
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = yield* Effect.tryPromise({
try: () => plugin(input),
@@ -1,31 +0,0 @@
# OpenAI Responses WebSocket
Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`.
## Flow
1. A streamed `POST /responses` request arrives.
2. If it has no `session-id` or `x-session-affinity` header, use HTTP.
3. Title requests use HTTP.
4. If that session's socket is busy or already in fallback mode, use HTTP.
5. Otherwise, reuse its open socket or open a new one.
6. Send `response.create` and return WebSocket events as SSE.
## Lifetime
- Connect timeout: 15 seconds.
- Idle timeout: 5 minutes.
- After a completed response, keep the socket for reuse.
- Reuse a socket for up to 55 minutes, then replace it on the next request.
## Retries
- Retry WebSocket stream/setup failures up to 5 times, then use HTTP for that session until the pool entry is idle-pruned.
- `websocket_connection_limit_reached` consumes the same retry budget and HTTP fallback.
- If a WebSocket fails after its first event, fail it as retryable rather than replaying partial output in transport.
- Abort or cancel closes the socket.
## Next Steps
- `previous_response_id` continuation.
- Optional second WebSocket for concurrent requests in one session. Currently these use HTTP.
@@ -1,272 +0,0 @@
import WebSocket from "ws"
import * as Log from "@opencode-ai/core/util/log"
import { ProviderError } from "@/provider/error"
import { isRecord } from "@/util/record"
import { OpenAIWebSocket } from "./ws"
export const TITLE_HEADER = "x-opencode-title"
const log = Log.create({ service: "plugin.openai.ws" })
export interface CreateWebSocketFetchOptions {
httpFetch?: typeof globalThis.fetch
url?: string
connectTimeout?: number
idleTimeout?: number
maxConnectionAge?: number
streamRetries?: number
}
interface PoolEntry {
socket?: WebSocket
connectedAt?: number
lastUsedAt: number
busy: boolean
fallback: boolean
streamFailures: number
}
const DEFAULT_CONNECT_TIMEOUT = 15_000
const DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1000
const DEFAULT_MAX_CONNECTION_AGE = 55 * 60 * 1000
const CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"
export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
const httpFetch = options?.httpFetch ?? globalThis.fetch
const pool = new Map<string, PoolEntry>()
const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT
const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT
const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE
const streamRetries = options?.streamRetries ?? 5
const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000))
if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") {
pruneTimer.unref()
}
async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url
const internalHeaders = OpenAIWebSocket.normalizeHeaders(init?.headers)
const httpInit = withoutInternalHeaders(init)
if (init?.method !== "POST" || !new URL(url).pathname.endsWith("/responses")) {
return httpFetch(input, httpInit)
}
const body = (() => {
try {
if (typeof init?.body !== "string") return undefined
const parsed = JSON.parse(init.body)
return typeof parsed === "object" && parsed !== null ? parsed : undefined
} catch {
return undefined
}
})()
if (!body?.stream) return httpFetch(input, httpInit)
if (internalHeaders[TITLE_HEADER] === "true") {
log.debug("http fallback", { reason: "title" })
return httpFetch(input, httpInit)
}
const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"]
if (!sessionID) {
log.debug("http fallback", { reason: "missing_session" })
return httpFetch(input, httpInit)
}
const key = `${sessionID}:conversation`
const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 }
pool.set(key, entry)
if (entry.fallback) {
log.debug("http fallback", { key, reason: "fallback_active" })
return httpFetch(input, httpInit)
}
if (entry.busy) {
log.debug("http fallback", { key, reason: "busy" })
return httpFetch(input, httpInit)
}
entry.busy = true
entry.lastUsedAt = Date.now()
try {
entry.socket = await socket(
entry,
options?.url ?? url,
OpenAIWebSocket.normalizeHeaders(httpInit?.headers),
connectTimeout,
maxConnectionAge,
init?.signal,
)
let resolveFirstEvent: (started: boolean) => void = () => {}
let rejectFirstEvent: (error: Error) => void = () => {}
const firstEvent = new Promise<boolean>((resolve, reject) => {
resolveFirstEvent = resolve
rejectFirstEvent = reject
})
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket: entry.socket,
body,
idleTimeout,
signal: init?.signal ?? undefined,
onFirstEvent: () => resolveFirstEvent(true),
onTerminal: (event) => {
entry.busy = false
entry.lastUsedAt = Date.now()
entry.streamFailures = 0
if (event.type !== "response.completed" && event.type !== "response.done") {
log.warn("websocket terminal failure", { key, type: event.type })
invalidate(entry)
}
},
onConnectionInvalid: (error) => {
log.warn("websocket invalidated", { key, error: error.message })
entry.busy = false
if (!entry.fallback) recordStreamFailure(entry)
invalidate(entry)
resolveFirstEvent(false)
},
onAbort: (error) => {
log.debug("websocket aborted", { key })
entry.busy = false
entry.lastUsedAt = Date.now()
entry.streamFailures = 0
invalidate(entry)
rejectFirstEvent(error)
},
onRetryableTerminal: async (event) => {
const error = connectionLimitError(event)
if (!error) return undefined
log.warn("websocket connection limit reached", { key })
throw error
},
})
if (await firstEvent) return response
if (!entry.fallback) return response
log.debug("http fallback", { key, reason: "websocket_retries_exhausted" })
return httpFetch(input, httpInit)
} catch (error) {
entry.busy = false
entry.lastUsedAt = Date.now()
if (OpenAIWebSocket.isAbortError(error)) {
entry.streamFailures = 0
invalidate(entry)
throw error
}
recordStreamFailure(entry)
log.warn("websocket setup failed", {
key,
error: error instanceof Error ? error.message : String(error),
fallback: entry.fallback ? "http" : undefined,
})
invalidate(entry)
if (entry.fallback) return httpFetch(input, httpInit)
return failedResponse(
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
cause: error,
}),
)
}
}
function recordStreamFailure(entry: PoolEntry) {
entry.streamFailures++
// Codex counts retries after the initial failed WebSocket attempt.
if (entry.streamFailures > streamRetries) entry.fallback = true
}
function prune() {
const now = Date.now()
for (const [key, entry] of pool) {
if (entry.busy) continue
if (now - entry.lastUsedAt < idleTimeout) continue
log.debug("websocket idle prune", { key })
invalidate(entry)
pool.delete(key)
}
}
function close() {
log.debug("websocket pool close", { count: pool.size })
clearInterval(pruneTimer)
for (const entry of pool.values()) invalidate(entry)
pool.clear()
}
return Object.assign(websocketFetch, { close })
}
function connectionLimitError(event: Record<string, unknown>) {
if (event.type !== "error" || !isRecord(event.error) || event.error.code !== CONNECTION_LIMIT_REACHED_CODE) return
return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE)
}
function failedResponse(error: ProviderError.ResponseStreamError) {
return new Response(
new ReadableStream({
start(controller) {
controller.error(error)
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
},
)
}
async function socket(
entry: PoolEntry,
url: string,
headers: Record<string, string>,
connectTimeout: number,
maxConnectionAge: number,
signal?: AbortSignal | null,
) {
if (
entry.socket?.readyState === WebSocket.OPEN &&
entry.connectedAt &&
Date.now() - entry.connectedAt < maxConnectionAge
) {
return entry.socket
}
invalidate(entry)
const next = await OpenAIWebSocket.connectResponsesWebSocket({
url: OpenAIWebSocket.toWebSocketUrl(url),
headers,
timeout: connectTimeout,
signal: signal ?? undefined,
})
entry.connectedAt = Date.now()
return next
}
function invalidate(entry: PoolEntry) {
if (entry.socket) {
entry.socket.on("error", () => {})
entry.socket.terminate()
entry.socket = undefined
}
entry.connectedAt = undefined
}
export function withoutInternalHeaders<T extends { headers?: HeadersInit }>(init: T | undefined): T | undefined {
if (!init?.headers) return init
if (init.headers instanceof Headers) {
const headers = new Headers(init.headers)
headers.delete(TITLE_HEADER)
return { ...init, headers }
}
if (Array.isArray(init.headers)) {
return { ...init, headers: init.headers.filter((item) => item[0].toLowerCase() !== TITLE_HEADER) }
}
return {
...init,
headers: Object.fromEntries(Object.entries(init.headers).filter(([key]) => key.toLowerCase() !== TITLE_HEADER)),
}
}
export * as OpenAIWebSocketPool from "./ws-pool"
-326
View File
@@ -1,326 +0,0 @@
// Low-level OpenAI Responses WebSocket protocol helpers. Session pooling,
// fallback, and continuation state intentionally live above this file.
import WebSocket from "ws"
import { ProviderError } from "@/provider/error"
export const PROTOCOL_HEADER = "responses_websockets=2026-02-06"
export interface ConnectResponsesWebSocketOptions {
url: string
headers: Record<string, string>
timeout?: number
signal?: AbortSignal
}
export interface StreamResponsesWebSocketOptions {
socket: WebSocket
body: Record<string, unknown>
idleTimeout?: number
signal?: AbortSignal
onFirstEvent?: () => void
onComplete?: (event: Record<string, unknown>) => void
onTerminal?: (event: Record<string, unknown>) => void
onRetryableTerminal?: (event: Record<string, unknown>) => Promise<WebSocket | undefined>
onConnectionInvalid?: (error: ProviderError.ResponseStreamError) => void
onAbort?: (error: Error) => void
}
export function toWebSocketUrl(url: string) {
return url.replace(/^http/, "ws")
}
export function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const result: Record<string, string> = {}
if (!headers) return result
if (headers instanceof Headers) {
headers.forEach((value, key) => {
result[key.toLowerCase()] = value
})
return result
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
result[key.toLowerCase()] = value
}
return result
}
for (const [key, value] of Object.entries(headers)) {
if (value != null) result[key.toLowerCase()] = value
}
return result
}
export function isAbortError(error: unknown): error is DOMException {
return error instanceof DOMException && error.name === "AbortError"
}
export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOptions) {
return new Promise<WebSocket>((resolve, reject) => {
if (options.signal?.aborted) {
reject(abortError(options.signal))
return
}
const headers: Record<string, string> = {
...options.headers,
"openai-beta": options.headers["openai-beta"] ?? PROTOCOL_HEADER,
}
delete headers["content-length"]
const socket = new WebSocket(options.url, { headers })
const timeout = options.timeout
? setTimeout(() => {
cleanup()
socket.on("error", () => {})
socket.terminate()
reject(new Error("WebSocket connect timed out"))
}, options.timeout)
: undefined
function cleanup() {
if (timeout) clearTimeout(timeout)
socket.off("open", onOpen)
socket.off("error", onError)
socket.off("close", onClose)
options.signal?.removeEventListener("abort", onAbort)
}
function onOpen() {
cleanup()
resolve(socket)
}
function onError(error: Error) {
socket.on("error", () => {})
cleanup()
reject(error)
}
function onClose(code: number, reason: Buffer) {
cleanup()
reject(new Error(closeMessage("WebSocket closed before open", code, reason)))
}
function onAbort() {
cleanup()
socket.on("error", () => {})
socket.terminate()
reject(abortError(options.signal))
}
socket.once("open", onOpen)
socket.once("error", onError)
socket.once("close", onClose)
options.signal?.addEventListener("abort", onAbort, { once: true })
})
}
export function streamResponsesWebSocket(options: StreamResponsesWebSocketOptions) {
const encoder = new TextEncoder()
let socket = options.socket
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
let cleanupSocket = () => {}
let completed = false
let emitted = false
let idleTimer: ReturnType<typeof setTimeout> | undefined
function cleanup() {
if (idleTimer) clearTimeout(idleTimer)
cleanupSocket()
options.signal?.removeEventListener("abort", onAbort)
}
function terminateSocket(target = socket) {
target.on("error", () => {})
target.terminate()
}
function closeCompleted() {
cleanup()
controller?.enqueue(encoder.encode("data: [DONE]\n\n"))
controller?.close()
}
function invalidate(error: ProviderError.ResponseStreamError) {
if (completed) return
completed = true
cleanup()
options.onConnectionInvalid?.(error)
controller?.error(error)
}
function resetIdleTimeout(message: string) {
if (completed) return
if (!options.idleTimeout) return
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => invalidate(new ProviderError.ResponseStreamError(message)), options.idleTimeout)
}
async function onMessage(data: WebSocket.RawData, isBinary: boolean) {
if (completed) return
if (isBinary) {
invalidate(new ProviderError.ResponseStreamError("Unexpected binary WebSocket frame"))
return
}
const text = data.toString()
const event = (() => {
try {
const parsed = JSON.parse(text)
return typeof parsed === "object" && parsed !== null ? parsed : undefined
} catch {
return undefined
}
})()
if (event?.type === "error" && !emitted && options.onRetryableTerminal) {
cleanupSocket()
if (idleTimer) clearTimeout(idleTimer)
idleTimer = undefined
try {
const next = await options.onRetryableTerminal(event)
if (completed) {
if (next) terminateSocket(next)
return
}
if (next) {
attach(next)
return
}
} catch (error) {
invalidate(
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
cause: error,
}),
)
return
}
}
if (!emitted) options.onFirstEvent?.()
controller?.enqueue(
encoder.encode(
`${text
.split(/\r?\n/)
.map((line) => `data: ${line}`)
.join("\n")}\n\n`,
),
)
emitted = true
resetIdleTimeout("idle timeout waiting for websocket")
if (!event) return
if (event.type === "response.completed" || event.type === "response.done") {
completed = true
options.onComplete?.(event)
options.onTerminal?.(event)
closeCompleted()
return
}
if (event.type === "response.failed" || event.type === "response.incomplete" || event.type === "error") {
completed = true
options.onTerminal?.(event)
closeCompleted()
}
}
function onError(error: Error) {
invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
}
function onClose(code: number, reason: Buffer) {
if (completed) return
invalidate(
new ProviderError.ResponseStreamError(closeMessage("WebSocket closed before response.completed", code, reason)),
)
}
function onAbort() {
const error = abortError(options.signal)
if (completed) return
completed = true
cleanup()
terminateSocket()
options.onAbort?.(error)
controller?.error(error)
}
function onCancel(reason: unknown) {
if (completed) return
completed = true
cleanup()
terminateSocket()
options.onAbort?.(cancelError(reason))
}
function attach(next: WebSocket) {
cleanupSocket()
socket = next
socket.on("message", onMessage)
socket.once("error", onError)
socket.once("close", onClose)
cleanupSocket = () => {
socket.off("message", onMessage)
socket.off("error", onError)
socket.off("close", onClose)
}
const { stream: _stream, background: _background, ...payload } = options.body
resetIdleTimeout("idle timeout sending websocket request")
socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => {
if (completed) return
resetIdleTimeout("idle timeout waiting for websocket")
if (error) invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
})
}
return new Response(
new ReadableStream<Uint8Array>({
start(next) {
controller = next
options.signal?.addEventListener("abort", onAbort, { once: true })
if (options.signal?.aborted) {
onAbort()
return
}
attach(socket)
},
cancel(reason) {
onCancel(reason)
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
},
)
}
function cancelError(reason: unknown) {
if (isAbortError(reason)) return reason
if (reason instanceof Error) return reason
return new DOMException(typeof reason === "string" ? reason : "Aborted", "AbortError")
}
function abortError(signal: AbortSignal | undefined) {
const reason = signal?.reason
if (isAbortError(reason)) return reason
return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError")
}
function closeMessage(message: string, code: number, reason: Buffer) {
const details = [`code ${code}`]
if (code === 1009) details.push("message too big")
if (reason.length > 0) details.push(reason.toString())
return `${message} (${details.join(": ")})`
}
export * as OpenAIWebSocket from "./ws"
-8
View File
@@ -11,14 +11,6 @@ export class HeaderTimeoutError extends Error {
}
}
export class ResponseStreamError extends Error {
public override readonly name = "ProviderResponseStreamError"
constructor(message: string, options?: ErrorOptions) {
super(message, options)
}
}
// Adapted from overflow detection patterns in:
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
const OVERFLOW_PATTERNS = [
+7 -12
View File
@@ -596,16 +596,8 @@ function openaiCompatibleReasoningEfforts(id: string) {
return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS
}
function anthropicOpus47OrLater(apiId: string) {
const version = /opus-(\d+)[.-](\d+)(?:[.-]|$)/i.exec(apiId)
if (!version) return false
const major = Number(version[1])
const minor = Number(version[2])
return major > 4 || (major === 4 && minor >= 7)
}
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
if (anthropicOpus47OrLater(apiId)) {
if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
return ["low", "medium", "high", "xhigh", "max"]
}
if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) {
@@ -633,7 +625,6 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
if (!model.capabilities.reasoning) return {}
const id = model.id.toLowerCase()
const adaptiveOpus = anthropicOpus47OrLater(model.api.id)
const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
if (
id.includes("deepseek-chat") ||
@@ -842,7 +833,9 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
thinking: {
type: "adaptive",
...(adaptiveOpus ? { display: "summarized" } : {}),
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
? { display: "summarized" }
: {}),
},
effort,
},
@@ -879,7 +872,9 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(adaptiveOpus ? { display: "summarized" } : {}),
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
? { display: "summarized" }
: {}),
},
},
]),
@@ -14,20 +14,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type.
Use `HttpApiBuilder.group(...)` with `handleRaw(...)` for declared endpoints that need the raw request or response, including WebSocket upgrade routes. This keeps endpoint middleware, routing context, and OpenAPI metadata on one typed route tree.
Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks.
```ts
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
export const rawRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
return handlers.handleRaw("connect", (ctx) => connectPty(ctx.request, pty))
yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty))
}),
)
```
Use raw `HttpRouter.use(...)` only for routes outside the declared API surface, such as a catch-all UI fallback.
Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request.
Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services.
@@ -36,4 +34,4 @@ Use `Effect.provideService(...)` in middleware only for request-derived context,
Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
When adding middleware, declare endpoint-contract middleware on the owning `HttpApiGroup` and provide its implementation layer at the assembly boundary in `server.ts`. Keep router middleware for truly raw fallback routes or global transport policy.
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
@@ -1,8 +1,6 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingQuery } from "../middleware/workspace-routing"
export const EventPaths = {
event: "/event",
@@ -22,8 +20,5 @@ export const EventApi = HttpApi.make("event").add(
}),
),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization)
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
)
@@ -1,10 +1,9 @@
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { PtyID } from "@/pty/schema"
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization, PtyConnectAuthorization } from "../middleware/authorization"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
@@ -139,10 +138,9 @@ export const PtyApi = HttpApi.make("pty")
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
// Decode PTY connection query fields in the raw handler after checking
// existence, preserving the established empty-404 response ordering.
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Connected session"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(
@@ -151,22 +149,8 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
transform: (operation) => ({
...operation,
parameters: [
...(operation.parameters ?? []),
...["directory", "workspace", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
in: "query",
name,
schema: { type: "string" },
})),
],
}),
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(PtyConnectAuthorization),
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)
@@ -10,13 +10,13 @@ import {
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { Effect, Option, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import * as ApiError from "../errors"
import { CursorQuery, PtyConnectApi } from "../groups/pty"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
@@ -121,39 +121,37 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
}),
)
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
export const ptyConnectRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
return handlers.handleRaw(
"connect",
Effect.fn("PtyHttpApi.connect")(function* (ctx: {
params: { ptyID: PtyID }
request: HttpServerRequest.HttpServerRequest
}) {
const exists = yield* pty.get(ctx.params.ptyID).pipe(
yield* router.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const params = yield* HttpRouter.schemaPathParams(Params)
const exists = yield* pty.get(params.ptyID).pipe(
Effect.as(true),
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
)
if (!exists) return HttpServerResponse.empty({ status: 404 })
const query = Schema.decodeUnknownOption(CursorQuery)(yield* HttpServerRequest.ParsedSearchParams)
if (Option.isNone(query)) return HttpServerResponse.empty({ status: 400 })
const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const request = yield* HttpServerRequest.HttpServerRequest
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = validOrigin(ctx.request, cors)
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
const valid = validOrigin(request, cors)
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
const parsedCursor = query.value.cursor === undefined ? undefined : Number(query.value.cursor)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
? parsedCursor
: undefined
const socket = yield* Effect.orDie(ctx.request.upgrade)
const socket = yield* Effect.orDie(request.upgrade)
const write = yield* socket.writer
const closeAccepted = (event: Socket.CloseEvent) =>
socket
@@ -188,7 +186,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
},
}
const handler = yield* pty
.connect(ctx.params.ptyID, adapter, cursor)
.connect(params.ptyID, adapter, cursor)
.pipe(
Effect.catchTag("Pty.NotFoundError", () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
@@ -196,8 +194,12 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
)
if (!handler) return HttpServerResponse.empty()
// The handshake runs inside `socket.runRaw`, after the input callback is
// registered, so the client cannot send frames before PTY input is wired.
// No `pending[]`-style early-frame buffer (the legacy handler had one).
// `request.upgrade` returns a Socket without running the WS handshake; the
// handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves
// and the message callback is registered. The client therefore can't fire
// `open` and start sending until the listener is already wired. Don't move
// `runRaw` ahead of `pty.connect` without re-introducing a buffer.
yield* socket
.runRaw((message) => handlePtyInput(handler, message))
.pipe(
@@ -27,13 +27,6 @@ export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>(
},
) {}
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
{
error: HttpApiError.UnauthorizedNoContent,
},
) {}
function emptyCredential() {
return {
username: "",
@@ -63,11 +56,11 @@ function decodeCredential(input: string) {
Effect.match({
onFailure: emptyCredential,
onSuccess: (header) => {
const separator = header.indexOf(":")
if (separator === -1) return emptyCredential()
const parts = header.split(":")
if (parts.length !== 2) return emptyCredential()
return {
username: header.slice(0, separator),
password: Redacted.make(header.slice(separator + 1)),
username: parts[0],
password: Redacted.make(parts[1]),
}
},
}),
@@ -112,6 +105,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
if (hasPtyConnectTicketURL(url)) return yield* effect
return yield* credentialFromURL(url, request).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
@@ -135,24 +129,6 @@ export const authorizationLayer = Layer.effect(
}),
)
export const ptyConnectAuthorizationLayer = Layer.effect(
PtyConnectAuthorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return PtyConnectAuthorization.of((effect) => effect)
return PtyConnectAuthorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (hasPtyConnectTicketURL(url)) return yield* effect
return yield* credentialFromURL(url, request).pipe(
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
)
}),
)
}),
)
export const v2AuthorizationLayer = Layer.effect(
V2Authorization,
Effect.gen(function* () {
@@ -1,7 +1,7 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { Effect, Layer } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { WorkspaceRouteContext } from "./workspace-routing"
@@ -41,3 +41,10 @@ export const instanceContextLayer = Layer.effect(
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
}),
)
export const instanceRouterMiddleware = HttpRouter.middleware()(
Effect.gen(function* () {
const store = yield* InstanceStore.Service
return (effect) => provideInstanceContext(effect, store)
}),
)
@@ -4,11 +4,17 @@ import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerR
import * as Socket from "effect/unstable/socket/Socket"
import { WebSocketTracker } from "../websocket-tracker"
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
return request.source instanceof Request ? request.source : undefined
}
function requestBody(request: HttpServerRequest.HttpServerRequest) {
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
if (request.source instanceof Request && request.source.body === null) return HttpBody.empty
const len = request.headers["content-length"]
return HttpBody.stream(request.stream, request.headers["content-type"], len ? Number(len) : undefined)
return HttpBody.raw(webSource(request)?.body ?? null, {
contentType: request.headers["content-type"],
contentLength: len ? Number(len) : undefined,
})
}
export function websocket(
@@ -9,7 +9,7 @@ import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL }
import { NotFoundError } from "@/storage/storage"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InvalidRequestError } from "../errors"
@@ -159,14 +159,14 @@ function planWorkspaceRequest(
function planRequest(
request: HttpServerRequest.HttpServerRequest,
session?: Session.Info,
sessionWorkspaceID?: WorkspaceID,
): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () {
const url = requestURL(request)
const envWorkspaceID = configuredWorkspaceID()
const workspaceID = url.pathname.startsWith("/api/")
? selectedV2WorkspaceID(url, session?.workspaceID)
: selectedWorkspaceID(url, session?.workspaceID)
? selectedV2WorkspaceID(url, sessionWorkspaceID)
: selectedWorkspaceID(url, sessionWorkspaceID)
if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace()
const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID)
@@ -178,10 +178,7 @@ function planRequest(
return yield* planWorkspaceRequest(request, url, workspace)
}
return RequestPlan.Local({
directory: session?.directory || defaultDirectory(request, url),
workspaceID: envWorkspaceID ?? workspaceID,
})
return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID })
})
}
@@ -222,14 +219,11 @@ function routeHttpApiWorkspace<E>(
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
Effect.catchIf(
(error): error is NotFoundError => NotFoundError.isInstance(error),
() => Effect.succeed(undefined),
),
Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)),
Effect.catchDefect(() => Effect.succeed(undefined)),
)
: undefined
const plan = yield* planRequest(request, session)
const plan = yield* planRequest(request, session?.workspaceID)
return yield* routeWorkspace(client, effect, plan)
})
}
@@ -248,3 +242,20 @@ export const workspaceRoutingLayer = Layer.effect(
)
}),
)
export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()(
Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service
const client = yield* HttpClient.HttpClient
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const plan = yield* planRequest(request)
return yield* routeWorkspace(client, effect, plan)
}).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace),
)
}),
)
@@ -59,14 +59,8 @@ import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { PublicApi } from "./public"
import {
authorizationLayer,
authorizationRouterMiddleware,
ptyConnectAuthorizationLayer,
v2AuthorizationLayer,
} from "./middleware/authorization"
import { authorizationLayer, authorizationRouterMiddleware, v2AuthorizationLayer } from "./middleware/authorization"
import { EventApi } from "./groups/event"
import { PtyConnectApi } from "./groups/pty"
import { eventHandlers } from "./handlers/event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
@@ -78,15 +72,15 @@ import { mcpHandlers } from "./handlers/mcp"
import { permissionHandlers } from "./handlers/permission"
import { projectHandlers } from "./handlers/project"
import { providerHandlers } from "./handlers/provider"
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { v2Handlers } from "./handlers/v2"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer } from "./middleware/instance-context"
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { compressionLayer } from "./middleware/compression"
@@ -108,27 +102,24 @@ const cors = (corsOptions?: CorsOptions) =>
// Route tree:
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
// - eventApiRoutes: typed SSE route with instance routing context and its existing API contract.
// - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth.
// - instanceApiRoutes: remaining typed instance routes.
// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware.
// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below.
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide(schemaErrorLayer),
Layer.provide(httpApiAuthLayer),
)
const instanceRouterLayer = authorizationRouterMiddleware
.combine(instanceRouterMiddleware)
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer))
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
Layer.provide(eventHandlers),
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
)
const ptyConnectApiRoutes = HttpApiBuilder.layer(PtyConnectApi).pipe(
Layer.provide(ptyConnectHandlers),
Layer.provide([ptyConnectHttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
Layer.provide(instanceRouterLayer),
)
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
@@ -150,8 +141,15 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
]),
)
const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([
httpApiAuthLayer,
v2HttpApiAuthLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
instanceContextLayer,
schemaErrorLayer,
]),
)
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
@@ -186,7 +184,7 @@ type RouteRequirements =
export function createRoutes(
corsOptions?: CorsOptions,
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
Layer.provide([
errorLayer,
compressionLayer,
@@ -1155,17 +1155,6 @@ export function fromError(
},
{ cause: e },
).toObject()
case e instanceof ProviderError.ResponseStreamError:
return new APIError(
{
message: e.message,
isRetryable: true,
metadata: {
code: e.name,
},
},
{ cause: e },
).toObject()
case APICallError.isInstance(e):
const parsed = ProviderError.parseAPICallError({
providerID: ctx.providerID,
+1 -1
View File
@@ -351,7 +351,7 @@ export const layer: Layer.Layer<
return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
}
const primary = yield* canonical(ctx.project.worktree)
const primary = yield* canonical(ctx.worktree)
const primaryName = pathSvc.basename(primary).toLowerCase()
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
Effect.gen(function* () {
@@ -1,237 +0,0 @@
import { describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
RequestPermissionRequest,
RequestPermissionResponse,
SessionUpdate,
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
import { ACPNextEvent } from "@/acp-next/event"
import { ACPNextSession } from "@/acp-next/session"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
const pollUntil = async (
check: () => boolean | Promise<boolean>,
message: string,
opts?: { timeoutMs?: number; intervalMs?: number },
) => {
const started = Date.now()
while (true) {
if (await check()) return
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
}
}
function makeSessionService() {
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)),
)
}
function createHarness(
requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
) {
const replies: PermissionReplyParams[] = []
const requests: RequestPermissionRequest[] = []
const updates: SessionUpdateParams[] = []
const session = makeSessionService()
const sdk = {
permission: {
reply: (params: PermissionReplyParams) => {
replies.push(params)
return Promise.resolve({ data: true })
},
},
session: {
message: () => Promise.resolve({ data: undefined }),
},
} as unknown as OpencodeClient
const connection = {
requestPermission: (params: RequestPermissionRequest) => {
requests.push(params)
return requestPermission(params)
},
sessionUpdate: (params: SessionUpdateParams) => {
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
return { connection, replies, requests, sdk, session, subscription, updates }
}
async function createSession(session: ACPNextSession.Interface, sessionId: string, cwd = "/workspace") {
await Effect.runPromise(session.create({ id: sessionId, cwd }))
}
async function createKnownTextPart(
session: ACPNextSession.Interface,
sessionId: string,
messageId: string,
partId: string,
) {
await Effect.runPromise(
session.recordPartMetadata({
sessionId,
messageId,
partId,
partType: "text",
role: "assistant",
}),
)
}
function permissionAsked(
sessionID: string,
id: string,
input: {
permission?: string
metadata?: Record<string, unknown>
tool?: { messageID: string; callID: string }
} = {},
) {
return {
id: `evt_${id}`,
type: "permission.asked",
properties: {
id,
sessionID,
permission: input.permission ?? "bash",
patterns: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
always: [],
...(input.tool ? { tool: input.tool } : {}),
},
} as PermissionEvent
}
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
return {
id: `evt_${sessionID}_${messageID}_${partID}`,
type: "message.part.delta",
properties: {
sessionID,
messageID,
partID,
field: "text",
delta,
},
} as Event
}
function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
return updates
.filter((item) => item.sessionId === sessionId)
.map((item) => item.update)
.filter((update): update is Extract<SessionUpdate, { sessionUpdate: "agent_message_chunk" }> => {
return update.sessionUpdate === "agent_message_chunk"
})
.map((update) => (update.content.type === "text" ? update.content.text : ""))
.join("")
}
describe("acp-next permissions", () => {
it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } }))
await pollUntil(() => harness.replies.length === 1, "permission was never replied")
expect(harness.requests[0]).toMatchObject({
sessionId: "ses_a",
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "bash",
rawInput: { command: "printf hello" },
kind: "execute",
locations: [],
},
options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
],
})
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
})
it("rejects non-selected outcomes", async () => {
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled"))
await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" })
})
it("rejects when requestPermission fails", async () => {
const harness = createHarness(() => Promise.reject(new Error("client permission UI failed")))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_failed"))
await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" })
})
it("does not let a blocked session A permission block session B message updates", async () => {
let releasePermission: (() => void) | undefined
const blocked = new Promise<RequestPermissionResponse>((resolve) => {
releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() => blocked)
await createSession(harness.session, "ses_a")
await createSession(harness.session, "ses_b")
await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b")
harness.subscription.handle(permissionAsked("ses_a", "perm_blocked"))
await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested")
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message"))
expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message")
expect(harness.replies).toHaveLength(0)
releasePermission?.()
await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release")
})
it("serializes permission requests per session", async () => {
let releaseFirst: (() => void) | undefined
const first = new Promise<RequestPermissionResponse>((resolve) => {
releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() =>
harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }),
)
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1"))
harness.subscription.handle(permissionAsked("ses_a", "perm_2"))
await pollUntil(() => harness.requests.length === 1, "first permission was never requested")
expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
releaseFirst?.()
await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved")
await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied")
expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
})
})
@@ -4,7 +4,6 @@ import type {
ForkSessionResponse,
LoadSessionResponse,
NewSessionResponse,
SessionNotification,
ResumeSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
@@ -15,7 +14,6 @@ import { Effect, ManagedRuntime } from "effect"
import * as ACPNextService from "@/acp-next/service"
import * as ACPNextError from "@/acp-next/error"
import { ACPNextSession } from "@/acp-next/session"
import { UsageService } from "@/acp-next/usage"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
@@ -143,14 +141,10 @@ const provider: Provider.Info = {
describe("ACP next service sessions", () => {
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
const updates: SessionNotification[] = []
const updates: unknown[] = []
const mcpAdds: string[] = []
const aborts: string[] = []
const forks: string[] = []
const prompts: unknown[] = []
const commands: unknown[] = []
const summarizes: unknown[] = []
const usageUpdates: string[] = []
const sessions = Array.from({ length: 102 }, (_, index) => ({
id: `ses_${index + 1}`,
directory: index % 2 === 0 ? "/workspace" : "/other",
@@ -190,36 +184,6 @@ describe("ACP next service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}),
messages: () => Promise.resolve({ data: messages }),
prompt: (input: unknown) => {
prompts.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}),
},
})
},
command: (input: unknown) => {
commands.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 3,
output: 4,
reasoning: 0,
cache: { read: 0, write: 0 },
}),
},
})
},
summarize: (input: unknown) => {
summarizes.push(input)
return Promise.resolve({ data: true })
},
abort: (input: { sessionID: string }) => {
aborts.push(input.sessionID)
return Promise.resolve({ data: true })
@@ -237,33 +201,13 @@ describe("ACP next service sessions", () => {
},
} as unknown as OpencodeClient
const connection = {
sessionUpdate: (update: SessionNotification) => {
sessionUpdate: (update: unknown) => {
updates.push(update)
return Promise.resolve()
},
} as Pick<AgentSideConnection, "sessionUpdate">
const usage = UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit: () => Effect.succeed(128000),
sendUpdate: (input) =>
Effect.sync(() => {
usageUpdates.push(input.sessionID)
}),
})
return {
service: ACPNextService.make({ sdk, connection, usage }),
updates,
mcpAdds,
aborts,
forks,
prompts,
commands,
summarizes,
usageUpdates,
}
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds, aborts, forks }
}
it("creates a backed session with config options and command update", async () => {
@@ -323,15 +267,6 @@ describe("ACP next service sessions", () => {
expect(second.sessions).toEqual(first.sessions)
})
it("includes live ACP sessions before they appear in server-backed session list", async () => {
const { service } = makeService()
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
expect(listed.sessions[0]?.sessionId).toBe(created.sessionId)
expect(listed.sessions[0]?.cwd).toBe("/workspace")
})
it("lists all sessions with next cursor when the first page is full", async () => {
const { service } = makeService()
const first = await Effect.runPromise(service.listSessions({}))
@@ -611,46 +546,6 @@ describe("ACP next service sessions", () => {
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
})
it("does not scan last-used sessions when resolving the new session default", async () => {
const historyCalls: string[] = []
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
list: () => {
historyCalls.push("list")
return Promise.resolve({ data: [{ id: "ses_recent" }] })
},
messages: () => {
historyCalls.push("messages")
return Promise.resolve({
data: [{ info: { role: "user", model: { providerID: "test", modelID: "second-model" } } }],
})
},
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(result.sessionId).toBe("test-model")
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model")
expect(historyCalls).toEqual([])
})
it("switches model and returns updated model and effort options", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -716,35 +611,24 @@ describe("ACP next service sessions", () => {
expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
})
it("does not refetch providers modes or commands when switching effort from session snapshot", async () => {
const calls = {
providers: 0,
agents: 0,
commands: 0,
skills: 0,
mcpAdds: 0,
}
it("does not reload providers or commands when switching effort from a warm snapshot", async () => {
let providersCalls = 0
let commandCalls = 0
const sdk = {
config: {
providers: () => {
calls.providers++
providersCalls++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => {
calls.commands++
commandCalls++
return Promise.resolve({ data: [] })
},
},
@@ -753,16 +637,14 @@ describe("ACP next service sessions", () => {
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => {
calls.mcpAdds++
return Promise.resolve({ data: {} })
},
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
await Effect.runPromise(
service.setSessionConfigOption({
@@ -772,350 +654,11 @@ describe("ACP next service sessions", () => {
}),
)
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
})
it("switches model against the warm provider snapshot without refetching", async () => {
const calls = {
providers: 0,
agents: 0,
commands: 0,
skills: 0,
}
const sdk = {
config: {
providers: () => {
calls.providers++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
calls.commands++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => Promise.resolve({ data: { id: "ses_model_fast" } }),
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
}),
)
expect(select(updated, "model")?.currentValue).toBe("test/second-model")
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1 })
})
it("reuses the warm directory snapshot for a second new session in the same cwd", async () => {
const calls = {
providers: 0,
config: 0,
agents: 0,
commands: 0,
skills: 0,
sessionList: 0,
messages: 0,
creates: 0,
}
const sdk = {
config: {
providers: () => {
calls.providers++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => {
calls.config++
return Promise.resolve({ data: {} })
},
},
app: {
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
calls.commands++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => {
calls.creates++
return Promise.resolve({ data: { id: `ses_warm_${calls.creates}` } })
},
list: () => {
calls.sessionList++
return Promise.resolve({ data: [] })
},
messages: () => {
calls.messages++
return Promise.resolve({ data: [] })
},
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(first.sessionId).toBe("ses_warm_1")
expect(second.sessionId).toBe("ses_warm_2")
expect(calls).toEqual({
providers: 1,
config: 1,
agents: 1,
commands: 1,
skills: 1,
sessionList: 0,
messages: 0,
creates: 2,
})
})
it("normal text prompt sends model variant mode and converted parts", async () => {
const { service, prompts, usageUpdates } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
}),
)
await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "plan",
}),
)
const result = await Effect.runPromise(
service.prompt({
sessionId: session.sessionId,
messageId: "00000000-0000-4000-8000-000000000001",
prompt: [{ type: "text", text: "hello" }],
}),
)
expect(prompts).toEqual([
{
sessionID: session.sessionId,
model: { providerID, modelID },
variant: "high",
parts: [{ type: "text", text: "hello" }],
agent: "plan",
directory: "/workspace",
},
])
expect(result).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 100,
outputTokens: 40,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
},
userMessageId: "00000000-0000-4000-8000-000000000001",
_meta: {},
})
expect(usageUpdates).toEqual([session.sessionId])
})
it("prompt maps assistant and user audience annotations", async () => {
const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.prompt({
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "assistant context", annotations: { audience: ["assistant"] } },
{ type: "text", text: "user context", annotations: { audience: ["user"] } },
],
}),
)
expect(prompts).toContainEqual({
sessionID: session.sessionId,
model: { providerID, modelID },
variant: "default",
parts: [
{ type: "text", text: "assistant context", synthetic: true },
{ type: "text", text: "user context", ignored: true },
],
agent: "build",
directory: "/workspace",
})
})
it("prompt sends image and resource parts", async () => {
const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.prompt({
sessionId: session.sessionId,
prompt: [
{ type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///tmp/screenshot.png" },
{
type: "resource",
resource: {
uri: "file:///tmp/report.pdf",
mimeType: "application/pdf",
blob: "JVBERg==",
},
},
],
}),
)
expect((prompts[0] as { parts?: unknown }).parts).toEqual([
{
type: "file",
url: "data:image/png;base64,AAAA",
filename: "screenshot.png",
mime: "image/png",
},
{
type: "file",
url: "data:application/pdf;base64,JVBERg==",
filename: "report.pdf",
mime: "application/pdf",
},
])
})
it("slash command prompt calls session command", async () => {
const { service, prompts, commands } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const result = await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init now" }] }),
)
expect(prompts).toEqual([])
expect(commands).toEqual([
{
sessionID: session.sessionId,
command: "init",
arguments: "now",
model: "test/test-model",
variant: "default",
agent: "build",
directory: "/workspace",
},
])
expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 })
})
it("compact slash command calls summarize path", async () => {
const { service, prompts, commands, summarizes } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }),
)
expect(prompts).toEqual([])
expect(commands).toEqual([])
expect(summarizes).toEqual([
{
sessionID: session.sessionId,
directory: "/workspace",
providerID,
modelID,
},
])
})
it("maps prompt auth failures to auth-required request errors", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const failing = ACPNextService.make({
sdk: {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: () => Promise.resolve({ data: { id: session.sessionId } }),
list: () => Promise.resolve({ data: [] }),
prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient,
usage: UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit: () => Effect.succeed(128000),
sendUpdate: () => Effect.void,
}),
})
await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] }))
const error = await Effect.runPromise(
failing
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32000)
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
})
})
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
return {
role: "assistant",
providerID: "test",
modelID: "test-model",
cost: 0,
tokens,
}
}
function categories(result: NewSessionResponse | LoadSessionResponse) {
return result.configOptions?.map((option) => option.category) ?? []
}
@@ -0,0 +1,282 @@
import { describe, expect } from "bun:test"
import type {
AuthenticateResponse,
CloseSessionResponse,
InitializeResponse,
LoadSessionResponse,
NewSessionResponse,
ResumeSessionResponse,
SessionNotification,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import { createAcpClient, expectOk, firstAlternateValue, selectConfigOption } from "../acp/acp-test-client"
describe("opencode acp-next (subprocess)", () => {
cliIt.live(
"responds to initialize behind OPENCODE_ACP_NEXT",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
const initialized = expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
}),
)
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
}),
60_000,
)
cliIt.live(
"authenticate succeeds for the advertised auth method and rejects unknown methods safely",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
const methodId = initialized.authMethods?.[0]?.id
expect(methodId).toBe("opencode-login")
expectOk(yield* acp.request<AuthenticateResponse>("authenticate", { methodId }))
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expect(errorCode(rejected.error)).toBe(-32602)
}),
60_000,
)
cliIt.live(
"creates and loads sessions behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(testProviderConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expect(typeof session.sessionId).toBe("string")
expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model")
const update = yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId && params.update.sessionUpdate === "available_commands_update",
)
expect(update.params?.sessionId).toBe(session.sessionId)
const loaded = expectOk(
yield* acp.request<LoadSessionResponse>("session/load", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
const prompt = yield* acp.request("session/prompt", {
sessionId: "ses_missing",
prompt: [{ type: "text", text: "hello" }],
})
expect(errorCode(prompt.error)).toBe(-32601)
}),
60_000,
)
cliIt.live(
"switches model through config options behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe("test/second-model")
}),
60_000,
)
cliIt.live(
"switches effort through config options behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const effort = selectConfigOption(session.configOptions, "effort")
expect(effort?.category).toBe("thought_level")
const nextEffort = effort ? firstAlternateValue(effort) : undefined
expect(nextEffort).toBe("high")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}),
60_000,
)
cliIt.live(
"advertises and supports close behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
}),
60_000,
)
cliIt.live(
"advertises and supports resume behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
cliIt.live(
"exits cleanly when flagged stdin is closed",
({ opencode }) =>
Effect.gen(function* () {
const exitedPromise = yield* Effect.scoped(
Effect.gen(function* () {
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
return acp.exited
}),
)
const code = yield* Effect.promise(() => exitedPromise)
expect(typeof code === "number" || code === null).toBe(true)
}),
60_000,
)
cliIt.live(
"default unflagged path still uses production ACP",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
})
function errorCode(error: unknown) {
if (!error || typeof error !== "object") return undefined
if (!("code" in error)) return undefined
return typeof error.code === "number" ? error.code : undefined
}
function verifierConfig(llmUrl: string) {
const config = testProviderConfig(llmUrl)
return {
...config,
model: "test/test-model",
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: {
low: {},
high: {},
},
},
"second-model": {
...config.provider.test.models["test-model"],
id: "second-model",
name: "Second Test Model",
variants: {
medium: {},
max: {},
},
},
},
},
},
}
}
@@ -1,103 +0,0 @@
import { describe, expect } from "bun:test"
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client"
import {
createAcpNextClient,
expectAlternateValue,
expectSelectOption,
initialize,
newSession,
verifierConfig,
} from "./helpers"
describe("opencode acp-next config option subprocess", () => {
cliIt.live(
'model option is listed with category "model"',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const model = expectSelectOption((yield* newSession(acp, home)).configOptions, "model")
expect(model.category).toBe("model")
expect(model.currentValue).toBe("test/test-model")
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
}),
60_000,
)
cliIt.live(
"model switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const model = expectSelectOption(session.configOptions, "model")
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
expect(nextModel).toBe("test/second-model")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: nextModel,
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}),
60_000,
)
cliIt.live(
'effort option is listed with category "thought_level" when selected model supports variants',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const effort = expectSelectOption((yield* newSession(acp, home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}),
60_000,
)
cliIt.live(
"effort switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const nextEffort = expectAlternateValue(expectSelectOption(session.configOptions, "effort"))
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}),
60_000,
)
})
@@ -1,103 +0,0 @@
import { expect } from "bun:test"
import type { InitializeResponse, NewSessionResponse, SessionConfigOption } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import type { CliFixture } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import {
createAcpClient,
expectOk,
flattenSelectOptions,
selectConfigOption,
type AcpClient,
} from "../acp/acp-test-client"
export function createAcpNextClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
return Effect.gen(function* () {
return createAcpClient(
yield* input.opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
...env,
},
}),
)
})
}
export function initialize(acp: AcpClient) {
return Effect.gen(function* () {
return expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
clientInfo: { name: "opencode-local-acp-next", version: "0.1.0" },
}),
)
})
}
export function newSession(acp: AcpClient, cwd: string) {
return Effect.gen(function* () {
return expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd, mcpServers: [] }))
})
}
export function verifierConfig(llmUrl: string, skills?: string) {
const config = testProviderConfig(llmUrl)
return {
...config,
model: "test/test-model",
...(skills ? { skills: { paths: [skills] } } : {}),
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: {
low: {},
high: {},
},
},
"second-model": {
...config.provider.test.models["test-model"],
id: "second-model",
name: "Second Test Model",
variants: {
medium: {},
max: {},
},
},
},
},
},
}
}
export function expectErrorCode(error: unknown, code: number) {
if (!error || typeof error !== "object" || !("code" in error)) {
expect(error).toEqual({ code })
return
}
expect(error.code).toBe(code)
}
export function expectSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
const option = selectConfigOption(options, id)
expect(option).toBeDefined()
return option!
}
export function expectAlternateValue(option: ReturnType<typeof expectSelectOption>) {
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
expect(value).toBeDefined()
return value!
}
export const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`
@@ -1,61 +0,0 @@
import { describe, expect } from "bun:test"
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { createAcpNextClient, expectErrorCode, initialize } from "./helpers"
describe("opencode acp-next initialize/auth subprocess", () => {
cliIt.live(
"initialize responds with capabilities",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
}),
60_000,
)
cliIt.live(
"auth negotiation is explicit and safe",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode })
const initialized = yield* initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
expect(yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" })).toMatchObject({
result: {},
})
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expectErrorCode(rejected.error, -32602)
expect(JSON.stringify(rejected.error)).not.toContain(process.env.OPENCODE_AUTH_CONTENT ?? "not-present")
}),
60_000,
)
cliIt.live(
"initialize without terminal-auth metadata keeps auth command implicit",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode })
const initialized = yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.result?.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
}),
60_000,
)
})
@@ -1,118 +0,0 @@
import { describe, expect } from "bun:test"
import type {
CloseSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
} from "@agentclientprotocol/sdk"
import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, selectConfigOption } from "../acp/acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
describe("opencode acp-next lifecycle subprocess", () => {
cliIt.live(
"stdin EOF exits cleanly",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
acp.close()
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5)))
expect(code).toBe(0)
}),
60_000,
)
cliIt.live(
"close capability and close request",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
const initialized = yield* initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = yield* newSession(acp, home)
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
}),
60_000,
)
cliIt.live(
"loadSession capability and load request return session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
const initialized = yield* initialize(acp)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
const session = yield* newSession(acp, home)
const loaded = expectOk(
yield* acp.request<LoadSessionResponse>("session/load", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
cliIt.live(
"list request includes a live ACP-created session",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const listed = expectOk(yield* acp.request<ListSessionsResponse>("session/list", { cwd: home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}),
60_000,
)
cliIt.live(
"resume capability advertisement",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
cliIt.live(
"resume request returns session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
})
@@ -1,97 +0,0 @@
import { describe, expect } from "bun:test"
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { cliIt } from "../../lib/cli-process"
import { expectOk } from "../acp/acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("opencode acp-next prompt content subprocess", () => {
cliIt.live(
"accepts embedded text resource image and file resource link prompt content",
({ home, llm, opencode }) =>
Effect.gen(function* () {
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
yield* llm.text("embedded resource accepted")
expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this embedded resource." },
{
type: "resource",
resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
},
],
}),
)
yield* llm.text("image accepted")
expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this image." },
{
type: "image",
mimeType: "image/png",
data: tinyPng,
},
],
}),
)
yield* llm.text("file link accepted")
const linked = expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this linked file." },
{
type: "resource_link",
uri: pathToFileURL(path.join(home, "README.md")).href,
name: "README.md",
mimeType: "text/markdown",
},
],
}),
)
expect(linked.stopReason).toBe("end_turn")
}),
60_000,
)
})
function promptContentConfig(llmUrl: string) {
const config = verifierConfig(llmUrl)
return {
...config,
provider: {
test: {
...config.provider.test,
models: Object.fromEntries(
Object.entries(config.provider.test.models).map(([id, model]) => [
id,
{
...model,
attachment: true,
reasoning: true,
},
]),
),
},
},
}
}
@@ -1,38 +0,0 @@
import { describe, expect } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { cliIt } from "../../lib/cli-process"
import { createAcpNextClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
describe("opencode acp-next skills subprocess", () => {
cliIt.live(
"skill slash command appears through available_commands_update",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const skills = path.join(home, "skills")
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const update = yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some(
(command) => command.name === "verifier-skill" && command.description.length > 0,
),
)
expect(update.params?.sessionId).toBe(session.sessionId)
}),
60_000,
)
})
@@ -40,7 +40,7 @@ describe("opencode acp verifier compatibility baseline", () => {
)
cliIt.live(
"first session returns model options",
"first session timing diagnostic stays bounded and returns model options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
@@ -50,6 +50,7 @@ describe("opencode acp verifier compatibility baseline", () => {
},
}),
)
const started = Date.now()
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: {},
@@ -61,6 +62,9 @@ describe("opencode acp verifier compatibility baseline", () => {
mcpServers: [],
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
const model = selectConfigOption(session.configOptions, "model")
expect(model?.category).toBe("model")
expect(model?.currentValue).toBe("test/test-model")
@@ -70,7 +74,7 @@ describe("opencode acp verifier compatibility baseline", () => {
)
cliIt.live(
"newSession can be called repeatedly",
"warm newSession timing diagnostic stays bounded",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
@@ -83,19 +87,22 @@ describe("opencode acp verifier compatibility baseline", () => {
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] })
const started = Date.now()
const session = expectOk(
yield* acp.request<NewSessionResponse>("session/new", {
cwd: home,
mcpServers: [],
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
expect(session.sessionId).toBeTruthy()
}),
60_000,
)
cliIt.live(
"model switch updates currentValue",
"model switch timing diagnostic updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
@@ -114,6 +121,7 @@ describe("opencode acp verifier compatibility baseline", () => {
: undefined
expect(nextModel).toBe("test/second-model")
const started = Date.now()
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
@@ -121,7 +129,9 @@ describe("opencode acp verifier compatibility baseline", () => {
value: nextModel,
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}),
60_000,
@@ -179,7 +189,7 @@ describe("opencode acp verifier compatibility baseline", () => {
)
cliIt.live(
"skill slash command appears through available_commands_update",
"skill slash command timing diagnostic appears through available_commands_update",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const skills = path.join(home, "skills")
@@ -204,6 +214,20 @@ describe("opencode acp verifier compatibility baseline", () => {
)
expect(update.params?.sessionId).toBe(session.sessionId)
const secondSession = expectOk(
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }),
)
const started = Date.now()
yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === secondSession.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some((command) => command.name === "verifier-skill"),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
}),
60_000,
)
+1 -83
View File
@@ -4,12 +4,7 @@ import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import {
getOpencodeModeStack,
OPENCODE_BASE_MODE,
OpencodeKeymapProvider,
registerOpencodeKeymap,
} from "@/cli/cmd/tui/keymap"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
test("legacy page key aliases compile as page keys", async () => {
const sequences: Record<string, string[][]> = {}
@@ -57,80 +52,3 @@ test("legacy page key aliases compile as page keys", async () => {
app.renderer.destroy()
}
})
test("mode-less bindings stay active when opencode mode changes", async () => {
const counts: Record<string, Record<string, number>> = {}
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createTuiResolvedConfig()
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offGlobal = keymap.registerLayer({
commands: [
{ name: "session.list", run() {} },
{ name: "session.new", run() {} },
{ name: "session.page.up", run() {} },
{ name: "session.first", run() {} },
],
bindings: config.keybinds.gather("test.global", [
"session.list",
"session.new",
"session.page.up",
"session.first",
]),
})
const offBase = keymap.registerLayer({
mode: OPENCODE_BASE_MODE,
commands: [{ name: "model.list", run() {} }],
bindings: config.keybinds.gather("test.base", ["model.list"]),
})
const activeCounts = () =>
Object.fromEntries(
Array.from(
keymap.getCommandBindings({
visibility: "active",
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
}),
([command, bindings]) => [command, bindings.length],
),
)
counts.base = activeCounts()
const popQuestion = getOpencodeModeStack(keymap).push("question")
counts.question = activeCounts()
popQuestion()
const popAutocomplete = getOpencodeModeStack(keymap).push("autocomplete")
counts.autocomplete = activeCounts()
popAutocomplete()
onCleanup(() => {
offBase()
offGlobal()
offKeymap()
})
return (
<OpencodeKeymapProvider keymap={keymap}>
<box />
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />)
try {
expect(counts).toEqual({
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
autocomplete: {
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"model.list": 0,
},
})
} finally {
app.renderer.destroy()
}
})
@@ -63,7 +63,6 @@ describe("RuntimeFlags", () => {
expect(flags.experimentalWorkspaces).toBe(true)
expect(flags.experimentalIconDiscovery).toBe(true)
expect(flags.experimentalNativeLlm).toBe(false)
expect(flags.experimentalWebSockets).toBe(false)
expect(flags.client).toBe("desktop")
}),
)
@@ -92,16 +91,6 @@ describe("RuntimeFlags", () => {
}),
)
it.effect("enables WebSockets via dedicated flag only", () =>
Effect.gen(function* () {
const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_WEBSOCKETS: "true" })))
const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
expect(explicit.experimentalWebSockets).toBe(true)
expect(umbrella.experimentalWebSockets).toBe(false)
}),
)
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
+1 -19
View File
@@ -5,7 +5,7 @@ import {
extractAccountIdFromClaims,
extractAccountId,
type IdTokenClaims,
} from "../../src/plugin/openai/codex"
} from "../../src/plugin/codex"
function createTestJwt(payload: object): string {
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
@@ -122,24 +122,6 @@ describe("plugin.codex", () => {
})
})
test("installs websocket transport only when experimental websockets are enabled", async () => {
const disabled = await CodexAuthPlugin({} as never)
const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
const disabledOptions = await disabled.auth!.loader!(
async () => ({ type: "api", key: "sk-test" }) as never,
{} as never,
)
const enabledOptions = await enabled.auth!.loader!(
async () => ({ type: "api", key: "sk-test" }) as never,
{} as never,
)
expect(disabledOptions.fetch).toBeUndefined()
expect(enabledOptions.fetch).toBeFunction()
await enabled.dispose?.()
})
test("deduplicates concurrent Codex token refreshes", async () => {
let auth = {
type: "oauth" as const,
@@ -1,17 +0,0 @@
import { describe, expect, test } from "bun:test"
import { experimentalWebSocketsEnabled } from "../../src/plugin"
describe("plugin.openai.websocket rollout", () => {
test("enables websockets by default only on pre-release channels", () => {
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "local" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "dev" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "beta" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "latest" })).toBe(false)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "prod" })).toBe(false)
})
test("allows releases to opt in through the experimental flag", () => {
expect(experimentalWebSocketsEnabled({ enabled: true, channel: "latest" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: true, channel: "prod" })).toBe(true)
})
})
@@ -1,700 +0,0 @@
import { describe, expect, test } from "bun:test"
import { EventEmitter } from "node:events"
import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http"
import net, { type AddressInfo, type Socket } from "node:net"
import WebSocket, { WebSocketServer } from "ws"
import { ProviderError } from "../../src/provider/error"
import { OpenAIWebSocket } from "../../src/plugin/openai/ws"
import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool"
describe("plugin.openai.ws", () => {
test("derives websocket URLs and sends auth plus protocol headers", async () => {
let headers: IncomingMessage["headers"] | undefined
await using server = await createWebSocketServer((_socket, request) => {
headers = request.headers
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: { authorization: "Bearer test", "content-length": "123" },
})
expect(OpenAIWebSocket.toWebSocketUrl("http://example.com/v1/responses")).toBe("ws://example.com/v1/responses")
expect(OpenAIWebSocket.toWebSocketUrl("https://example.com/v1/responses")).toBe("wss://example.com/v1/responses")
expect(headers?.authorization).toBe("Bearer test")
expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER)
expect(headers?.["content-length"]).toBeUndefined()
socket.terminate()
})
test("enforces websocket connect timeout", async () => {
await using server = await createHangingTcpServer()
await expect(
OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: {},
timeout: 20,
}),
).rejects.toThrow("WebSocket connect timed out")
})
test("enforces websocket send idle timeout", async () => {
const socket = new (class extends EventEmitter {
send(_data: string, _callback: (error?: Error) => void) {}
})() as unknown as WebSocket
const invalid: string[] = []
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
idleTimeout: 20,
onConnectionInvalid: (error) => invalid.push(error.message),
})
expect((await readTextError(response.text())).message).toContain("idle timeout sending websocket request")
expect(invalid).toEqual(["idle timeout sending websocket request"])
})
test("streams websocket events as SSE and handles response.done", async () => {
let requestBody: unknown
await using server = await createWebSocketServer((socket) => {
socket.once("message", (data) => {
requestBody = JSON.parse(data.toString())
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "hello" }))
socket.send(JSON.stringify({ type: "response.done", response: { id: "resp_123" } }))
socket.close(1000, "done")
})
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: { authorization: "Bearer test", "content-length": "123" },
})
const completed: Record<string, unknown>[] = []
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, background: true, input: "hi" },
onComplete: (event) => completed.push(event),
})
expect(await response.text()).toBe(
'data: {"type":"response.output_text.delta","delta":"hello"}\n\ndata: {"type":"response.done","response":{"id":"resp_123"}}\n\ndata: [DONE]\n\n',
)
expect(requestBody).toEqual({ type: "response.create", input: "hi" })
expect(completed).toHaveLength(1)
expect(completed[0]?.type).toBe("response.done")
})
test("errors the SSE stream when the server closes before a terminal event", async () => {
const invalid: Error[] = []
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.close(1009, "payload too large")
})
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
onConnectionInvalid: (error) => invalid.push(error),
})
expect((await readTextError(response.text())).message).toContain(
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
)
expect(invalid[0]).toBeInstanceOf(ProviderError.ResponseStreamError)
expect(invalid.map((error) => error.message)).toEqual([
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
])
})
test("rejects unexpected binary websocket frames", async () => {
const invalid: string[] = []
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(Buffer.from("not json text"))
})
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
onConnectionInvalid: (error) => invalid.push(error.message),
})
expect((await readTextError(response.text())).message).toContain("Unexpected binary WebSocket frame")
expect(invalid).toEqual(["Unexpected binary WebSocket frame"])
})
})
describe("plugin.openai.ws-pool", () => {
test("reuses one healthy websocket for sequential requests", async () => {
let connections = 0
let messages = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
messages += 1
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${messages}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain("data: [DONE]")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(1)
expect(messages).toBe(2)
fetch.close()
})
test("rotates a socket that exceeds max connection age", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${connections}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
maxConnectionAge: 0,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain("data: [DONE]")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
fetch.close()
})
test("falls back to HTTP after websocket setup retries are exhausted", async () => {
const attempts: string[] = []
await using server = await createRejectingWebSocketServer(() => attempts.push("websocket"))
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 100,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
expect(await readTextError(first.text())).toBeInstanceOf(ProviderError.ResponseStreamError)
const second = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
const third = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(attempts).toEqual(["websocket", "websocket"])
expect(server.httpRequests).toHaveLength(2)
expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
fetch.close()
})
test("prunes HTTP fallback after its idle timeout", async () => {
let websocketAttempts = 0
await using server = await createRejectingWebSocketServer(() => websocketAttempts++)
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 100,
idleTimeout: 20,
streamRetries: 0,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toBe("http")
await new Promise((resolve) => setTimeout(resolve, 50))
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(websocketAttempts).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("invalidates but does not reuse a socket after terminal failure frames", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.send(JSON.stringify({ type: connections === 1 ? "response.failed" : "response.completed" }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain('data: {"type":"response.failed"}')
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain('data: {"type":"response.completed"}')
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("retries websocket connection limit errors on the next stream attempt", async () => {
let connections = 0
let messages = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
messages += 1
if (connections === 1) {
socket.send(
JSON.stringify({
type: "error",
status: 400,
error: {
type: "invalid_request_error",
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_retry" } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
const second = await fetch(server.url, streamRequest())
const text = await second.text()
expect(text).not.toContain("websocket_connection_limit_reached")
expect(text).toContain('data: {"type":"response.completed","response":{"id":"resp_retry"}}')
expect(text).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(messages).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("falls back to HTTP after websocket connection limit retries are exhausted", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.send(
JSON.stringify({
type: "error",
status: 400,
error: {
type: "invalid_request_error",
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 2,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
const second = await fetch(server.url, streamRequest())
expect((await readTextError(second.text())).message).toContain("Responses websocket connection limit reached")
const third = await fetch(server.url, streamRequest())
const fourth = await fetch(server.url, streamRequest())
expect(await third.text()).toBe("http")
expect(await fourth.text()).toBe("http")
expect(connections).toBe(3)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("shares the websocket retry budget across stream and connection limit failures", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
socket.terminate()
return
}
socket.send(
JSON.stringify({
type: "error",
error: {
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(1)
fetch.close()
})
test("retries websocket idle failures before first event then falls back to HTTP", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 20,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
const second = await fetch(server.url, streamRequest())
const third = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("retries failed websocket streams before using HTTP fallback", async () => {
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 20,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
const second = await fetch(server.url, streamRequest())
expect((await readTextError(second.text())).message).toContain("idle timeout waiting for websocket")
const third = await fetch(server.url, streamRequest())
expect(await third.text()).toBe("http")
expect(server.httpRequests).toHaveLength(1)
fetch.close()
})
test("resets websocket stream failures after a completed response", async () => {
let connections = 0
let requests = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
requests += 1
if (requests === 1 || requests === 3) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
socket.terminate()
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${requests}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
const third = await fetch(server.url, streamRequest())
expect((await readTextError(third.text())).message).toContain("WebSocket closed before response.completed")
const fourth = await fetch(server.url, streamRequest())
expect(await fourth.text()).toContain("data: [DONE]")
expect(connections).toBe(3)
expect(requests).toBe(4)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("falls back to HTTP for missing session and title requests", async () => {
await using server = await createWebSocketServer(() => {})
const fetch = OpenAIWebSocketPool.createWebSocketFetch()
const missingSession = await fetch(server.url, {
method: "POST",
headers: { [TITLE_HEADER]: "false" },
body: JSON.stringify({ stream: true }),
})
const title = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "true" }))
expect(await missingSession.text()).toBe("http")
expect(await title.text()).toBe("http")
expect(server.httpRequests).toHaveLength(2)
expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
fetch.close()
})
test("falls back to HTTP while a websocket lane is busy", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
})
})
const abort = new AbortController()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest({}, abort.signal))
const firstText = first.text()
await waitFor(() => connections === 1, "websocket did not connect")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(server.httpRequests).toHaveLength(1)
expect(connections).toBe(1)
abort.abort(new Error("stop"))
expect((await readTextError(firstText)).message).toContain("stop")
fetch.close()
})
test("reserves a websocket lane while its socket is connecting", async () => {
await using server = await createHangingTcpServer()
await using fallback = await createHttpServer()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 20,
streamRetries: 0,
})
const first = fetch(fallback.url, streamRequest())
await waitFor(() => server.connections() === 1, "first websocket did not begin connecting")
const second = fetch(fallback.url, streamRequest())
expect(await (await second).text()).toBe("http")
expect(await (await first).text()).toBe("http")
expect(server.connections()).toBe(1)
expect(fallback.httpRequests).toHaveLength(2)
fetch.close()
})
test("retries unexpected closes before first event then falls back to HTTP", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.close(1001, "server shutdown")
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
const third = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("does not keep HTTP fallback active after aborting a websocket response", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_456" } }))
})
})
const abort = new AbortController()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest({}, abort.signal))
const firstText = first.text()
await waitFor(() => connections === 1, "first websocket did not connect")
abort.abort(new Error("stop"))
expect((await readTextError(firstText)).message).toContain("stop")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("releases the websocket lane when the response body is cancelled", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_cancel" } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
await waitFor(() => connections === 1, "first websocket did not connect")
await first.body!.cancel("stop")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
})
function streamRequest(headers?: Record<string, string>, signal?: AbortSignal): RequestInit {
return {
method: "POST",
headers: {
"session-id": "session-1",
authorization: "Bearer test",
...headers,
},
body: JSON.stringify({ stream: true, input: "hi" }),
signal,
}
}
async function readTextError(promise: Promise<string>) {
// Bun 1.3.14 hangs on expect(response.text()).rejects for streams errored from ws callbacks.
return promise.then(
() => {
throw new Error("Expected response text to reject")
},
(error) => {
expect(error).toBeInstanceOf(Error)
return error as Error
},
)
}
async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) {
const http = await createHttpServer()
const server = new WebSocketServer({ server: http.server })
server.on("connection", onConnection)
return websocketServerHandle(server, http)
}
async function createHangingTcpServer() {
const sockets = new Set<Socket>()
let connections = 0
const server = net.createServer((socket) => {
connections += 1
sockets.add(socket)
socket.on("close", () => sockets.delete(socket))
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address() as AddressInfo
return {
url: `http://127.0.0.1:${address.port}/v1/responses`,
wsUrl: `ws://127.0.0.1:${address.port}/v1/responses`,
connections: () => connections,
async [Symbol.asyncDispose]() {
for (const socket of sockets) socket.destroy()
server.close()
},
}
}
async function createRejectingWebSocketServer(onAttempt: () => void) {
const http = await createHttpServer()
const server = new WebSocketServer({
server: http.server,
verifyClient(_info, callback) {
onAttempt()
callback(false, 401, "denied")
},
})
return websocketServerHandle(server, http)
}
async function createHttpServer() {
const httpRequests: IncomingMessage[] = []
const server = createServer((request, response) => {
httpRequests.push(request)
response.writeHead(200, { "content-type": "text/plain" })
response.end("http")
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address() as AddressInfo
return {
server,
httpRequests,
url: `http://127.0.0.1:${address.port}/v1/responses`,
async [Symbol.asyncDispose]() {
await closeHttpServer(server)
},
}
}
function websocketServerHandle(server: WebSocketServer, http: Awaited<ReturnType<typeof createHttpServer>>) {
return {
url: http.url,
wsUrl: http.url.replace(/^http/, "ws"),
httpRequests: http.httpRequests,
async [Symbol.asyncDispose]() {
for (const socket of server.clients) socket.terminate()
server.close()
http.server.close()
},
}
}
function closeHttpServer(server: HttpServer) {
return new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
async function waitFor(predicate: () => boolean, message: string) {
const started = Date.now()
while (!predicate()) {
if (Date.now() - started > 1_000) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, 1))
}
}
@@ -214,22 +214,6 @@ describe("Worktree", () => {
{ git: true },
)
it.instance(
"lists the active linked worktree but not the project checkout",
() =>
withCreatedWorktree(undefined, ({ info }) =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const list = yield* svc.list().pipe(provideInstance(info.directory))
expect(list.map((item) => item.name)).toContain(info.name)
expect(list.map((item) => item.name)).not.toContain(path.basename(test.directory).toLowerCase())
}),
),
{ git: true },
)
it.instance(
"create with custom name",
() =>
@@ -3223,12 +3223,6 @@ describe("ProviderTransform.variants", () => {
efforts: ["low", "medium", "high", "xhigh", "max"],
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
{
name: "opus 4.8",
apiIds: ["claude-opus-4-8", "claude-opus-4.8"],
efforts: ["low", "medium", "high", "xhigh", "max"],
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
]) {
for (const apiId of testCase.apiIds) {
test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => {
@@ -3347,28 +3341,6 @@ describe("ProviderTransform.variants", () => {
})
})
test("anthropic opus 4.8 returns adaptive reasoning options with xhigh", () => {
const result = ProviderTransform.variants(
createMockModel({
id: "bedrock/anthropic-claude-opus-4.8",
providerID: "bedrock",
api: {
id: "anthropic.claude-opus-4.8",
url: "https://bedrock.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
}),
)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.high).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "high",
display: "summarized",
},
})
})
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
const model = createMockModel({
id: "bedrock/llama-4",
+2 -2
View File
@@ -4,8 +4,8 @@ Use these patterns for server and HttpApi middleware tests in this directory.
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
- Use tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`.
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
@@ -1,8 +1,7 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import path from "node:path"
@@ -13,17 +12,9 @@ import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import {
InstanceContextMiddleware,
instanceContextLayer,
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { withFixedWorkspaceID } from "../fixture/flag"
@@ -56,10 +47,9 @@ const it = testEffect(
),
)
const instanceContextTestLayer = Layer.mergeAll(
instanceContextLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const localAdapter = (directory: string): WorkspaceAdapter => ({
name: "Local Test",
@@ -90,57 +80,20 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
const probeInstanceContext = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return {
return yield* HttpServerResponse.json({
directory: instance?.directory,
worktree: instance?.worktree,
projectID: instance?.project.id,
workspaceID,
}
})
})
const ProbeResult = Schema.Struct({
directory: Schema.optional(Schema.String),
worktree: Schema.optional(Schema.String),
projectID: Schema.optional(Schema.String),
workspaceID: Schema.optional(Schema.String),
})
const ProbeApi = HttpApi.make("instance-context-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.post("dispose", "/dispose-probe", {
query: WorkspaceRoutingQuery,
success: Schema.Boolean,
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware),
)
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("get", () => probeInstanceContext)
.handle("session", () => probeInstanceContext)
.handle(
"dispose",
Effect.fn("InstanceContextProbe.dispose")(function* () {
const instance = yield* InstanceRef
if (!instance) return false
yield* markInstanceForDisposal(instance)
return true
}),
),
)
const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(instanceContextTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
)
const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build)
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
Layer.provide(instanceContextTestLayer),
HttpRouter.serve,
Layer.build,
)
const waitDisposedEvent = waitGlobalBusEvent({
message: "timed out waiting for instance disposal",
@@ -148,9 +101,19 @@ const waitDisposedEvent = waitGlobalBusEvent({
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
const serveDisposeProbe = () =>
HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe(
Layer.build,
)
HttpRouter.serve(
HttpRouter.add(
"POST",
"/dispose-probe",
Effect.gen(function* () {
const instance = yield* InstanceRef
if (!instance) return HttpServerResponse.empty({ status: 500 })
yield* markInstanceForDisposal(instance)
return yield* HttpServerResponse.json(true)
}),
).pipe(Layer.provide(instanceContextTestLayer)),
{ middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
).pipe(Layer.build)
describe("HttpApi instance context middleware", () => {
it.live("provides instance context from the routed directory", () =>
@@ -166,7 +129,6 @@ describe("HttpApi instance context middleware", () => {
directory: dir,
worktree: dir,
projectID: project.project.id,
workspaceID: null,
})
}),
)
@@ -194,7 +156,7 @@ describe("HttpApi instance context middleware", () => {
type: "instance-context-workspace-ref",
directory: workspaceDir,
})
yield* serveProbe()
yield* serveProbe("/session")
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
@@ -307,7 +269,7 @@ describe("HttpApi instance context middleware", () => {
// is true. Combined with the env override, the route must stay Local with
// the configured workspace id (not divert to the requested workspace's
// local directory).
yield* serveProbe()
yield* serveProbe("/session")
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
@@ -9,11 +9,10 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Schema, Scope } from "effect"
import { Deferred, Effect, Layer, Scope } from "effect"
import * as Stream from "effect/Stream"
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { mkdir } from "node:fs/promises"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
@@ -21,16 +20,8 @@ import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import {
InstanceContextMiddleware,
instanceContextLayer,
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
@@ -61,10 +52,9 @@ const it = testEffect(
),
)
const instanceContextTestLayer = Layer.mergeAll(
instanceContextLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const localAdapter = (directory: string): WorkspaceAdapter => ({
name: "Local Test",
@@ -97,46 +87,6 @@ const captureInstance = Effect.gen(function* () {
return { directory: instance?.directory, workspaceID } satisfies Capture
})
const ProbeApi = HttpApi.make("handler-context-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
HttpApiEndpoint.post("streamWithout", "/stream-probe-without", {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
}),
HttpApiEndpoint.post("streamWith", "/stream-probe-with", {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware),
)
const serveProbes = (input: {
fork?: Effect.Effect<boolean, never, Scope.Scope>
streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
}) =>
HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(
HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("fork", () => input.fork ?? Effect.succeed(false))
.handleRaw(
"streamWithout",
() => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })),
)
.handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
),
),
Layer.provide(instanceContextTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
describe("HttpApi handler context inheritance", () => {
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
// the request's Context — including InstanceRef and WorkspaceRef provided
@@ -146,20 +96,22 @@ describe("HttpApi handler context inheritance", () => {
const { dir, workspace } = yield* setupWorkspace("local-fork")
const capture = yield* Deferred.make<Capture>()
yield* serveProbes({
fork: Effect.gen(function* () {
yield* HttpRouter.add(
"POST",
"/fork-probe",
Effect.gen(function* () {
const scope = yield* Scope.Scope
yield* Effect.gen(function* () {
yield* Deferred.succeed(capture, yield* captureInstance)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return true
return HttpServerResponse.empty({ status: 204 })
}),
})
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClient.post(
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
)
expect(response.status).toBe(200)
expect(response.status).toBe(204)
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
expect(observed.directory).toBe(dir)
@@ -177,8 +129,10 @@ describe("HttpApi handler context inheritance", () => {
const withoutCapture = yield* Deferred.make<Capture>()
const withCapture = yield* Deferred.make<Capture>()
yield* serveProbes({
streamWithout: Effect.gen(function* () {
yield* HttpRouter.add(
"POST",
"/stream-probe-without",
Effect.gen(function* () {
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.gen(function* () {
@@ -189,7 +143,12 @@ describe("HttpApi handler context inheritance", () => {
{ contentType: "application/json" },
)
}),
streamWith: Effect.gen(function* () {
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
yield* HttpRouter.add(
"POST",
"/stream-probe-with",
Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return HttpServerResponse.stream(
@@ -202,7 +161,7 @@ describe("HttpApi handler context inheritance", () => {
{ contentType: "application/json" },
)
}),
})
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
@@ -147,14 +147,6 @@ describe("pty HttpApi bridge", () => {
expect(response.status).toBe(404)
})
test("returns 404 for missing PTY websocket before decoding cursor query", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(404)
})
test("returns typed not found errors for missing PTY HTTP resources", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
@@ -9,7 +9,6 @@ type OpenApiResponse = {
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
}
type OpenApiOperation = {
readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }>
readonly responses?: Record<string, OpenApiResponse>
readonly security?: unknown
}
@@ -208,11 +207,6 @@ describe("PublicApi OpenAPI v2 errors", () => {
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
"PtyForbiddenError",
)
expect(
spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
?.filter((parameter) => parameter.in === "query")
.map((parameter) => parameter.name),
).toEqual(["directory", "workspace", "cursor", "ticket"])
})
test("documents project not-found errors", () => {
@@ -4,7 +4,6 @@ import { HttpRouter } from "effect/unstable/http"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { ServerAuth } from "../../src/server/auth"
import { PtyID } from "../../src/pty/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -36,7 +35,7 @@ function app(input: { password?: string; username?: string }) {
}
function basic(username: string, password: string) {
return ServerAuth.header({ username, password }) ?? ""
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
async function cancelBody(response: Response) {
@@ -48,8 +47,8 @@ afterEach(async () => {
await resetDatabase()
})
describe("HttpApi instance route authorization", () => {
test("requires configured auth before opening the instance event stream", async () => {
describe("HttpApi raw route authorization", () => {
test("requires configured auth before opening the raw instance event stream", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const headers = { "x-opencode-directory": tmp.path }
@@ -65,7 +64,7 @@ describe("HttpApi instance route authorization", () => {
expect(authed.status).toBe(200)
})
test("requires configured auth before resolving the PTY websocket route", async () => {
test("requires configured auth before resolving the raw PTY websocket route", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
@@ -2,7 +2,6 @@ import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Cause, Effect, Exit, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
@@ -28,9 +27,7 @@ import * as DateTime from "effect/DateTime"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { TestLLMServer } from "../lib/llm-server"
import { testProviderConfig } from "../lib/test-provider"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
@@ -371,45 +368,6 @@ describe("session HttpApi", () => {
{ git: true, config: { formatter: false, lsp: false } },
)
it.live("uses the persisted session directory for prompt requests", () =>
Effect.gen(function* () {
const llm = yield* TestLLMServer
yield* llm.text("ok", { usage: { input: 1, output: 1 } })
const config = testProviderConfig(llm.url)
const sessionDirectory = yield* tmpdirScoped({ git: true, config })
const requestDirectory = yield* tmpdirScoped({ git: true, config })
const session = yield* createSession({ title: "directory regression" }).pipe(
provideInstanceEffect(sessionDirectory),
)
const response = yield* request(
`${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
agent: "build",
model: { providerID: "test", modelID: "test-model" },
parts: [{ type: "text", text: "which directory?" }],
}),
},
)
expect(response.status).toBe(200)
yield* responseJson(response)
const messages = yield* Session.use
.messages({ sessionID: session.id })
.pipe(provideInstanceEffect(sessionDirectory), Effect.orDie)
const assistant = messages.find((message) => message.info.role === "assistant")
expect(assistant?.info.role === "assistant" ? assistant.info.path : undefined).toEqual({
cwd: sessionDirectory,
root: sessionDirectory,
})
}).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
)
it.instance(
"returns v2 public request errors for cursor and workspace query failures",
() =>

Some files were not shown because too many files have changed in this diff Show More