Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d15d89140 | |||
| 2eb0606998 | |||
| cc230feca1 | |||
| 3ce9b4be0d | |||
| fff7781fa8 | |||
| 0a72298062 | |||
| 7c320fd463 | |||
| 05e3c4ecee | |||
| 72d008bd5c | |||
| 202ec12056 | |||
| af30b315b2 | |||
| 425a4f584f | |||
| 7504daa602 | |||
| 9ac0f3e9ac | |||
| cf2cd13fb8 | |||
| 6883309997 | |||
| c3562a4a51 | |||
| 30a66dfc31 | |||
| fdc574ff81 | |||
| 1e5ddbd812 | |||
| 3109060216 | |||
| 9031ce7b51 | |||
| 2449b50585 | |||
| 66e6662440 | |||
| 56f3b9044d | |||
| 913659890d | |||
| 14e0b9b17f | |||
| e5524f5bf9 | |||
| 16234a0baf | |||
| e5cf3ad3c2 | |||
| 82da3320ba | |||
| aa553dea94 | |||
| 0c9cad86ec | |||
| 69910f361c | |||
| 7bafbb309a | |||
| ec26d78450 | |||
| 49e8d324dd | |||
| 60b9ba4db7 | |||
| a1a401dc9b | |||
| 2f1547ebaf | |||
| 62da1e7682 | |||
| 28a06e52fc | |||
| 5a5d981c4e | |||
| 62a7781e04 | |||
| 2d03cf4a9a | |||
| 4025d30b24 | |||
| dd3c443d8c | |||
| 4aaa9f9e30 | |||
| 31c805bc97 | |||
| 94f2ed1b84 | |||
| 86dc66eae9 | |||
| 9814dc6526 | |||
| f09c859974 | |||
| 1fcdb0246a | |||
| 340ad9e9ed | |||
| 76d814e747 | |||
| 9941e70d29 | |||
| 2afb812620 | |||
| ad2fc1ce4b | |||
| 5acc368ef4 | |||
| e2dc89c6f3 | |||
| e1581183ff | |||
| f195c952fc | |||
| 34b1045a01 | |||
| aa9956d46d | |||
| cec5a752bb | |||
| 0f0a4be253 | |||
| 0e88dd99e7 | |||
| f965db9e13 | |||
| 519d344470 | |||
| a78605f8ea |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"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": "0.0.0",
|
||||
"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.10",
|
||||
"version": "1.15.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -275,7 +275,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.10",
|
||||
"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.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -344,7 +344,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -403,7 +403,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.10",
|
||||
"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.10",
|
||||
"version": "1.15.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -478,6 +478,7 @@
|
||||
"@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",
|
||||
@@ -519,6 +520,7 @@
|
||||
"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:",
|
||||
@@ -559,7 +561,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -575,9 +577,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.15",
|
||||
"@opentui/keymap": ">=0.2.15",
|
||||
"@opentui/solid": ">=0.2.15",
|
||||
"@opentui/core": ">=0.2.16",
|
||||
"@opentui/keymap": ">=0.2.16",
|
||||
"@opentui/solid": ">=0.2.16",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -597,7 +599,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -612,7 +614,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -625,8 +627,9 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -648,7 +651,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -667,7 +670,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.14.50",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -707,7 +710,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -756,7 +759,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -824,9 +827,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1716,23 +1719,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
|
||||
|
||||
"@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/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
|
||||
|
||||
"@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/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/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=="],
|
||||
"@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=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -5138,7 +5141,7 @@
|
||||
|
||||
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
@@ -5496,6 +5499,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5958,6 +5963,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6074,6 +6081,8 @@
|
||||
|
||||
"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
@@ -209,8 +209,8 @@ const ingestConfig = new sst.Linkable("LakeIngestConfig", {
|
||||
const ingestService = new sst.aws.Service("LakeIngestService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "0.5 vCPU",
|
||||
memory: "1 GB",
|
||||
cpu: "1 vCPU",
|
||||
memory: "4 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
|
||||
+1
-3
@@ -5,10 +5,8 @@ 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"
|
||||
// Temporarily omit AWS infra so SST removes the lake/stats resources.
|
||||
export const deployAws = false
|
||||
export const deployAws = $app.stage === awsStage
|
||||
|
||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||
hostname: domain,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-FT8N4SBP7OmVu73OwNyPJvBoxFd2+IXzNnFubB8y6J0=",
|
||||
"aarch64-linux": "sha256-rGst01voY1/qr/fIMEgfL2zep/+W2RhjJh5pKs+/P3s=",
|
||||
"aarch64-darwin": "sha256-+oTdR3CuR88Ra3QQsDn7ixNwJf0YjOxySTSiRsPPUOg=",
|
||||
"x86_64-darwin": "sha256-3ZkB83KWtp6N7u9xhzmCouv27UuisXYvr2SBap4k++E="
|
||||
"x86_64-linux": "sha256-70om2TEJYJJOcf9lqex+Sxs27c1SVVgN7FNkLbTXBR0=",
|
||||
"aarch64-linux": "sha256-/xTsjAFl6TmydJFXETctBoRePIFAkMyxv0HLWHFymJg=",
|
||||
"aarch64-darwin": "sha256-/xbFzrnScm1x4xuz2rEJyyjockDMIIyqJtxm3Axghbg=",
|
||||
"x86_64-darwin": "sha256-ZbJDPhmcvBlY80CPmzygw4S8mjdR+d/UyL+3R3nhZtM="
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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 run --cwd packages/stats/app dev",
|
||||
"dev:stats": "bun sst shell --stage=production -- 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.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
@@ -40,7 +41,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 } from "@/context/settings"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
@@ -48,11 +49,6 @@ 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"))
|
||||
|
||||
@@ -97,9 +93,26 @@ 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>
|
||||
|
||||
@@ -79,8 +79,6 @@ 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"
|
||||
@@ -1456,7 +1454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={USE_V2_INPUT}>
|
||||
<Match when={settings.general.newLayoutDesigns()}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
@@ -1528,7 +1526,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-faint [font-family:Inter,var(--font-family-sans)]": 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,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
|
||||
@@ -45,8 +45,6 @@ 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"
|
||||
|
||||
@@ -157,11 +155,11 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
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 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 [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
|
||||
@@ -399,6 +399,18 @@ 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>
|
||||
)
|
||||
@@ -802,7 +814,7 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,6 @@ 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}`
|
||||
|
||||
@@ -75,6 +74,7 @@ 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 = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
const height = useV2Titlebar() ? 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(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
|
||||
const nav = createMemo(() => (useV2Titlebar() ? 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": USE_V2_TITLEBAR,
|
||||
"h-10 bg-background-base": !USE_V2_TITLEBAR,
|
||||
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
|
||||
"h-10 bg-background-base": !useV2Titlebar(),
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
@@ -239,7 +239,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
onDblClick={maximize}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={USE_V2_TITLEBAR}>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const serverSync = useServerSync()
|
||||
const navigate = useNavigate()
|
||||
@@ -490,7 +490,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
project={projectForSession(tab.info, projects(), projectByID())}
|
||||
directory={tab.dir}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
hideClose={tabsEnriched().length < 2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -737,14 +736,13 @@ 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)] 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)]"
|
||||
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)]"
|
||||
data-active={isActive()}
|
||||
>
|
||||
<a
|
||||
@@ -755,7 +753,7 @@ function TabNavItem(props: {
|
||||
<span class="text-clip leading-5">{props.title}</span>
|
||||
</a>
|
||||
|
||||
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<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 inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
style={{
|
||||
@@ -766,7 +764,7 @@ function TabNavItem(props: {
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
|
||||
onClick={props.onClose}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Settings {
|
||||
editToolPartsExpanded: boolean
|
||||
showSessionProgressBar: boolean
|
||||
showCustomAgents: boolean
|
||||
newLayoutDesigns?: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -54,6 +55,7 @@ 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'
|
||||
@@ -242,6 +244,10 @@ 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),
|
||||
|
||||
@@ -791,6 +791,8 @@ 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",
|
||||
|
||||
|
||||
+109
-104
@@ -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,8 +52,12 @@ type HomeSessionGroup = {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
if (USE_HOME_DESIGN) return <HomeDesign />
|
||||
return <LegacyHome />
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
|
||||
<HomeDesign />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeDesign() {
|
||||
@@ -68,13 +72,11 @@ 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) ?? projects()[0],
|
||||
)
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
|
||||
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return []
|
||||
if (!project) return [...projects().flatMap((project) => directories(project))]
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
@@ -89,8 +91,8 @@ function HomeDesign() {
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const records = createMemo(() =>
|
||||
[
|
||||
const records = createMemo(() => {
|
||||
return [
|
||||
...new Map(
|
||||
projectDirectories()
|
||||
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
|
||||
@@ -112,8 +114,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) {
|
||||
@@ -220,7 +222,7 @@ function HomeDesign() {
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<Show
|
||||
when={selectedProject()}
|
||||
when={projectDirectories().length > 0}
|
||||
fallback={
|
||||
<HomeEmptyState
|
||||
icon="folder-add-left"
|
||||
@@ -298,8 +300,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]" aria-label={props.language.t("home.projects")}>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
||||
<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">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
@@ -311,8 +313,8 @@ function HomeProjectColumn(props: {
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</div>
|
||||
<For
|
||||
each={servers.list()}
|
||||
<Show
|
||||
when={servers.list().length > 1}
|
||||
fallback={
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
@@ -328,61 +330,63 @@ function HomeProjectColumn(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(server) => {
|
||||
const key = ServerConnection.key(server)
|
||||
const healthy = () => !!servers.health[key]?.healthy
|
||||
const [open, setOpen] = createSignal(true)
|
||||
<For each={servers.list()}>
|
||||
{(server) => {
|
||||
const key = ServerConnection.key(server)
|
||||
const healthy = () => !!servers.health[key]?.healthy
|
||||
const [open, setOpen] = createSignal(true)
|
||||
|
||||
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" />}
|
||||
/>
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div class="mt-4 flex min-w-0 flex-col gap-1">
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<div class="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`}
|
||||
@@ -416,12 +420,14 @@ 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] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
|
||||
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 peer`}
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
@@ -430,7 +436,37 @@ 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 group-focus-within/project:opacity-100">
|
||||
<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>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
@@ -442,37 +478,6 @@ 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>
|
||||
)
|
||||
@@ -561,7 +566,7 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
|
||||
class="h-7 px-2 text-v2-text-text-muted"
|
||||
onClick={onNewSession()}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
|
||||
+160
-160
@@ -89,8 +89,6 @@ 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"]),
|
||||
@@ -129,6 +127,7 @@ 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(() => {
|
||||
@@ -154,7 +153,7 @@ export default function Layout(props: ParentProps) {
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory && !USE_NEW_DESIGN,
|
||||
autoselect: !initialDirectory && !newDesign(),
|
||||
busyWorkspaces: {} as Record<string, boolean>,
|
||||
hoverProject: undefined as string | undefined,
|
||||
scrollSessionKey: undefined as string | undefined,
|
||||
@@ -1142,7 +1141,7 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
]
|
||||
|
||||
if (!USE_NEW_DESIGN)
|
||||
if (!newDesign())
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
@@ -1821,7 +1820,7 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--dialog-left-margin",
|
||||
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2363,179 +2362,180 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
)
|
||||
|
||||
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">
|
||||
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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}}
|
||||
/>
|
||||
<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-mobile"
|
||||
data-component="sidebar-nav-desktop"
|
||||
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(),
|
||||
"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()
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</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
|
||||
<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={{
|
||||
"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,
|
||||
"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",
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
</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
|
||||
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>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ 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
|
||||
@@ -198,6 +197,7 @@ 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({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
|
||||
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), 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={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
|
||||
<Show when={params.id || !newSessionDesign()}>{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 prod session pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
|
||||
test("keeps disabled pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).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)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
|
||||
return input.channel !== "prod" && !input.sessionID
|
||||
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
|
||||
return input.newLayoutDesigns && !input.sessionID
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ 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 {
|
||||
@@ -60,7 +58,7 @@ export function SessionSidePanel(props: {
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
|
||||
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
|
||||
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
@@ -205,7 +203,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
|
||||
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
|
||||
<aside
|
||||
id="review-panel"
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
|
||||
@@ -20,8 +20,6 @@ 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
|
||||
@@ -72,7 +70,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
})
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const closableTab = tabState.closableTab
|
||||
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
|
||||
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
|
||||
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
|
||||
|
||||
const messages = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -9,6 +10,7 @@ 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>>
|
||||
@@ -25,7 +27,21 @@ const emptyUsagePreview = {
|
||||
|
||||
export const queryGoReferral = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Referral.summary(), workspaceID)
|
||||
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)
|
||||
}, "go.referral.get")
|
||||
|
||||
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
|
||||
@@ -65,6 +81,8 @@ 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,11 +1,9 @@
|
||||
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)
|
||||
@@ -19,7 +17,6 @@ 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) => {
|
||||
@@ -35,15 +32,8 @@ 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", "")
|
||||
const response = redirect(route(locale, next))
|
||||
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
|
||||
return response
|
||||
return redirect(route(locale, next))
|
||||
} catch (e: any) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -64,11 +64,12 @@ 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: 1290, d: "150ms" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, 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,7 +4,6 @@ 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"
|
||||
@@ -124,7 +123,6 @@ 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>
|
||||
@@ -156,9 +154,6 @@ 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,7 +506,6 @@ 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) => {
|
||||
@@ -990,10 +989,6 @@ 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,5 +1,3 @@
|
||||
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"
|
||||
@@ -22,7 +20,6 @@ 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()
|
||||
@@ -32,33 +29,12 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
@@ -68,18 +44,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
pipeline.incr(dailyKey)
|
||||
pipeline.expire(dailyKey, retryAfter)
|
||||
if (isNew) pipeline.incr(lifetimeKey)
|
||||
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` } }),
|
||||
),
|
||||
])
|
||||
await pipeline.exec()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
|
||||
@@ -19,26 +18,20 @@ 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 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
|
||||
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
|
||||
|
||||
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
|
||||
},
|
||||
track: async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(KeyRateLimitTable)
|
||||
.values({ key: zenApiKey, interval, count: 1 })
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }),
|
||||
)
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.incr(key)
|
||||
pipeline.expire(key, 60)
|
||||
await pipeline.exec()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
||||
],
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
port: 3001,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod"
|
||||
import { and, asc, eq, isNull, sql, Database } from "./drizzle"
|
||||
import { and, asc, eq, inArray, isNull, sql, Database } from "./drizzle"
|
||||
import { Actor } from "./actor"
|
||||
import { Identifier } from "./identifier"
|
||||
import { LiteTable } from "./schema/billing.sql"
|
||||
import { LiteTable, PaymentTable } 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,6 +318,26 @@ 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,
|
||||
@@ -355,7 +375,7 @@ export namespace Referral {
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!referral) throw new Error("Referral not found")
|
||||
if (!referral) return
|
||||
|
||||
const result = await tx
|
||||
.insert(ReferralRewardTable)
|
||||
@@ -373,7 +393,7 @@ export namespace Referral {
|
||||
},
|
||||
])
|
||||
|
||||
if (result.rowsAffected === 0) throw new Error("Referral already completed")
|
||||
if (result.rowsAffected === 0) return
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "0.0.0",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -108,7 +108,7 @@ export const layer = Layer.effect(
|
||||
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const id = previous ?? (yield* root(repo))
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
|
||||
return {
|
||||
previous,
|
||||
|
||||
@@ -5,11 +5,16 @@ import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Project.defaultLayer)
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
function abs(value: string) {
|
||||
return AbsolutePath.make(value)
|
||||
}
|
||||
@@ -86,7 +91,7 @@ describe("ProjectV2.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses root commit when origin exists", () =>
|
||||
it.live("prefers normalized origin over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -97,13 +102,36 @@ describe("ProjectV2.resolve", () => {
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.id).toBe(remoteID("github.com/Acme/App"))
|
||||
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses root commit when local remote exists", () =>
|
||||
it.live("normalizes ssh and https remotes to the same id", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const https = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const a = yield* project.resolve(abs(ssh.path))
|
||||
const b = yield* project.resolve(abs(https.path))
|
||||
|
||||
expect(a.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores file remotes and falls back to root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -118,7 +146,7 @@ describe("ProjectV2.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers previous cached id over origin", () =>
|
||||
it.live("returns previous cached id from common dir", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -131,7 +159,7 @@ describe("ProjectV2.resolve", () => {
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -185,7 +213,7 @@ describe("ProjectV2.resolve", () => {
|
||||
|
||||
expect(result.directory).toBe(yield* real(worktree))
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: true,
|
||||
port: 3002,
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.15.10"
|
||||
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.10/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.10/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.10/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.10/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.10/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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.10",
|
||||
"version": "1.15.11",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -122,6 +122,7 @@
|
||||
"@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",
|
||||
@@ -163,6 +164,7 @@
|
||||
"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:"
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { ACPNextPermission } from "./permission"
|
||||
import {
|
||||
duplicateRunningToolUpdate,
|
||||
errorToolUpdate,
|
||||
@@ -22,7 +23,8 @@ import {
|
||||
|
||||
const log = Log.create({ service: "acp-next-event" })
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate">
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
type GlobalEventEnvelope = {
|
||||
payload?: Event
|
||||
}
|
||||
@@ -40,6 +42,7 @@ 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(
|
||||
@@ -48,7 +51,9 @@ export class Subscription {
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
},
|
||||
) {}
|
||||
) {
|
||||
this.permission = new ACPNextPermission.Handler(input)
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) return
|
||||
@@ -65,6 +70,9 @@ 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":
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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"
|
||||
@@ -0,0 +1,42 @@
|
||||
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"
|
||||
@@ -35,9 +35,12 @@ 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"
|
||||
@@ -46,6 +49,8 @@ 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>
|
||||
@@ -69,20 +74,23 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AC
|
||||
|
||||
export function make(input: {
|
||||
sdk: OpencodeClient
|
||||
connection?: Pick<AgentSideConnection, "sessionUpdate">
|
||||
connection?: ServiceConnection
|
||||
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",
|
||||
@@ -99,7 +107,7 @@ export function make(input: {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const response = {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
@@ -124,6 +132,8 @@ export function make(input: {
|
||||
version: InstallationVersion,
|
||||
},
|
||||
}
|
||||
ACPNextProfile.duration("acp.initialize", started)
|
||||
return response
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
|
||||
@@ -134,15 +144,28 @@ export function make(input: {
|
||||
})
|
||||
|
||||
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
|
||||
return yield* directoryService.get(cwd)
|
||||
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
|
||||
})
|
||||
|
||||
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* request(
|
||||
const created = yield* profiledRequest(
|
||||
"acp.newSession.session.create",
|
||||
() =>
|
||||
input.sdk.session.create(
|
||||
{
|
||||
@@ -166,11 +189,12 @@ 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)
|
||||
|
||||
return {
|
||||
const response = {
|
||||
sessionId: state.id,
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? selected,
|
||||
@@ -178,6 +202,8 @@ export function make(input: {
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
ACPNextProfile.duration("acp.newSession", started)
|
||||
return response
|
||||
})
|
||||
|
||||
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
|
||||
@@ -204,6 +230,7 @@ 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)
|
||||
@@ -232,21 +259,35 @@ export function make(input: {
|
||||
),
|
||||
"session",
|
||||
)
|
||||
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
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 filtered =
|
||||
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
|
||||
cursor === undefined || !Number.isFinite(cursor)
|
||||
? sorted
|
||||
: sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor)
|
||||
const page = filtered.slice(0, limit)
|
||||
const last = page.at(-1)
|
||||
return {
|
||||
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) } : {}),
|
||||
sessions: page,
|
||||
...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -274,6 +315,7 @@ 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)
|
||||
@@ -291,6 +333,7 @@ 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(
|
||||
@@ -334,6 +377,7 @@ 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)
|
||||
@@ -353,7 +397,7 @@ export function make(input: {
|
||||
params: SetSessionConfigOptionRequest,
|
||||
) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
if (typeof params.value !== "string") {
|
||||
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
@@ -408,7 +452,7 @@ export function make(input: {
|
||||
|
||||
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
|
||||
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
|
||||
}
|
||||
@@ -418,7 +462,7 @@ export function make(input: {
|
||||
|
||||
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
const selected = yield* parseSelectedModel(snapshot, params.modelId)
|
||||
yield* session
|
||||
.setVariant(
|
||||
@@ -443,8 +487,81 @@ export function make(input: {
|
||||
setSessionConfigOption,
|
||||
setSessionMode,
|
||||
setSessionModel,
|
||||
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
|
||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
|
||||
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)
|
||||
}),
|
||||
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
|
||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
|
||||
@@ -473,6 +590,91 @@ 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 () => {
|
||||
@@ -505,6 +707,8 @@ 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 () => {
|
||||
@@ -519,66 +723,79 @@ function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
|
||||
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[]
|
||||
function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
return request(() => ACPNextProfile.measure(name, fn), service)
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
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[]
|
||||
|
||||
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 } : {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultModelFromSdk(
|
||||
sdk: OpencodeClient,
|
||||
directory: string,
|
||||
function defaultModelFromConfig(
|
||||
configuredModel: string | undefined,
|
||||
providers: Record<ProviderID, Provider.Info>,
|
||||
): 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)
|
||||
): Directory.DefaultModel | undefined {
|
||||
const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined
|
||||
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
|
||||
|
||||
const lastUsed = await lastUsedModel(sdk, directory, providers)
|
||||
if (lastUsed) return lastUsed
|
||||
|
||||
// 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 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 }
|
||||
@@ -588,30 +805,6 @@ async function defaultModelFromSdk(
|
||||
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]
|
||||
@@ -619,6 +812,43 @@ 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
|
||||
@@ -689,6 +919,7 @@ 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>()
|
||||
@@ -720,7 +951,16 @@ function registerMcpServers(
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.asVoid)
|
||||
).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() =>
|
||||
ACPNextProfile.duration("acp.mcp.register", started, {
|
||||
count: pending.size,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
function mcpRegistrationKey(name: string, config: ReturnType<typeof mcpConfig>) {
|
||||
|
||||
@@ -60,6 +60,7 @@ 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>
|
||||
@@ -168,6 +169,12 @@ 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,
|
||||
|
||||
@@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return locationFrom(input.filePath)
|
||||
return locationFrom(input.filePath ?? input.filepath)
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
|
||||
@@ -9,6 +9,7 @@ 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" })
|
||||
|
||||
@@ -23,10 +24,13 @@ 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(() => Server.listen(opts))
|
||||
const server = yield* Effect.promise(() =>
|
||||
ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
|
||||
)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
@@ -60,6 +64,7 @@ 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)
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ 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"
|
||||
@@ -50,7 +51,7 @@ import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { Toast, ToastProvider, useToast } from "./ui/toast"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
import { TuiEvent } from "./event"
|
||||
@@ -79,8 +80,7 @@ import {
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.quick_switch.1",
|
||||
@@ -92,6 +92,10 @@ const appBindingCommands = [
|
||||
"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",
|
||||
@@ -111,6 +115,7 @@ const appBindingCommands = [
|
||||
"theme.mode.lock",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"workspace.list",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"app.heap_snapshot",
|
||||
@@ -603,6 +608,16 @@ 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}`,
|
||||
@@ -929,6 +944,10 @@ 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: () => {
|
||||
@@ -1072,8 +1091,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
<TuiPluginRuntime.Slot name="app_bottom" />
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot name="app" />
|
||||
{/* One Toast for the whole app, not per-route, so toasts survive route.navigate */}
|
||||
<Toast />
|
||||
</Show>
|
||||
<StartupLoading ready={ready} />
|
||||
</box>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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,7 +512,10 @@ export function Prompt(props: PromptProps) {
|
||||
const content = await Editor.open({
|
||||
value,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
if (!content) return
|
||||
|
||||
|
||||
@@ -442,16 +442,25 @@ function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: bool
|
||||
: theme.warning
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
@@ -82,6 +83,7 @@ export function Home() {
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot name="home_bottom" />
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<Toast />
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
|
||||
|
||||
@@ -5,8 +5,6 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { useDialog, type DialogContext } from "../../ui/dialog"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
import { strip } from "@tui/component/prompt/part"
|
||||
@@ -16,37 +14,24 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
// Forking a large session can take a moment, so swap the dialog to a progress view instead of
|
||||
// leaving it open (which looks frozen), then navigate to the fork and confirm with a toast.
|
||||
const fork = async (dialog: DialogContext, messageID?: string, prompt?: PromptInfo) => {
|
||||
dialog.replace(() => (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1}>
|
||||
<Spinner>Forking session…</Spinner>
|
||||
</box>
|
||||
))
|
||||
const forked = await sdk.client.session.fork({ sessionID: props.sessionID, messageID })
|
||||
if (!forked.data) {
|
||||
toast.show({ variant: "error", message: "Failed to fork session" })
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
route.navigate({ sessionID: forked.data.id, type: "session", prompt })
|
||||
dialog.clear()
|
||||
toast.show({ variant: "success", message: "Forked session", duration: 4000 })
|
||||
}
|
||||
|
||||
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
|
||||
const messages = sync.data.message[props.sessionID] ?? []
|
||||
const fullSession = {
|
||||
title: "Full session",
|
||||
value: undefined,
|
||||
onSelect: fork,
|
||||
onSelect: async (dialog: DialogContext) => {
|
||||
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
} satisfies DialogSelectOption<string | undefined>
|
||||
const result = [] as DialogSelectOption<string | undefined>[]
|
||||
for (const message of messages) {
|
||||
@@ -59,8 +44,13 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: (dialog) => {
|
||||
const prompt = (sync.data.part[message.id] ?? []).reduce(
|
||||
onSelect: async (dialog) => {
|
||||
const forked = await sdk.client.session.fork({
|
||||
sessionID: props.sessionID,
|
||||
messageID: message.id,
|
||||
})
|
||||
const parts = sync.data.part[message.id] ?? []
|
||||
const prompt = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
@@ -70,7 +60,12 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
return fork(dialog, message.id, prompt)
|
||||
route.navigate({
|
||||
sessionID: forked.data!.id,
|
||||
type: "session",
|
||||
prompt,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ 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"
|
||||
@@ -67,7 +68,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import parsers from "../../../../../../parsers-config.ts"
|
||||
import * as Clipboard from "../../util/clipboard"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { Toast, useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv.tsx"
|
||||
import * as Editor from "../../util/editor"
|
||||
import stripAnsi from "strip-ansi"
|
||||
@@ -133,12 +134,6 @@ 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",
|
||||
@@ -153,6 +148,17 @@ 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
|
||||
@@ -409,15 +415,19 @@ 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) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: next.id,
|
||||
})
|
||||
}
|
||||
if (next) enterChild(next.id)
|
||||
}
|
||||
|
||||
function moveChild(direction: number) {
|
||||
@@ -428,12 +438,7 @@ export function Session() {
|
||||
|
||||
if (next >= sessions.length) next = 0
|
||||
if (next < 0) next = sessions.length - 1
|
||||
if (sessions[next]) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: sessions[next].id,
|
||||
})
|
||||
}
|
||||
if (sessions[next]) enterChild(sessions[next].id)
|
||||
}
|
||||
|
||||
function childSessionHandler(func: () => void) {
|
||||
@@ -968,7 +973,10 @@ export function Session() {
|
||||
await Editor.open({
|
||||
value: transcript,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
} else {
|
||||
const exportDir = process.cwd()
|
||||
@@ -981,7 +989,10 @@ export function Session() {
|
||||
const result = await Editor.open({
|
||||
value: transcript,
|
||||
renderer,
|
||||
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
|
||||
cwd:
|
||||
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
|
||||
project.instance.directory() ||
|
||||
process.cwd(),
|
||||
})
|
||||
if (result !== undefined) {
|
||||
await Filesystem.write(filepath, result)
|
||||
@@ -1001,8 +1012,8 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
moveFirstChild()
|
||||
dialog.clear()
|
||||
moveFirstChild()
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1029,8 +1040,8 @@ export function Session() {
|
||||
hidden: true,
|
||||
enabled: !!session()?.parentID,
|
||||
run: childSessionHandler(() => {
|
||||
moveChild(1)
|
||||
dialog.clear()
|
||||
moveChild(1)
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -1040,8 +1051,8 @@ export function Session() {
|
||||
hidden: true,
|
||||
enabled: !!session()?.parentID,
|
||||
run: childSessionHandler(() => {
|
||||
moveChild(-1)
|
||||
dialog.clear()
|
||||
moveChild(-1)
|
||||
}),
|
||||
},
|
||||
])
|
||||
@@ -1061,6 +1072,15 @@ 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),
|
||||
@@ -1263,6 +1283,7 @@ export function Session() {
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
<Switch>
|
||||
@@ -1574,24 +1595,33 @@ function ReasoningHeader(props: {
|
||||
: theme.warning
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1751,6 +1781,7 @@ function GenericTool(props: ToolProps<any>) {
|
||||
function InlineTool(props: {
|
||||
icon: string
|
||||
iconColor?: RGBA
|
||||
color?: RGBA
|
||||
complete: any
|
||||
pending: string
|
||||
spinner?: boolean
|
||||
@@ -1772,6 +1803,7 @@ 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
|
||||
@@ -2056,8 +2088,10 @@ 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)
|
||||
@@ -2079,6 +2113,11 @@ 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
|
||||
@@ -2093,7 +2132,10 @@ 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}`]
|
||||
|
||||
if (isRunning() && tools().length > 0) {
|
||||
const retrying = retry()
|
||||
if (isRunning() && retrying) {
|
||||
content.push(`↳ ${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`)
|
||||
} else if (isRunning() && tools().length > 0) {
|
||||
// content[0] += ` · ${tools().length} toolcalls`
|
||||
if (current()) {
|
||||
const state = current()!.state
|
||||
@@ -2116,6 +2158,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
return (
|
||||
<InlineTool
|
||||
icon="│"
|
||||
color={retry() ? theme.error : undefined}
|
||||
spinner={isRunning()}
|
||||
complete={props.input.description}
|
||||
pending="Delegating..."
|
||||
@@ -2124,6 +2167,8 @@ 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,6 +51,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
details?: string[]
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
categoryView?: JSX.Element
|
||||
@@ -167,7 +168,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
if (!category) return acc
|
||||
return acc + (i > 0 ? 2 : 1)
|
||||
}, 0)
|
||||
return flat().length + headers
|
||||
return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -426,7 +427,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
return (
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
flexDirection="column"
|
||||
position="relative"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
@@ -446,24 +447,37 @@ 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}
|
||||
>
|
||||
<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
|
||||
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>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -90,13 +90,20 @@ export const Info = Schema.Struct({
|
||||
description: "Enable promptCacheKey for this provider (default false)",
|
||||
}),
|
||||
timeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
|
||||
}),
|
||||
headerTimeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
|
||||
}),
|
||||
chunkTimeout: Schema.optional(PositiveInt).annotate({
|
||||
description:
|
||||
|
||||
@@ -54,6 +54,7 @@ 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")),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -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 "./codex"
|
||||
import { CodexAuthPlugin } from "./openai/codex"
|
||||
import { Session } from "@/session/session"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||
@@ -29,6 +29,7 @@ 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" })
|
||||
|
||||
@@ -57,18 +58,28 @@ 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)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
CodexAuthPlugin,
|
||||
CopilotAuthPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
]
|
||||
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,
|
||||
]
|
||||
}
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
return typeof value === "function"
|
||||
@@ -151,7 +162,7 @@ export const layer = Layer.effect(
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
|
||||
for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
|
||||
log.info("loading internal plugin", { name: plugin.name })
|
||||
const init = yield* Effect.tryPromise({
|
||||
try: () => plugin(input),
|
||||
@@ -244,6 +255,20 @@ export const layer = Layer.effect(
|
||||
}).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(
|
||||
hooks,
|
||||
(hook) =>
|
||||
Effect.tryPromise({
|
||||
try: () => Promise.resolve(hook.dispose?.()),
|
||||
catch: (error) => {
|
||||
log.error("plugin dispose hook failed", { error })
|
||||
},
|
||||
}).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
|
||||
// Subscribe to bus events, fiber interrupted when scope closes
|
||||
yield* (yield* bus.subscribeAll()).pipe(
|
||||
Stream.runForEach((input) =>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
+32
-31
@@ -1,10 +1,11 @@
|
||||
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" })
|
||||
|
||||
@@ -28,20 +29,12 @@ 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 bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||
return Array.from(bytes)
|
||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)))
|
||||
.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 {
|
||||
@@ -50,10 +43,6 @@ 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 }>
|
||||
@@ -120,6 +109,7 @@ interface TokenResponse {
|
||||
interface CodexAuthPluginOptions {
|
||||
issuer?: string
|
||||
codexApiEndpoint?: string
|
||||
experimentalWebSockets?: boolean
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||
@@ -371,8 +361,14 @@ 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) {
|
||||
@@ -410,7 +406,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
provider: "openai",
|
||||
async loader(getAuth) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
const websocketFetch = options.experimentalWebSockets
|
||||
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
|
||||
: undefined
|
||||
if (websocketFetch) {
|
||||
websocketFetches.push(websocketFetch)
|
||||
websocketFetchInstalled = true
|
||||
}
|
||||
if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {}
|
||||
|
||||
let refreshPromise:
|
||||
| Promise<{
|
||||
@@ -422,7 +425,6 @@ 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")
|
||||
@@ -436,12 +438,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
}
|
||||
|
||||
const currentAuth = await getAuth()
|
||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||
if (currentAuth.type !== "oauth")
|
||||
return websocketFetch ? websocketFetch(requestInput, init) : 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")
|
||||
@@ -473,7 +474,6 @@ 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,16 +488,11 @@ 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
|
||||
@@ -507,10 +502,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
? new URL(codexApiEndpoint)
|
||||
: parsed
|
||||
|
||||
return fetch(url, {
|
||||
const requestInit = {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
|
||||
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -521,7 +518,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
authorize: async () => {
|
||||
const { redirectUri } = await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateState()
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
|
||||
|
||||
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||
@@ -638,7 +635,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
if (input.model.providerID !== "openai") return
|
||||
output.headers.originator = "opencode"
|
||||
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||
output.headers.session_id = input.sessionID
|
||||
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
|
||||
@@ -0,0 +1,272 @@
|
||||
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"
|
||||
@@ -0,0 +1,326 @@
|
||||
// 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"
|
||||
@@ -3,6 +3,22 @@ import { STATUS_CODES } from "http"
|
||||
import { iife } from "@/util/iife"
|
||||
import type { ProviderID } from "./schema"
|
||||
|
||||
export class HeaderTimeoutError extends Error {
|
||||
public override readonly name = "ProviderHeaderTimeoutError"
|
||||
|
||||
constructor(public readonly ms: number) {
|
||||
super(`Provider response headers timed out after ${ms}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
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 = [
|
||||
|
||||
@@ -28,9 +28,10 @@ import * as ProviderTransform from "./transform"
|
||||
import { ModelID, ProviderID } from "./schema"
|
||||
import { ModelStatus } from "./model-status"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderError } from "./error"
|
||||
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
|
||||
function shouldUseCopilotResponsesApi(modelID: string): boolean {
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
@@ -85,6 +86,15 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function timeoutController(ms: number) {
|
||||
const ctl = new AbortController()
|
||||
const id = setTimeout(() => ctl.abort(new ProviderError.HeaderTimeoutError(ms)), ms)
|
||||
return {
|
||||
signal: ctl.signal,
|
||||
clear: () => clearTimeout(id),
|
||||
}
|
||||
}
|
||||
|
||||
function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) {
|
||||
if (!project) return
|
||||
if (location !== "eu" && location !== "us") return
|
||||
@@ -194,7 +204,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
return sdk.responses(modelID)
|
||||
},
|
||||
options: {},
|
||||
options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT },
|
||||
}),
|
||||
xai: () =>
|
||||
Effect.succeed({
|
||||
@@ -1601,16 +1611,21 @@ export const layer = Layer.effect(
|
||||
|
||||
const customFetch = options["fetch"]
|
||||
const chunkTimeout = options["chunkTimeout"]
|
||||
const headerTimeout = options["headerTimeout"]
|
||||
delete options["chunkTimeout"]
|
||||
delete options["headerTimeout"]
|
||||
|
||||
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
||||
const fetchFn = customFetch ?? fetch
|
||||
const opts = init ?? {}
|
||||
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
||||
const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout
|
||||
const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined
|
||||
const signals: AbortSignal[] = []
|
||||
|
||||
if (opts.signal) signals.push(opts.signal)
|
||||
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
|
||||
if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal)
|
||||
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
|
||||
signals.push(AbortSignal.timeout(options["timeout"]))
|
||||
|
||||
@@ -1639,7 +1654,7 @@ export const layer = Layer.effect(
|
||||
...opts,
|
||||
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
|
||||
timeout: false,
|
||||
})
|
||||
}).finally(() => headerTimeoutCtl?.clear())
|
||||
|
||||
if (!chunkAbortCtl) return res
|
||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||
|
||||
@@ -596,8 +596,16 @@ 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 (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
|
||||
if (anthropicOpus47OrLater(apiId)) {
|
||||
return ["low", "medium", "high", "xhigh", "max"]
|
||||
}
|
||||
if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) {
|
||||
@@ -625,6 +633,7 @@ 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") ||
|
||||
@@ -833,9 +842,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
{
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
|
||||
? { display: "summarized" }
|
||||
: {}),
|
||||
...(adaptiveOpus ? { display: "summarized" } : {}),
|
||||
},
|
||||
effort,
|
||||
},
|
||||
@@ -872,9 +879,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
reasoningConfig: {
|
||||
type: "adaptive",
|
||||
maxReasoningEffort: effort,
|
||||
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
|
||||
? { display: "summarized" }
|
||||
: {}),
|
||||
...(adaptiveOpus ? { display: "summarized" } : {}),
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -14,18 +14,20 @@ 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 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.
|
||||
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.
|
||||
|
||||
```ts
|
||||
export const rawRoute = HttpRouter.use((router) =>
|
||||
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
|
||||
yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty))
|
||||
return handlers.handleRaw("connect", (ctx) => connectPty(ctx.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.
|
||||
@@ -34,4 +36,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, 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.
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
|
||||
export const EventPaths = {
|
||||
event: "/event",
|
||||
@@ -20,5 +22,8 @@ export const EventApi = HttpApi.make("event").add(
|
||||
}),
|
||||
),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization)
|
||||
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 } from "../middleware/authorization"
|
||||
import { Authorization, PtyConnectAuthorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
@@ -138,9 +139,10 @@ 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(
|
||||
@@ -149,8 +151,22 @@ 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." })),
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(PtyConnectAuthorization),
|
||||
)
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { 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, Params, PtyPaths } from "../groups/pty"
|
||||
import { CursorQuery, PtyConnectApi } from "../groups/pty"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
|
||||
@@ -121,37 +121,39 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
yield* router.add(
|
||||
"GET",
|
||||
PtyPaths.connect,
|
||||
Effect.gen(function* () {
|
||||
const params = yield* HttpRouter.schemaPathParams(Params)
|
||||
const exists = yield* pty.get(params.ptyID).pipe(
|
||||
|
||||
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(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!exists) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
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)
|
||||
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)
|
||||
if (ticket) {
|
||||
const valid = validOrigin(request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
const valid = validOrigin(ctx.request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
: false
|
||||
if (!valid) return HttpServerResponse.empty({ status: 403 })
|
||||
}
|
||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||
const parsedCursor = query.value.cursor === undefined ? undefined : Number(query.value.cursor)
|
||||
const cursor =
|
||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
|
||||
? parsedCursor
|
||||
: undefined
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const socket = yield* Effect.orDie(ctx.request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
const closeAccepted = (event: Socket.CloseEvent) =>
|
||||
socket
|
||||
@@ -186,7 +188,7 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
},
|
||||
}
|
||||
const handler = yield* pty
|
||||
.connect(params.ptyID, adapter, cursor)
|
||||
.connect(ctx.params.ptyID, adapter, cursor)
|
||||
.pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
@@ -194,12 +196,8 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
// 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.
|
||||
// The handshake runs inside `socket.runRaw`, after the input callback is
|
||||
// registered, so the client cannot send frames before PTY input is wired.
|
||||
yield* socket
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
.pipe(
|
||||
|
||||
@@ -27,6 +27,13 @@ export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>(
|
||||
},
|
||||
) {}
|
||||
|
||||
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
|
||||
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
|
||||
{
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
},
|
||||
) {}
|
||||
|
||||
function emptyCredential() {
|
||||
return {
|
||||
username: "",
|
||||
@@ -56,11 +63,11 @@ function decodeCredential(input: string) {
|
||||
Effect.match({
|
||||
onFailure: emptyCredential,
|
||||
onSuccess: (header) => {
|
||||
const parts = header.split(":")
|
||||
if (parts.length !== 2) return emptyCredential()
|
||||
const separator = header.indexOf(":")
|
||||
if (separator === -1) return emptyCredential()
|
||||
return {
|
||||
username: parts[0],
|
||||
password: Redacted.make(parts[1]),
|
||||
username: header.slice(0, separator),
|
||||
password: Redacted.make(header.slice(separator + 1)),
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -105,7 +112,6 @@ 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)),
|
||||
)
|
||||
@@ -129,6 +135,24 @@ 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 { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { WorkspaceRouteContext } from "./workspace-routing"
|
||||
|
||||
@@ -41,10 +41,3 @@ 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,17 +4,11 @@ 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.raw(webSource(request)?.body ?? null, {
|
||||
contentType: request.headers["content-type"],
|
||||
contentLength: len ? Number(len) : undefined,
|
||||
})
|
||||
return HttpBody.stream(request.stream, request.headers["content-type"], len ? Number(len) : undefined)
|
||||
}
|
||||
|
||||
export function websocket(
|
||||
|
||||
+13
-24
@@ -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, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpClient, 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,
|
||||
sessionWorkspaceID?: WorkspaceID,
|
||||
session?: Session.Info,
|
||||
): 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, sessionWorkspaceID)
|
||||
: selectedWorkspaceID(url, sessionWorkspaceID)
|
||||
? selectedV2WorkspaceID(url, session?.workspaceID)
|
||||
: selectedWorkspaceID(url, session?.workspaceID)
|
||||
if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace()
|
||||
const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID)
|
||||
|
||||
@@ -178,7 +178,10 @@ function planRequest(
|
||||
return yield* planWorkspaceRequest(request, url, workspace)
|
||||
}
|
||||
|
||||
return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID })
|
||||
return RequestPlan.Local({
|
||||
directory: session?.directory || defaultDirectory(request, url),
|
||||
workspaceID: envWorkspaceID ?? workspaceID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -219,11 +222,14 @@ function routeHttpApiWorkspace<E>(
|
||||
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
|
||||
const session = sessionID
|
||||
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
|
||||
Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)),
|
||||
Effect.catchIf(
|
||||
(error): error is NotFoundError => NotFoundError.isInstance(error),
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Effect.catchDefect(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: undefined
|
||||
const plan = yield* planRequest(request, session?.workspaceID)
|
||||
const plan = yield* planRequest(request, session)
|
||||
return yield* routeWorkspace(client, effect, plan)
|
||||
})
|
||||
}
|
||||
@@ -242,20 +248,3 @@ 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,8 +59,14 @@ import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { PublicApi } from "./public"
|
||||
import { authorizationLayer, authorizationRouterMiddleware, v2AuthorizationLayer } from "./middleware/authorization"
|
||||
import {
|
||||
authorizationLayer,
|
||||
authorizationRouterMiddleware,
|
||||
ptyConnectAuthorizationLayer,
|
||||
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"
|
||||
@@ -72,15 +78,15 @@ import { mcpHandlers } from "./handlers/mcp"
|
||||
import { permissionHandlers } from "./handlers/permission"
|
||||
import { projectHandlers } from "./handlers/project"
|
||||
import { providerHandlers } from "./handlers/provider"
|
||||
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
|
||||
import { ptyConnectHandlers, 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, instanceRouterMiddleware } from "./middleware/instance-context"
|
||||
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
import { instanceContextLayer } from "./middleware/instance-context"
|
||||
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { compressionLayer } from "./middleware/compression"
|
||||
@@ -102,24 +108,27 @@ const cors = (corsOptions?: CorsOptions) =>
|
||||
|
||||
// Route tree:
|
||||
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
|
||||
// - 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.
|
||||
// - 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.
|
||||
// - 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(instanceRouterLayer),
|
||||
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
|
||||
)
|
||||
const ptyConnectApiRoutes = HttpApiBuilder.layer(PtyConnectApi).pipe(
|
||||
Layer.provide(ptyConnectHandlers),
|
||||
Layer.provide([ptyConnectHttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
|
||||
)
|
||||
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
Layer.provide([
|
||||
@@ -141,15 +150,8 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
]),
|
||||
)
|
||||
|
||||
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,
|
||||
]),
|
||||
const instanceRoutes = instanceApiRoutes.pipe(
|
||||
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
|
||||
)
|
||||
|
||||
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
|
||||
@@ -184,7 +186,7 @@ type RouteRequirements =
|
||||
export function createRoutes(
|
||||
corsOptions?: CorsOptions,
|
||||
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
||||
Layer.provide([
|
||||
errorLayer,
|
||||
compressionLayer,
|
||||
|
||||
@@ -1143,6 +1143,29 @@ export function fromError(
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case e instanceof ProviderError.HeaderTimeoutError:
|
||||
return new APIError(
|
||||
{
|
||||
message: e.message,
|
||||
isRetryable: true,
|
||||
metadata: {
|
||||
code: e.name,
|
||||
timeoutMs: String(e.ms),
|
||||
},
|
||||
},
|
||||
{ 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,
|
||||
|
||||
@@ -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.worktree)
|
||||
const primary = yield* canonical(ctx.project.worktree)
|
||||
const primaryName = pathSvc.basename(primary).toLowerCase()
|
||||
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
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,6 +4,7 @@ import type {
|
||||
ForkSessionResponse,
|
||||
LoadSessionResponse,
|
||||
NewSessionResponse,
|
||||
SessionNotification,
|
||||
ResumeSessionResponse,
|
||||
SessionConfigOption,
|
||||
SessionConfigSelectOption,
|
||||
@@ -14,6 +15,7 @@ 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"
|
||||
|
||||
@@ -141,10 +143,14 @@ const provider: Provider.Info = {
|
||||
|
||||
describe("ACP next service sessions", () => {
|
||||
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
|
||||
const updates: unknown[] = []
|
||||
const updates: SessionNotification[] = []
|
||||
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",
|
||||
@@ -184,6 +190,36 @@ 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 })
|
||||
@@ -201,13 +237,33 @@ describe("ACP next service sessions", () => {
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const connection = {
|
||||
sessionUpdate: (update: unknown) => {
|
||||
sessionUpdate: (update: SessionNotification) => {
|
||||
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 }), updates, mcpAdds, aborts, forks }
|
||||
return {
|
||||
service: ACPNextService.make({ sdk, connection, usage }),
|
||||
updates,
|
||||
mcpAdds,
|
||||
aborts,
|
||||
forks,
|
||||
prompts,
|
||||
commands,
|
||||
summarizes,
|
||||
usageUpdates,
|
||||
}
|
||||
}
|
||||
|
||||
it("creates a backed session with config options and command update", async () => {
|
||||
@@ -267,6 +323,15 @@ 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({}))
|
||||
@@ -546,6 +611,46 @@ 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: [] }))
|
||||
@@ -611,24 +716,35 @@ describe("ACP next service sessions", () => {
|
||||
expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
|
||||
})
|
||||
|
||||
it("does not reload providers or commands when switching effort from a warm snapshot", async () => {
|
||||
let providersCalls = 0
|
||||
let commandCalls = 0
|
||||
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,
|
||||
}
|
||||
const sdk = {
|
||||
config: {
|
||||
providers: () => {
|
||||
providersCalls++
|
||||
calls.providers++
|
||||
return 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: [] }),
|
||||
agents: () => {
|
||||
calls.agents++
|
||||
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
|
||||
},
|
||||
skills: () => {
|
||||
calls.skills++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list: () => {
|
||||
commandCalls++
|
||||
calls.commands++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
@@ -637,14 +753,16 @@ describe("ACP next service sessions", () => {
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
mcp: {
|
||||
add: () => Promise.resolve({ data: {} }),
|
||||
add: () => {
|
||||
calls.mcpAdds++
|
||||
return Promise.resolve({ data: {} })
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
expect(providersCalls).toBe(1)
|
||||
expect(commandCalls).toBe(1)
|
||||
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
|
||||
|
||||
await Effect.runPromise(
|
||||
service.setSessionConfigOption({
|
||||
@@ -654,11 +772,350 @@ describe("ACP next service sessions", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(providersCalls).toBe(1)
|
||||
expect(commandCalls).toBe(1)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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) ?? []
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
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
|
||||
`
|
||||
@@ -0,0 +1,61 @@
|
||||
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,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
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,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
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,
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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 timing diagnostic stays bounded and returns model options",
|
||||
"first session returns model options",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
@@ -50,7 +50,6 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const started = Date.now()
|
||||
yield* acp.request<InitializeResponse>("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
@@ -62,9 +61,6 @@ 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")
|
||||
@@ -74,7 +70,7 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"warm newSession timing diagnostic stays bounded",
|
||||
"newSession can be called repeatedly",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
@@ -87,22 +83,19 @@ 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 timing diagnostic updates currentValue",
|
||||
"model switch updates currentValue",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
@@ -121,7 +114,6 @@ 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,
|
||||
@@ -129,9 +121,7 @@ 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,
|
||||
@@ -189,7 +179,7 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"skill slash command timing diagnostic appears through available_commands_update",
|
||||
"skill slash command appears through available_commands_update",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const skills = path.join(home, "skills")
|
||||
@@ -214,20 +204,6 @@ 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,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,12 @@ import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
|
||||
import {
|
||||
getOpencodeModeStack,
|
||||
OPENCODE_BASE_MODE,
|
||||
OpencodeKeymapProvider,
|
||||
registerOpencodeKeymap,
|
||||
} from "@/cli/cmd/tui/keymap"
|
||||
|
||||
test("legacy page key aliases compile as page keys", async () => {
|
||||
const sequences: Record<string, string[][]> = {}
|
||||
@@ -52,3 +57,80 @@ 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,6 +63,7 @@ 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")
|
||||
}),
|
||||
)
|
||||
@@ -91,6 +92,16 @@ 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(
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
extractAccountIdFromClaims,
|
||||
extractAccountId,
|
||||
type IdTokenClaims,
|
||||
} from "../../src/plugin/codex"
|
||||
} from "../../src/plugin/openai/codex"
|
||||
|
||||
function createTestJwt(payload: object): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
|
||||
@@ -122,6 +122,24 @@ 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,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,700 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,15 @@ import path from "path"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Database } from "@/storage/db"
|
||||
import { ProjectTable } from "@/project/project.sql"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { WorkspaceTable } from "@/control-plane/workspace.sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
@@ -31,6 +40,10 @@ function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
|
||||
})
|
||||
}
|
||||
|
||||
function remoteProjectID(remote: string) {
|
||||
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
|
||||
* matching `failArg` and returns exit code 128, while delegating everything
|
||||
@@ -154,28 +167,91 @@ describe("Project.fromDirectory", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps root commit identity when origin exists", () =>
|
||||
it.live("prefers normalized origin remote over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const root = (yield* Effect.promise(() => $`git rev-list --max-parents=0 HEAD`.cwd(tmp).text())).trim()
|
||||
|
||||
expect(project.id).toBe(ProjectID.make(root))
|
||||
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps cached project identity when origin becomes available", () =>
|
||||
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* tmpdirScoped({ git: true })
|
||||
const https = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
|
||||
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
|
||||
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
|
||||
|
||||
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates cached root project data when origin becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project: rootProject } = yield* projects.fromDirectory(tmp)
|
||||
const remoteID = remoteProjectID("github.com/acme/app")
|
||||
const sessionID = crypto.randomUUID() as SessionID
|
||||
const workspaceID = WorkspaceID.ascending()
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
Database.use((db) => {
|
||||
db.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: rootProject.id,
|
||||
slug: sessionID,
|
||||
directory: tmp,
|
||||
title: "test",
|
||||
version: "0.0.0-test",
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(PermissionTable)
|
||||
.values({
|
||||
project_id: rootProject.id,
|
||||
data: [{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: workspaceID,
|
||||
type: "local",
|
||||
name: "test",
|
||||
project_id: rootProject.id,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
})
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(rootProject.id)
|
||||
expect(project.id).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id,
|
||||
).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())
|
||||
?.project_id,
|
||||
).toBe(remoteID)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -214,6 +214,22 @@ 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",
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { createServer, type Server } from "node:http"
|
||||
import { streamText } from "ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { Env } from "@/env"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Provider.defaultLayer,
|
||||
Env.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
TestLLMServer.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
|
||||
provideTmpdirServer(
|
||||
({ llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
|
||||
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
expect(yield* Effect.promise(() => result.text)).toBe("late")
|
||||
}),
|
||||
{
|
||||
config: (url) => {
|
||||
const config = testProviderConfig(url)
|
||||
return {
|
||||
...config,
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
options: { ...config.provider.test.options, headerTimeout: 50 },
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("headerTimeout aborts when response headers do not arrive", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedHeaderServer(250)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
onError() {},
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
const errors = yield* Effect.promise(async () => {
|
||||
const errors: string[] = []
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") errors.push(String(part.error))
|
||||
}
|
||||
return errors
|
||||
})
|
||||
expect(errors.join("\n")).toContain("response headers timed out")
|
||||
}),
|
||||
{ config: providerConfig(server.url, { headerTimeout: 50 }) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("headerTimeout is opt-in for non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedHeaderServer(100)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
expect(yield* Effect.promise(() => result.text)).toBe("ok")
|
||||
}),
|
||||
{ config: providerConfig(server.url) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("OpenAI Codex headerTimeout default can be disabled by config", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* withAuthContent(
|
||||
Effect.gen(function* () {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const openai = yield* provider.getProvider(ProviderID.openai)
|
||||
expect(openai.options.headerTimeout).toBe(false)
|
||||
}),
|
||||
{ config: { provider: { openai: { options: { headerTimeout: false } } } } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("OpenAI API auth gets default headerTimeout", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* withAuthContent(
|
||||
Effect.gen(function* () {
|
||||
yield* provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const openai = yield* provider.getProvider(ProviderID.openai)
|
||||
expect(openai.options.headerTimeout).toBe(10_000)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ openai: { type: "api", key: "sk-test" } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function providerConfig(url: string, options: Record<string, unknown> = {}) {
|
||||
const config = testProviderConfig(url)
|
||||
return {
|
||||
...config,
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
options: { ...config.provider.test.options, ...options },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> {
|
||||
const server = createServer((_, res) => {
|
||||
setTimeout(() => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" })
|
||||
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n')
|
||||
}, delay)
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
|
||||
return { server, url: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
function withAuthContent<A, E, R>(self: Effect.Effect<A, E, R>, value: Record<string, unknown> = defaultAuthContent()) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.OPENCODE_AUTH_CONTENT
|
||||
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify(value)
|
||||
return previous
|
||||
}),
|
||||
() => self,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT
|
||||
else process.env.OPENCODE_AUTH_CONTENT = previous
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function defaultAuthContent() {
|
||||
return {
|
||||
openai: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user