mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-01 04:56:15 +00:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7050f68a20 | ||
|
|
75b6d6ad54 | ||
|
|
f46fa72a92 | ||
|
|
14636b9900 | ||
|
|
b020f1018b | ||
|
|
7878744505 | ||
|
|
599bdc982e | ||
|
|
82bb5ff88a | ||
|
|
0159356be6 | ||
|
|
9388b94a97 | ||
|
|
02c3891d9b | ||
|
|
1844c11104 | ||
|
|
c0285b457a | ||
|
|
ffa4c4c730 | ||
|
|
01efd22b2a | ||
|
|
dd68d6eb79 | ||
|
|
1243d09dbc | ||
|
|
a80a00011d | ||
|
|
bf97a0443f | ||
|
|
67b14e9191 | ||
|
|
1b02abcfab | ||
|
|
74dbc509d7 | ||
|
|
760f87fab1 | ||
|
|
df51166711 | ||
|
|
8d8a7bc844 | ||
|
|
a2fc1968be | ||
|
|
c2a39ac592 | ||
|
|
4deda18037 | ||
|
|
0f83038253 | ||
|
|
4c33a253aa | ||
|
|
135995d4fc | ||
|
|
4d9090790b | ||
|
|
621a5b0706 | ||
|
|
16ef62c851 | ||
|
|
b694d2bf26 | ||
|
|
89d37a0f6d | ||
|
|
bc5ff1cfd9 | ||
|
|
c14a1f5acf | ||
|
|
7db941a892 | ||
|
|
83fafda63a | ||
|
|
a565ea8c74 | ||
|
|
3126808e0e |
No files matched your search
@@ -662,7 +662,7 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- publish
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
|
||||
if: ${{ !cancelled() && github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success' }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -178,7 +178,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -204,7 +204,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -377,6 +377,7 @@
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jose": "6.0.11",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
@@ -409,7 +410,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -458,7 +459,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -495,7 +496,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -511,7 +512,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -530,7 +531,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -543,7 +544,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -557,7 +558,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -572,7 +573,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -598,8 +599,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.12",
|
||||
"@opentui/solid": ">=0.5.12",
|
||||
"@opentui/core": ">=0.5.14",
|
||||
"@opentui/solid": ">=0.5.14",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -611,7 +612,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -641,7 +642,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -656,7 +657,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -680,7 +681,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -701,7 +702,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -723,7 +724,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -758,7 +759,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -778,7 +779,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -812,7 +813,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -831,7 +832,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -877,7 +878,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -891,7 +892,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -925,7 +926,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -960,7 +961,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -998,7 +999,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1039,7 +1040,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -1116,9 +1117,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.12",
|
||||
"@opentui/keymap": "0.5.12",
|
||||
"@opentui/solid": "0.5.12",
|
||||
"@opentui/core": "0.5.14",
|
||||
"@opentui/keymap": "0.5.14",
|
||||
"@opentui/solid": "0.5.14",
|
||||
"@pierre/diffs": "1.5.1",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2254,27 +2255,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.12", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.12", "@opentui/core-darwin-x64": "0.5.12", "@opentui/core-linux-arm64": "0.5.12", "@opentui/core-linux-arm64-musl": "0.5.12", "@opentui/core-linux-x64": "0.5.12", "@opentui/core-linux-x64-musl": "0.5.12", "@opentui/core-win32-arm64": "0.5.12", "@opentui/core-win32-x64": "0.5.12" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ZXBE5gmvdovmV8zJQrOQf6E44v1tJRDEgrM2MYhEglzgXZ+smIUp95O8zeRYGsuIzQIiMPMgQqKtTJuzvAb7BQ=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.14", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.14", "@opentui/core-darwin-x64": "0.5.14", "@opentui/core-linux-arm64": "0.5.14", "@opentui/core-linux-arm64-musl": "0.5.14", "@opentui/core-linux-x64": "0.5.14", "@opentui/core-linux-x64-musl": "0.5.14", "@opentui/core-win32-arm64": "0.5.14", "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YdVnP0tAyerBNl0mIcmQEOotPeZzW1VnSXKBl5cyZ5e6nDd2Y+ui/8eRPpn1oqcamf1NCnzS4ohMgejOvna8Zg=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-uRrQJdHmLUSj3PV23QPi3WSimYTTxcXnVouxF6U4xMXlOv4N3SxnHfVwMRQkPqbGOfvVWHeLE6FdK4C+ubU0sQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-XeKhuIaEtgipvuPHbl4qPOBj+Ut+2zObmsxMVM1jDcjz/FatG9PGeGQPx1G1SnvH2AgpT4K+eCu7DUF0+yIqoQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ2sNMw1d/r1SLPjUbOP9LKscKz1CQjID8adTL6gG8Lrrq+mYcIUxutyB+P/eG0J/7oRZLPR6OMt7dUOap6RTg=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-eZiCjEzwbb6qClPPfk32Nha9xmr9obt69Xj0+9SKsXxWLBKkjQEGOMRoh/R9ObaQF4aq8If1xV3VEY0sD9W9vg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-WWW0hVBoSYZ3D6AgZ4u2Y5/u/IyIq2pDb+4yI3WgJ70Wyt6ofHy+6kRGRgbXFn1p+rPInAHjCXD2v6C7iEKSrA=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-aLbm6870Ybls6CYL4zMOCImTBPLZHZMUXJFGqMI44lIWxitkAtT6zg5lYA4oRqFRzzryDclxr29+hDgT3p3Blw=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-KTwtwpfd2zF9opVh3SyRJYDd1o3Xv4XL8OZb8Zi+CqWUel6Y2IDCiVivCv8fGJt3J7wOIXXtuZI9ZUkLyKJCiQ=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.12", "", { "dependencies": { "@opentui/core": "0.5.12" }, "peerDependencies": { "@opentui/react": "0.5.12", "@opentui/solid": "0.5.12", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yWPvJjRhJTRoRSUucQq9Ua8ZW7n/2YQ/j6JxWq5Qekm4WuFiTplEkebR/Aj2/xA8tX68NOE5qv1LrY0Jk3NLNQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.14", "", { "dependencies": { "@opentui/core": "0.5.14" }, "peerDependencies": { "@opentui/react": "0.5.14", "@opentui/solid": "0.5.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YGTAvRrpQTbRNV7GH0UxRuCSPxwnSooVdB+qPHHP+3ywc93+lhekljgP8dRivl32f3CiMqqm96Uj2PgwKxkC0Q=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.12", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.12", "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-hAiVlVMtT7AkHGblKwcW1YAuXtxkSy1XSf/RRc4j3IlG3mTNX0bhJdnGOo3Xw14EqeZMp41Mcp5WzHAzMm/DzA=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.14", "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-bBRl34mZ0wFGhjHX6y1VNiZmJ3DSm2DVPm0PNSVDpvq9qxRHuywEPTCn/Lgte0C9450tgf11cDqcE3pR9cZwVA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
@@ -52,9 +52,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.12",
|
||||
"@opentui/keymap": "0.5.12",
|
||||
"@opentui/solid": "0.5.12",
|
||||
"@opentui/core": "0.5.14",
|
||||
"@opentui/keymap": "0.5.14",
|
||||
"@opentui/solid": "0.5.14",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -51,6 +51,17 @@ const RESPECTS_INLINE_HINTS = new Set([
|
||||
"openrouter",
|
||||
])
|
||||
|
||||
// OpenRouter upstreams other than Anthropic and Alibaba Qwen cache without breakpoints. Gemini uses only the last
|
||||
// breakpoint, so a conversation-tail breakpoint writes a new cache every step and costs more than none. Qwen ignores
|
||||
// breakpoints on tool definitions and caches tools with the system prompt.
|
||||
const openRouterPolicy = (modelID: string): CachePolicyObject => {
|
||||
// `~anthropic/claude-sonnet-latest` style IDs are OpenRouter aliases for the latest model in a family.
|
||||
const id = modelID.replace(/^~/, "")
|
||||
if (id.startsWith("anthropic/")) return AUTO
|
||||
if (id.startsWith("qwen/")) return { system: true, messages: { tail: 1 } }
|
||||
return NONE
|
||||
}
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
|
||||
@@ -156,9 +167,10 @@ const countHints = (request: LLMRequest) =>
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto"))
|
||||
return request
|
||||
const policy = resolve(request.cache)
|
||||
const policy =
|
||||
request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")
|
||||
? openRouterPolicy(request.model.id)
|
||||
: resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
const hint = makeHint(policy.ttlSeconds)
|
||||
|
||||
@@ -426,6 +426,7 @@ interface ActiveContent {
|
||||
readonly type: "text" | "reasoning"
|
||||
readonly id: string
|
||||
readonly thinking?: MistralThinkingContent
|
||||
readonly thinkingUnits?: MistralThinkingUnit[]
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
@@ -502,8 +503,8 @@ const closeActive = (state: ParserState, events: LLMEvent[]) => {
|
||||
state.lifecycle,
|
||||
events,
|
||||
state.active.id,
|
||||
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
|
||||
thinkingText(state.active.thinking?.thinking ?? []),
|
||||
thinkingMetadata({ ...state.active.thinking, type: "thinking", thinking: state.active.thinkingUnits ?? [] }),
|
||||
thinkingText(state.active.thinkingUnits ?? []),
|
||||
)
|
||||
return { ...state, lifecycle, active: undefined }
|
||||
}
|
||||
@@ -524,20 +525,23 @@ const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOut
|
||||
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
|
||||
const units = thinkingUnits(part.thinking)
|
||||
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
|
||||
// Keep native units out of streamed events until the block is complete.
|
||||
const accumulated = active.thinkingUnits ?? []
|
||||
accumulated.push(...units)
|
||||
const thinking = {
|
||||
...active.thinking,
|
||||
...part,
|
||||
type: "thinking" as const,
|
||||
thinking: [...(active.thinking?.thinking ?? []), ...units],
|
||||
thinking: [],
|
||||
}
|
||||
const text = thinkingText(units)
|
||||
return {
|
||||
...current,
|
||||
lifecycle:
|
||||
text.length > 0
|
||||
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
|
||||
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
|
||||
active: { ...active, thinking },
|
||||
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text)
|
||||
: Lifecycle.reasoningStart(current.lifecycle, events, active.id),
|
||||
active: { ...active, thinking, thinkingUnits: accumulated },
|
||||
nextContent: current.active ? current.nextContent : current.nextContent + 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ReasoningPart,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
@@ -45,6 +44,7 @@ const OpenAIChatCacheControl = Schema.Struct({
|
||||
type: Schema.Literal("ephemeral"),
|
||||
ttl: Schema.optional(Schema.String),
|
||||
})
|
||||
type OpenAIChatCacheControl = Schema.Schema.Type<typeof OpenAIChatCacheControl>
|
||||
|
||||
const OpenAIChatFunction = Schema.Struct({
|
||||
name: Schema.String,
|
||||
@@ -121,12 +121,15 @@ const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
|
||||
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
|
||||
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
|
||||
|
||||
const OpenAIChatTextContent = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
})
|
||||
type OpenAIChatTextContent = Schema.Schema.Type<typeof OpenAIChatTextContent>
|
||||
|
||||
const OpenAIChatUserContent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
OpenAIChatTextContent,
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("image_url"),
|
||||
image_url: Schema.Struct({ url: Schema.String }),
|
||||
@@ -136,6 +139,7 @@ const OpenAIChatUserContent = Schema.Union([
|
||||
file: Schema.Struct({ filename: Schema.String, file_data: Schema.String }),
|
||||
}),
|
||||
])
|
||||
type OpenAIChatUserContent = Schema.Schema.Type<typeof OpenAIChatUserContent>
|
||||
|
||||
const OpenAIChatMessage = Schema.Union([
|
||||
Schema.Struct({
|
||||
@@ -149,21 +153,19 @@ const OpenAIChatMessage = Schema.Union([
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("assistant"),
|
||||
content: Schema.NullOr(Schema.String),
|
||||
content: Schema.NullOr(Schema.Union([Schema.String, Schema.Array(OpenAIChatTextContent)])),
|
||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||
reasoning_content: Schema.optional(Schema.String),
|
||||
reasoning: Schema.optional(Schema.String),
|
||||
reasoning_text: Schema.optional(Schema.String),
|
||||
reasoning_details: Schema.optional(Schema.Unknown),
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("tool"),
|
||||
tool_call_id: Schema.String,
|
||||
content: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatTextContent)]),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
||||
@@ -327,9 +329,7 @@ export interface ParserState {
|
||||
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
|
||||
// fields into `LLMRequest`.
|
||||
interface LoweringOptions {
|
||||
readonly cacheControl?: (
|
||||
cache: CacheHint | undefined,
|
||||
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
|
||||
readonly cacheControl?: (cache: CacheHint | undefined) => OpenAIChatCacheControl | undefined
|
||||
readonly toolCallID?: (id: string) => string
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const content: OpenAIChatUserContent[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
|
||||
@@ -441,14 +441,14 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
requireReasoning: boolean,
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
) {
|
||||
const content: TextPart[] = []
|
||||
const content: OpenAIChatTextContent[] = []
|
||||
const reasoning: ReasoningPart[] = []
|
||||
const toolCalls: OpenAIChatAssistantToolCall[] = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "assistant", ["text", "reasoning", "tool-call"])
|
||||
if (part.type === "text") {
|
||||
content.push(part)
|
||||
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
@@ -486,14 +486,15 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
if (reasoning.length === 0) return nativeReasoning ?? (requireReasoning ? "" : undefined)
|
||||
return text
|
||||
})()
|
||||
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
||||
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
|
||||
const result = {
|
||||
role: "assistant" as const,
|
||||
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",
|
||||
content: (() => {
|
||||
if (content.some((part) => part.cache_control !== undefined)) return content
|
||||
if (content.length === 0 && toolCalls.length > 0) return null
|
||||
return content.map((part) => part.text).join("")
|
||||
})(),
|
||||
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||
...(details !== undefined ? { reasoning_details: details } : {}),
|
||||
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}),
|
||||
}
|
||||
if (field === undefined || reasoningText === undefined) return result
|
||||
return { ...result, [field]: reasoningText }
|
||||
@@ -504,33 +505,38 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const messages: OpenAIChatMessage[] = []
|
||||
const attachments: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const attachments: OpenAIChatUserContent[] = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
|
||||
if (part.result.type !== "content") {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
|
||||
content: ProviderShared.toolResultText(part),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
messages.push(
|
||||
toolMessage(
|
||||
options.toolCallID?.(part.id) ?? part.id,
|
||||
ProviderShared.toolResultText(part),
|
||||
options.cacheControl?.(part.cache),
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
|
||||
content: text.join("\n"),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
messages.push(
|
||||
toolMessage(options.toolCallID?.(part.id) ?? part.id, text.join("\n"), options.cacheControl?.(part.cache)),
|
||||
)
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
attachments.push(...(yield* Effect.forEach(files, (item) => lowerMedia(ProviderShared.toolFileMedia(item)))))
|
||||
}
|
||||
return { messages, attachments }
|
||||
})
|
||||
|
||||
// Chat cache breakpoints belong on text content parts, not on the message itself.
|
||||
const toolMessage = (toolCallID: string, text: string, cacheControl: OpenAIChatCacheControl | undefined) => ({
|
||||
role: "tool" as const,
|
||||
tool_call_id: toolCallID,
|
||||
content: cacheControl === undefined ? text : [{ type: "text" as const, text, cache_control: cacheControl }],
|
||||
})
|
||||
|
||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField: string | undefined,
|
||||
@@ -589,7 +595,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool")
|
||||
messages.push({ role: "assistant", content: "Done." })
|
||||
}
|
||||
const pendingAttachments: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const pendingAttachments: OpenAIChatUserContent[] = []
|
||||
const flushAttachments = () => {
|
||||
if (pendingAttachments.length === 0) return
|
||||
bridgeTools()
|
||||
@@ -599,24 +605,25 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
if (message.role === "user") bridgeTools()
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
||||
const cacheControl = options.cacheControl?.(part.cache)
|
||||
if (pendingAttachments.length > 0) {
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
...pendingAttachments.splice(0),
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||
{ type: "text", text: part.text, cache_control: cacheControl },
|
||||
],
|
||||
})
|
||||
continue
|
||||
}
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user" && typeof previous.content === "string")
|
||||
messages[messages.length - 1] = options.cacheControl?.(part.cache)
|
||||
messages[messages.length - 1] = cacheControl
|
||||
? {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: previous.content },
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
|
||||
{ type: "text", text: part.text, cache_control: cacheControl },
|
||||
],
|
||||
}
|
||||
: { role: "user", content: `${previous.content}\n${part.text}` }
|
||||
@@ -625,15 +632,15 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
role: "user",
|
||||
content: [
|
||||
...previous.content,
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||
{ type: "text", text: part.text, cache_control: cacheControl },
|
||||
],
|
||||
}
|
||||
else
|
||||
messages.push(
|
||||
options.cacheControl?.(part.cache)
|
||||
cacheControl
|
||||
? {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
|
||||
content: [{ type: "text", text: part.text, cache_control: cacheControl }],
|
||||
}
|
||||
: { role: "user", content: part.text },
|
||||
)
|
||||
|
||||
@@ -16,9 +16,13 @@ import {
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
/input is too long for requested model/i,
|
||||
// Cloudflare Workers AI reports this as HTTP 413.
|
||||
/exceeded this model context window limit/i,
|
||||
/exceeds the context window/i,
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||
/input token count.*exceeds the maximum/i,
|
||||
// Amazon Nova on Bedrock reports this as a mid-stream validationException.
|
||||
/number of input tokens exceeds maximum length/i,
|
||||
/tokens in request more than max tokens allowed/i,
|
||||
/maximum prompt length is \d+/i,
|
||||
/reduce the length of the messages/i,
|
||||
|
||||
@@ -42,14 +42,24 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(requireAccountId(input))}/ai/v1`
|
||||
}
|
||||
|
||||
// Cloudflare's REST API rejects parts of the Anthropic Messages and OpenAI Responses request shapes, such as
|
||||
// system prompt blocks and `minimal` reasoning effort, so these models use the provider-native gateway endpoints.
|
||||
const passthroughURL = (input: GatewayURL & GatewayOptions, provider: "anthropic/v1" | "openai") =>
|
||||
`https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(requireAccountId(input))}/${encodeURIComponent(gatewayId(input))}/${provider}`
|
||||
|
||||
const requireAccountId = (input: GatewayURL) => {
|
||||
if (input.accountId) return input.accountId
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
}
|
||||
|
||||
const gatewayId = (input: GatewayOptions) => input.gatewayId?.trim() || "default"
|
||||
|
||||
export const responsesRoute = OpenAIResponses.route.with({
|
||||
id: "cloudflare-ai-gateway-responses",
|
||||
provider: id,
|
||||
@@ -70,16 +80,12 @@ export const route = OpenAIChat.route.with({
|
||||
|
||||
export const routes = [responsesRoute, messagesRoute, route]
|
||||
|
||||
const auth = (input: LanguageModelOptions) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
return Auth.optional(input.gatewayApiKey ?? ("apiKey" in input ? input.apiKey : undefined), "apiKey")
|
||||
const credential = (input: LanguageModelOptions) =>
|
||||
Auth.optional(input.gatewayApiKey ?? ("apiKey" in input ? input.apiKey : undefined), "apiKey")
|
||||
.orElse(Auth.config(authEnvVars[0]))
|
||||
.orElse(Auth.config(authEnvVars[1]))
|
||||
.bearer()
|
||||
}
|
||||
|
||||
const headers = (input: LanguageModelOptions) => ({
|
||||
...(input.gatewayId === undefined ? {} : { "cf-aig-gateway-id": input.gatewayId.trim() || "default" }),
|
||||
...(input.metadata === undefined
|
||||
? {}
|
||||
: { "cf-aig-metadata": Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(input.metadata) }),
|
||||
@@ -90,31 +96,42 @@ const headers = (input: LanguageModelOptions) => ({
|
||||
...input.headers,
|
||||
})
|
||||
|
||||
const modelID = (input: string | ModelID) => {
|
||||
const value = String(input)
|
||||
if (value.startsWith("workers-ai/")) return value.slice("workers-ai/".length)
|
||||
if (value.startsWith("anthropic/")) return `anthropic/${value.slice("anthropic/".length).replaceAll(".", "-")}`
|
||||
return value
|
||||
}
|
||||
|
||||
export const configure = (input: LanguageModelOptions) => {
|
||||
const defaults = {
|
||||
const custom = "auth" in input && input.auth ? input.auth : undefined
|
||||
const rest = {
|
||||
endpoint: { baseURL: baseURL(input) },
|
||||
auth: auth(input),
|
||||
headers: headers(input),
|
||||
auth: custom ?? credential(input).bearer(),
|
||||
headers: { "cf-aig-gateway-id": gatewayId(input), ...headers(input) },
|
||||
http: input.http,
|
||||
providerOptions: input.providerOptions,
|
||||
}
|
||||
const responses = responsesRoute.with(defaults)
|
||||
const messages = messagesRoute.with(defaults)
|
||||
const chat = route.with(defaults)
|
||||
// A custom baseURL stands in for the REST API, so every model keeps using it with gateway model IDs.
|
||||
const native = input.baseURL === undefined
|
||||
const passthrough = (provider: "anthropic/v1" | "openai") =>
|
||||
native
|
||||
? {
|
||||
...rest,
|
||||
endpoint: { baseURL: passthroughURL(input, provider) },
|
||||
auth: custom ?? Auth.bearerHeader("cf-aig-authorization", credential(input)),
|
||||
headers: headers(input),
|
||||
}
|
||||
: rest
|
||||
const responses = responsesRoute.with(passthrough("openai"))
|
||||
const messages = messagesRoute.with(passthrough("anthropic/v1"))
|
||||
const chat = route.with(rest)
|
||||
return {
|
||||
id,
|
||||
model: (input: string | ModelID) => {
|
||||
const wire = modelID(input)
|
||||
if (String(input).startsWith("openai/")) return responses.model<OpenAIProviderOptionsInput>({ id: wire })
|
||||
if (String(input).startsWith("anthropic/")) return messages.model<OpenAIProviderOptionsInput>({ id: wire })
|
||||
return chat.model<OpenAIProviderOptionsInput>({ id: wire })
|
||||
const value = String(input)
|
||||
if (value.startsWith("openai/"))
|
||||
return responses.model<OpenAIProviderOptionsInput>({ id: native ? value.slice("openai/".length) : value })
|
||||
if (value.startsWith("anthropic/"))
|
||||
return messages.model<OpenAIProviderOptionsInput>({
|
||||
id: native ? value.slice("anthropic/".length).replaceAll(".", "-") : value,
|
||||
})
|
||||
if (value.startsWith("workers-ai/"))
|
||||
return chat.model<OpenAIProviderOptionsInput>({ id: value.slice("workers-ai/".length) })
|
||||
return chat.model<OpenAIProviderOptionsInput>({ id: value })
|
||||
},
|
||||
configure,
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway-messages",
|
||||
"transport": "http",
|
||||
"model": "claude-haiku-4-5",
|
||||
"tags": [
|
||||
"prefix:cloudflare-ai-gateway",
|
||||
"provider:cloudflare-ai-gateway",
|
||||
"text",
|
||||
"golden"
|
||||
],
|
||||
"name": "cloudflare-ai-gateway/cloudflare-ai-gateway-claude-haiku-4-5-text",
|
||||
"recordedAt": "2026-09-30T04:35:43.657Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"p\": \"6247a7b95b5e3f\", \"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfZ16hXSVRfaXBDtUBKis\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null}}\n\nevent: content_block_start\ndata: {\"p\": \"f9b\", \"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"p\": \"cc76aa8\", \"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"p\": \"94f\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"p\": \"1a9d8\", \"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"p\": \"0cb3c23\", \"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"p\": \"6ef9de29973ed\", \"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway-messages",
|
||||
"transport": "http",
|
||||
"model": "claude-haiku-4-5",
|
||||
"tags": [
|
||||
"prefix:cloudflare-ai-gateway",
|
||||
"provider:cloudflare-ai-gateway",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"golden"
|
||||
],
|
||||
"name": "cloudflare-ai-gateway/cloudflare-ai-gateway-claude-haiku-4-5-tool-loop",
|
||||
"recordedAt": "2026-09-30T04:35:45.222Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"p\": \"f2f6a212\", \"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfZ16jzmxrCrHLxeCxrL8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":593,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":26,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null} }\n\nevent: content_block_start\ndata: {\"p\": \"7\", \"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_019hb1DDwKBNMMeaKjZf1CpS\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"p\": \"e112b39d9a4869\", \"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"p\": \"12d\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"p\": \"2\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\"\"} }\n\nevent: content_block_delta\ndata: {\"p\": \"f6bb01e5ae5c1c7\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\": \\\"Pari\"} }\n\nevent: content_block_delta\ndata: {\"p\": \"c26aa17\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"s\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"p\": \"16f665\", \"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"p\": \"e00cc845875\", \"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":593,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":54} }\n\nevent: message_stop\ndata: {\"p\": \"5082e73ce8055a\", \"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_019hb1DDwKBNMMeaKjZf1CpS\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_019hb1DDwKBNMMeaKjZf1CpS\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"p\": \"ff048fa113d17ffc\", \"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfZ16ooU6seiMzxt69doW\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"container\":null,\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":668,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null} }\n\nevent: content_block_start\ndata: {\"p\": \"5172b1\", \"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"p\": \"d\", \"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"p\": \"7f847273c\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris\"} }\n\nevent: content_block_delta\ndata: {\"p\": \"1de4\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is sunny\"}}\n\nevent: content_block_delta\ndata: {\"p\": \"d61\", \"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"}}\n\nevent: content_block_stop\ndata: {\"p\": \"29638a9b46e21ecd\", \"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"p\": \"8e5f9442b03f76\", \"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"container\":null},\"usage\":{\"input_tokens\":668,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":7} }\n\nevent: message_stop\ndata: {\"p\": \"97d9afcead79c7a\", \"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway-responses",
|
||||
"transport": "http",
|
||||
"model": "gpt-5-nano",
|
||||
"tags": [
|
||||
"prefix:cloudflare-ai-gateway",
|
||||
"provider:cloudflare-ai-gateway",
|
||||
"text",
|
||||
"golden"
|
||||
],
|
||||
"name": "cloudflare-ai-gateway/cloudflare-ai-gateway-openai-gpt-5-nano-text",
|
||||
"recordedAt": "2026-09-30T04:36:07.656Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openai/responses",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5-nano\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"instructions\":\"You are concise.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":2000,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: response.created\ndata: {\"p\": \"6e564fc3bf3\", \"type\":\"response.created\",\"response\":{\"id\":\"resp_0137cf30a4752bdf016abc91b6263487d19ea9f96c7605459a\",\"object\":\"response\",\"created_at\":1790742966,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"You are concise.\",\"max_output_tokens\":2000,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"p\": \"f61c4ed2a9768\", \"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0137cf30a4752bdf016abc91b6263487d19ea9f96c7605459a\",\"object\":\"response\",\"created_at\":1790742966,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"You are concise.\",\"max_output_tokens\":2000,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"p\": \"01\", \"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0137cf30a4752bdf016abc91b69ae887d1af96950defb06f97\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqvJG2MoCXBYbRlRsokydYprE0lt8iLr1a1iUQbNLeerXyd1MY4Fm9MfjJLFaGzGPJy6wwZUZmm6nsZ7LP1VprwIAnv2kfNTBoO57je5cZ78dkzJzOOLYnQyaiSPrbXTXKG8JQfKrrt0qN7x5-eiXD-SIcQa8E9Or_ZugNqIYn7UfiS3YC5Xupjij5eCgl43kmMzNLNoQgBOv5sTQkj4saT7b0NltrY_coexW7o7HA3YJU02_aKqV6YSKKKMzZkVUw8Qt71Jwbg28x-Udpk9Wk6uzqqMSFRchkQJ4oT8HGsFO6MfHo-eLUNqGI24ZJlYcHI_CVW8IkjcpGWtv7dHzoOIAbQ3tTx09taixBP3PvaIcD9Qn5YsUV7dE3NncXID-eAnjgs0WmP9Tz8vvLXZrYiV4Ry0S_gIAUPFKC-3Z9e7yQ395JWPeWk7K1eT9y0FmTGHNZ6MVfzqBahukSbogL1Q1izblwxu6dTVMfQbo0n7IEi6HhfDYIzsHNbpYDDL54yG0ncHNy4fSu4dnYjuZrokgqThIOCrpeYcJq7xTN9cTU5L-AQz9_EEraVt2swKnl-4sJ1V8k6j0eJIZMK8yTAgOAUiI3jFBLscCRGjmmWLft2UDMt8h6SoKFXyDmTyRZJ2Mbm0wBIvKmH-EZM5-xc2T_57XV9vI-BaPZYrbxT7jtOuK8oqnpZRtLGyDKiUYxibnU1J2AUh3HbXz-nMrnjA6WxU9NXCVf-1WnVQyJfFAIRD0Kpbt6c6rXOJw7aMGLHATOFFG4716vVg79CvT-ll9_AsUNQwyby8sRUYi-Y05x4vxPnkegilDuhnytDt6uYqNRFertTCaSCIg-u96yHrg3r8T9zBpy3UqogKq2pkG-WrkNxIwEa0LMOqlwQwrnIDf72eV9wFpg9M67ezVptNFTkCLi3BOB4Gfi4SFkmOBBE4tfjymktfUWGk9g1uAa3VAsRRSBcbMqwbnSS9s1flwOp7NkHffGHdDP6bo66Y-YcsryokhHWi1s3ikK7cvp5n6BdpypWLRQq4za25PGf3uj7c17XKM6YoiV36wuu4AKp6XAX2iJ1cIDIqLETLmPJowSL7bj-Nodly_VatRK8h4yjbCExGdk6WURCo93KaEc1h9lbFKuRWeYGNPIByblnILU4JSoOm40K1aP0DT_KKJ6Gg==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"p\": \"2fe6743f311de\", \"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0137cf30a4752bdf016abc91b69ae887d1af96950defb06f97\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqvJG3gRV3VHJfHPkeyz8hrmbpzFNdsSUxXsSgIJAc7Z5rVjrK-eBH6uM3cuwf6oFRHDk5gl3tjbkubNIsNuX9QWS5qKZmni4YyzHa00eDWaF0mdEB41BiwcO085Ef-zgU9dsblogPVbaAkCx9AG01rLpeJvmEZrG3ZceeHDer7iebUpvoFSeBLRnuUn192MSKUTF0602CdbtQvzI39HuCijTVGiEh4dWgs5BoAvnD0tfNeWrXZBOueaf_CFInJPuoR4eyAORHV5pTn8fbAEiv8mVqKvHI0OL7GvA0E1Yg7c5rlrLgJj0Ey6P5POAYCL7KOkT3kw1LKbA4glCNOiZC54XWY2V7jI1bgquxOgj0FG3Gstl9B7IMncSTuNgKZONEJ9LWWVWOtLl6kMRq0gLZqhpKUoNqkgKpiS7noUkEQJJ7n-tv1djLIfrHdwWDO2XJhb0ouXC6qgn-TKnu1eBKCaGcWvKLine truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "cloudflare-ai-gateway",
|
||||
"route": "cloudflare-ai-gateway-responses",
|
||||
"transport": "http",
|
||||
"model": "gpt-5-nano",
|
||||
"tags": [
|
||||
"prefix:cloudflare-ai-gateway",
|
||||
"provider:cloudflare-ai-gateway",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"golden"
|
||||
],
|
||||
"name": "cloudflare-ai-gateway/cloudflare-ai-gateway-openai-gpt-5-nano-tool-loop",
|
||||
"recordedAt": "2026-09-30T04:36:34.984Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openai/responses",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5-nano\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":2000,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: response.created\ndata: {\"p\": \"d82f1d10b60\", \"type\":\"response.created\",\"response\":{\"id\":\"resp_06bc195765a8c9c7016abc91ce40f887d189169a2d89f2ee42\",\"object\":\"response\",\"created_at\":1790742990,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"max_output_tokens\":2000,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"p\": \"0b0cb44c44\", \"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06bc195765a8c9c7016abc91ce40f887d189169a2d89f2ee42\",\"object\":\"response\",\"created_at\":1790742990,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"max_output_tokens\":2000,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"p\": \"608c6368a0c703ea\", \"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_06bc195765a8c9c7016abc91ce881c87d18e71310b9a21b3ca\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqvJHOLa_CYgj0Ynl-_tBncVpCPOfdueKV7-3xsbVVIXhZFUdhtEfjPYHW85-TecrFUmkr7jMw4OsT2Zckq7xkxVfZ8kXNObzjU6N6t35hp8hIJ9CbGmJBsxZBQceHHtZgQrOVWCKJ7z1B1k5zsJ8lbSRkLVAahsCEwg-tYDzQAkYUcqlK7VS62sKLLfDMYpSMCvv-y-5v9I4S74P0D2Zr7znJBap72RK0tHjN-d3vdjOaxMMkLP8JlK6-HlOWabzVEPWmsdXFPFzjWuRjAOn5ILrcQzu5T8kPLjon2kW4XuwwcXeI_7UvFmt9yRfkTlVFVr-hRcSad9AjBHHYx3nmvlhM2nVuKggGolqpwdKLR87sccTmbo0kzYp8Av1yHPJ6xKxAIJYZO_XDdvXGK1vu6WFqxe37LXpWIAvJ1BbXo7yMdV2tZshqP2WBiYdILkMiZFV4VDuzuOYEx3_56QyL4sdh0p3_mxXfgjEiLNzloBrq9VtPL1YLucqzAZwCDuhtwKLC3zZ1awSC_exx7thmBbULObOBfOhWN9oIG53MrLbpJVN4MlENvtLoq7l_92k0VbPcOGDgJcmJjYOy-p7P_YDQ_Y9WR0oNpCNRFfarqGBOzegO7_UPRVUEKJeb3xx2e6-9HnrjC9jyAFs4MI54uNfKMbF7a6p55NWaEtvbTn3124XDaUD2qeSChCoAv2KFXxTIIyQeOVgVBQbHW8IamL9Eh0RvjEpg5WZqgRK4QdRZ8fiFL3lLV5stsqVvNz_1LAnGs4bnH69pmmlfO0E2C8LLhLhRVh9hcEXnKwpA6URICdn58uEPKVr9tDmTyaJbiZx_4lPbVSQ_T2ncKL8pL8gK4odQDdhe_XjdqF9i5N_t29H8A3XUDkXOgd5zyMepV1GCfKm7DaJHYXIIIjr_q4xheZFzxMl1KMQX_a9xbc-KRvOj2apHphqo5TsmYOFl5QYyt3wWllpmUZ6c9oSVOg159RFyR7UUdjGJDN8aN9pXlDjxYjR76Q9Xvmun2duw37Abi7TXl2BzJ2Bq9VFLGW_7CB_kv0vxNijP2K2XuD0Ge8tfM7HfU_gFXN-WXym5GdN6DMCxAg7whaBAj9su1wEaI11VQBTF0s41glo-V4DTcLyKTNFjScck_6a_vEnmFE57IEToGK_akhdx_SzKS9rHrA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\neveLine truncated
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openai/responses",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5-nano\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_06bc195765a8c9c7016abc91ce881c87d18e71310b9a21b3ca\",\"summary\":[],\"encrypted_content\":\"gAAAAABqvJHSN68IWwhWq9YTPxs-e-OeLdOqfdkkIEP_1hc9f7k6yH9tGZKLUC2OPF0avZ7xaJQp8iOR_gPt3z0TGQ1T0e4M9OR6boMFOA_C4TwinAF1POgbZLI-0SUDkAm1PZSaO0Ic7DBh1rRT6biled_-iWs0ke3FP3nFZit5Ve16AI1TdL4c9QERNCcZFsD5neV8eBeABWarZ8SMFfxcYJ5y8QkpDOL1ShqenD22Df7fDy8cdkYWybEEZcAfgXWSfQK2hRwSNYN_BDV9CfKj-cUTePwf0D2vLm-2xd9LGt-KiLPbsp9EpGXP41ra6-qHRLeu7MO64XWIu2vsf9qAflOjp7MOSuTJ3ZvDQoTNRs-pEam8-kRYRC07Hj9IzC0sjreE1NPVzz-Yuw79gqaQokfVmuznok8XoBFKLjQ1Sa9-i8Ano4lXOKz6APnXXBdbbSUBvNV3jlmqvtxhyZ2u901SuIxZcSmPY5aAEsaHiSm2lTIuHXr6f1WZcKOsR88U4ymlI-wSGKMKJT5hh9xxvSw4VQxcbjfMBllDpH6dg3X_AUwmN6JFVWZsl1QFyaTAt3LsbUY6xfuh_IbuO30tPnG5tlUrluJdXGloRCH11NFB7wjLQ3aR1DTSxjlfce9xxfX0OdZsrIeL0Z03kveHI5ZkIjp4xQo_VAGkU-tiQ8Dyx5DYiAR-sCYaOqNwzxKWVc5YYohZKpHjpCBel0pzDgUADDWyube45tOIDVyeefNjIfmRuDnJ9ZZhi45JY4E8kRviu00sFlfVxDdKLwnykuYBEeU6RCQSx6yVr5zxErvtqEbcrz3XvNJhOiNL6n_I8D6dd4muE7j43BrQgI_ae5dakGyvy83JLyeylqZKak5GIwq7KjymQQIWMVdQIfTuF4n6HbaYLcoYZthe9WdGjo3nnBkyn08Voor8OFycbUxS6qm-siAYU_0CgG29fDgorAb9F4DmFEM_Lg0ymYyBAEXsnm9EilD0rndz-tKthJN1e53CvZZlCfhbBKxjU-hTeiBeE49YIsM7KueWdCTNetU_gSeGipB-UU-sDJMvHUR0sclIenUIMC0MSNPuUBGdzTDjQBVKmJZvKUy8NJBghXHYEzCxZRdO_qnn-FIq731ZhqA-G58_48T-tovQF3JlygWW8d-XH4PzjgSR0YK4Ucpo-d8AcY0u-2HsEv6TaxgUvBnot_kPBxFhCpdLj6nbesLCAZkvmM1erBl-ew6ztD1FSt2MIg2Nr9vqOL-cQeH9zGX0OqXrbWWqCrOJkLSoq7ST-Fq6cQhvM_6KUw_mhgWHHuYy-68EgzyzZ7K7lQAx379F2Fiv2U3SR9JCWvtja5YigRBqM7PwxIyYs_o0y4p19UZg5jTx0TyR-pVXw1_CcknqmrGginPv2zKvbRlnRm-cIEO0mWsfwUgN7DzGSKa06zoEStd-aVprHolQ4_l3oFtTUVhymo4SO5VMAWVZLrGuZNn2PRifFZlvYl00Dt1RAMG0FguNGMcLn0-0hzxio-5psReiIguJfurRHnsk3tPyYwYqILmCng1OW5ytJvRUkWYgZ_esuD_9gnHyL58mYyW5MsqFmWeMoOGeDPde9ggHqf1uAZ6NxGbpco7njgxOhPKEKkr0bMXWmn31C1wh7VM2TkwhD2M9apFLQ1eX4wHl6hZsbknl1LdZrY3pLIL_SkAL6xnfxfCcA7hGli6fnlOVhLQC4WEIR-h7zJqeK60SF9-AZt6l3LwnQru5e5aIwKWv0xqS5VLf6mOmCRC0HVAJBdYRKU2CImoih7pey6HV7-JP1nxOTiK2teo5uI2PUrMLTV_oAh-O3SFkvX8eZ0LgGDF3s-AgEq3G-Hs5qNEeX7rcyUWeTZ63HZ2w7e4R1STF0pulnJW91So2vjF9aEBFY2AkpM-rSx2kKl-xYG3UMX-57Gbw0PPTNgnhwbP1AjAlPfG3TAt6Df8ouxUYsCsnA5tZna5e4QOQW1ZcEoesgJxy24QeaJN4OUHRBlmzEQ4d20rtNJL2wsed0HXdLSU427NicPdioL1lYVHXxlnU0tQEbQbLFQzIsh2WJaFqkDsBOTrN6HXTgUfY85cDo39Rm5u6gAEnJpef8NXpeqTim5oBASsqra5hZEjYi5PkFHNcLI663eNzFLlYix73_pVIcMch6YsHg6Z8JWLo7Xhz5vN4IqxEH8Qya_odYAkNpYN_kT1oTeo4MDqs0IpvPmj6tIylYRoW3txAnatsxge0l6HGa4_tG4dP_t91rsTJTzR8wsOsQZpnrKtuYEv5WlfuvpgeITrloDcWQOxq9VeqdYFdNEJPevIwvmEIA-udNoo-Ho31ZxhbODdI9p9nOLQ9lRxBVA__VOCNJisDFVLt_PyDevENtme1qJTe-RvsCuQBhTVffUPTpc4ez7V9V8CiTN37yb2VkMtHcmxcyNYiGdwIaMe3WKS_W9bM7Fqil1LX6mVu-49CnWp7SNRHXlvCeNfFWo_DvKiu8g6aMy-th-kdRSrMjKJ_6R_5ph0GKyGkF5sirhUf6W-S5s2WnV-3yj9Y0IbNWUr8xHK6SXgOXhSLfdLcJbedHNdIXIggd-jzLcAi5F_qkRV67g1W9IKQGwTzIFvoJrlq3gChxHzyrD3gOPYiAirj_6Kh2tO8iyEtEzXPNaIWHqxPnoHC2f79AA6S1rzzt1e0IPLyo0KrCeNWPb1lUsKDKOuxPB8_v6mp1LbSxAw-qWDcgHpHrM6Hve90fw-xSyRY1ZOJ14NsJE85vw2rqSo7AJtBQC2SrIsyKy5Q7rKC2lkQeyWdpCJOEslvHPTbNTftWXs9qZKSGhHXhY2_MCR5_jetBl33KKpIqOvJZs4nkaz2tmyCbFAmfQgcreiOYA1qGNWvPNJWUifd8GZHvDiemO37ZvLew3WCloB2bsn1urkN8OGmK2-NcL3jtVr0T7fRSvGG8BETDPUDtLvg2wAQeV0kGfM6dH37ED8auPcWxygz8cDD1ni8QoPpdlHsH55kL38rEerrgI_fEcAsAGqXacW8-07vKLDt_dJ4cmSVq-DVTIw-EQIrhBOf3gW8W5LP2_sVA2mjchwWP2mZ-v-zEeYVtkzCWznJCqFbeatyxHv5Q2phnWkxj2d-lsELVDHHRXPlB-MqUBsYLAhhPJzRC9n76EoOVdgr5bsfP1XQlYj_WdPdZ13HpGZyj7ZfIPRazmYlw-EBURIYzOHlilo0Hk7pLZ5Im7E5XtLXjb0r4MaUZltViFS7Za7lm1yeVmrA0k-X4T8lgTqaBpr5BnnVVTPUNB2701N4_Z3tPxFxO3Bi_wGVVqB19D1hZGyNCXHqxlvvvPozHi3gV6Oh5ElPb4_wWTJeXJTqPI_gcnExs8Z-AnwTST6Dbw1u4EBUapdXDNcC3PD6YHlQ2Cn0lUD3TMFjGZiTL3UQADwKHEtH8R45QIEadT7O8p1pVWhzFsdOZbNdc160iViD3A920MHSRcJ_pLhxwuyndMEfzJEInv3s7lIxcJtc_VVruYr-ce24eD57jf23TsXgBEkFBsGFvORSUtds_eviuK4_wjijz33Aeydm3SqMi_DfFoy0CDSLe7qnvWBda9IF4MRlvcR-N8qgZW_vjx3OkfiUUF2PDlw1tmXhSuiF1IoPusUWZQ7QD2aEEBD_RXaipdCy_Lh7yDEw9CtdEb332MLvzFgUPBLSWWyrA6gYQwpEiJHFrQMDetsaFFL-A5188dSPLjtXi9KkxGsA5wEbA1FcvABrHOBo_Ws8V3wzIuHl7uo7PO6FGIZB-d0RZiAw7AP5cVjG0udCWmQPpS0WVF5slSNmKgZjr9WD20w9yaliZOiPN_2A2w9Zx_ca4YBceORZ69irPy0xCKQJBlq0HMeZV_WzQATzbV0n8oyfx3CB12oF9JYn_4C5vqC_QnlGmUMFno3F1ZP94Tv7XqyDTt_JTAK_xppfVJEX7018apcF2w40aM5kp6_Clv0lb7AOkGWFKOAI7qOoraTldZh6ik_1DuJqKno7k0Qtc8RnRdQjNRhXJDfb1oqRBYvBfNQkROULiOoNx2yJdp7wvMjqah7WYE1wnKbftTPLjPDQDoc=\"},{\"type\":\"function_call\",\"id\":\"fc_06bc195765a8c9c7016abc91d247ec87d19e4f16354641a20f\",\"call_id\":\"call_RN4tTgQaJ61I5FoTePK9mpHu\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_RN4tTgQaJ61I5FoTePK9mpHu\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parametLine truncated
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: response.created\ndata: {\"p\": \"aa3530\", \"type\":\"response.created\",\"response\":{\"id\":\"resp_06bc195765a8c9c7016abc91d296d887d1bb460e087f742d90\",\"object\":\"response\",\"created_at\":1790742994,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"max_output_tokens\":2000,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"p\": \"a6b66d35bc20b2b\", \"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06bc195765a8c9c7016abc91d296d887d1bb460e087f742d90\",\"object\":\"response\",\"created_at\":1790742994,\"status\":\"in_progress\",\"access_programs\":null,\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"max_output_tokens\":2000,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"p\": \"169cd8bd840\", \"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_06bc195765a8c9c7016abc91d2e9dc87d1bc4d4524c7f3037d\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"p\": \"045e323978cf3\", \"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_06bc195765a8c9c7016abc91d2e9dc87d1bc4d4524c7f3037d\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"p\": \"42af845ab126d6\", \"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Paris\",\"item_id\":\"msg_06bc195765a8c9c7016abc91d2e9dc87d1bc4d4524c7f3037d\",\"logprobs\":[],\"obfuscation\":\"WAn5iJo53hF\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"p\": \"aa473d2c7c859\", \"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_06bc195765a8c9c7016abc91d2e9dc87d1bc4d4524c7f3037d\",\"logprobs\":[],\"obfuscation\":\"uH1AD585J0MVU\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"p\": \"8\", \"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_06bc195765a8c9c7016abc91d2e9dc87d1bc4d4524c7f3037d\",\"logprobs\":[],\"obfuscaLine truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"tags": ["prefix:openai-compatible-chat", "provider:openrouter", "protocol:openai-chat", "reasoning"],
|
||||
"name": "openrouter-reasoning",
|
||||
"recordedAt": "2026-07-18T11:28:39.267Z"
|
||||
"recordedAt": "2026-09-29T16:19:02.835Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -15,14 +15,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"temperature\":0,\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Think through the arithmetic, then reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219?\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_completion_tokens\":1536,\"temperature\":0,\"usage\":{\"include\":true},\"reasoning\":{\"max_tokens\":1024}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"Ev8CCrIBCBIYAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhN3oedXByb2ZfMDExQ2VkSE1TN0tEcTM4Z2JkUkp0bXN3qAH2ye/VBhIMhjcPOnE2wLhs0P05GgwA3z9luwbhnSyRMSQiMDYeIIThirdkJb/n2s2TxUk5EOMrSc1xejAb0RN7BIl66P5KhtwXvcZTiyCkBS8+7Cp6MOinWi+I8U7DhWskSA0pHZqaAncbV7qCIHkoZ5aT8jiPWplAOnlciDo2tLkGsLnzXrnfhI11GTF0kcfyhbrt3tjEDSiPstBbT2u19LJ5IqwV2VKl0pWVdl4PTsDwC09ClbnJj1y0A3AM9gEh4B9mEo4BvT0XVEncux4YAQ==.eyJlbmRwb2ludF9zbHVnIjoiYW50aHJvcGljL2NsYXVkZS00LjYtc29ubmV0LTIwMjYwMjE3fGNsYXVkZS1vbi1hd3MifQ\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":73,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -355,6 +355,34 @@ describe("provider error rawBody classification", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies Workers AI context window rejections as context overflow despite HTTP 413", () => {
|
||||
const message =
|
||||
"AiError: Ai: The estimated number of input and maximum output tokens (900047) exceeded this model context window limit (32000). (17851589-f2e6-405d-b67c-5550e52ee1c1)"
|
||||
const reason = classifyProviderFailure({
|
||||
message,
|
||||
status: 413,
|
||||
rawBody: JSON.stringify({ errors: [{ message, code: 5021 }], success: false, result: {}, messages: [] }),
|
||||
})
|
||||
expect(reason._tag === "InvalidRequest" ? reason.classification : reason._tag).toBe("context-overflow")
|
||||
})
|
||||
|
||||
test("classifies Amazon Nova input token rejections as context overflow", () => {
|
||||
const message =
|
||||
"The model returned the following errors: Input Tokens Exceeded: Number of input tokens exceeds maximum length. Please update the input to try again."
|
||||
const reason = classifyProviderFailure({
|
||||
message,
|
||||
rawBody: JSON.stringify({
|
||||
headers: {
|
||||
":exception-type": { type: "string", value: "validationException" },
|
||||
":content-type": { type: "string", value: "application/json" },
|
||||
":message-type": { type: "string", value: "exception" },
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
}),
|
||||
})
|
||||
expect(reason._tag === "InvalidRequest" ? reason.classification : reason._tag).toBe("context-overflow")
|
||||
})
|
||||
|
||||
test("classifies Google invalid API keys as authentication failures", () => {
|
||||
const rawBody = JSON.stringify({
|
||||
error: {
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.headers).toMatchObject({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ custom: true })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
}
|
||||
|
||||
@@ -39,9 +39,16 @@ describe("Cloudflare", () => {
|
||||
)
|
||||
|
||||
expect(responses.route).toBe("cloudflare-ai-gateway-responses")
|
||||
expect(responses.body).toMatchObject({ model: "openai/gpt-5.4", stream: true })
|
||||
expect(responses.model.route.endpoint.baseURL).toBe(
|
||||
"https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/openai",
|
||||
)
|
||||
expect(responses.body).toMatchObject({ model: "gpt-5.4", stream: true })
|
||||
expect(messages.route).toBe("cloudflare-ai-gateway-messages")
|
||||
expect(messages.body).toMatchObject({ model: "anthropic/claude-haiku-4-5" })
|
||||
expect(messages.model.route.endpoint.baseURL).toBe(
|
||||
"https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/anthropic/v1",
|
||||
)
|
||||
expect(messages.body).toMatchObject({ model: "claude-haiku-4-5" })
|
||||
expect(chat.model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1")
|
||||
expect(chat.route).toBe("cloudflare-ai-gateway-chat")
|
||||
expect(chat.body).toMatchObject({ model: "xai/grok-4.6", stream: true })
|
||||
expect(workers.route).toBe("cloudflare-ai-gateway-chat")
|
||||
@@ -100,6 +107,54 @@ describe("Cloudflare", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("posts Claude and OpenAI models to the provider-native gateway endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const gateway = CloudflareAIGateway.configure({ accountId: "test-account", apiKey: "test-token", cacheTtl: 300 })
|
||||
const requests: Array<Request> = []
|
||||
const capture = dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie))
|
||||
return input.respond("", { status: 500 })
|
||||
}),
|
||||
)
|
||||
yield* LLM.generate(LLM.request({ model: gateway.model("anthropic/claude-sonnet-4.6"), prompt: "Hi" })).pipe(
|
||||
Effect.provide(capture),
|
||||
Effect.ignore,
|
||||
)
|
||||
yield* LLM.generate(LLM.request({ model: gateway.model("openai/gpt-5-nano"), prompt: "Hi" })).pipe(
|
||||
Effect.provide(capture),
|
||||
Effect.ignore,
|
||||
)
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual([
|
||||
"https://gateway.ai.cloudflare.com/v1/test-account/default/anthropic/v1/messages",
|
||||
"https://gateway.ai.cloudflare.com/v1/test-account/default/openai/responses",
|
||||
])
|
||||
requests.forEach((request) => {
|
||||
expect(request.headers.get("cf-aig-authorization")).toBe("Bearer test-token")
|
||||
expect(request.headers.get("authorization")).toBeNull()
|
||||
expect(request.headers.get("x-api-key")).toBeNull()
|
||||
expect(request.headers.get("cf-aig-gateway-id")).toBeNull()
|
||||
expect(request.headers.get("cf-aig-cache-ttl")).toBe("300")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends the default gateway ID to the REST API", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: CloudflareAIGateway.configure({ accountId: "test-account", apiKey: "test-token" }).model(
|
||||
"workers-ai/@cf/meta/llama-3.3-70b-instruct",
|
||||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.model.route.defaults.headers).toMatchObject({ "cf-aig-gateway-id": "default" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves reasoning details for AI Gateway continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = CloudflareAIGateway.configure({
|
||||
@@ -160,12 +215,13 @@ describe("Cloudflare", () => {
|
||||
model: CloudflareAIGateway.configure({
|
||||
baseURL: "https://gateway.proxy.test/v1",
|
||||
apiKey: "test-token",
|
||||
}).model("xai/grok-4.6"),
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.model.route.endpoint.baseURL).toBe("https://gateway.proxy.test/v1")
|
||||
expect(prepared.body).toMatchObject({ model: "anthropic/claude-sonnet-4.6" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ const cloudflareWorkers = CloudflareWorkersAI.configure({
|
||||
})
|
||||
const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
|
||||
const cloudflareAIGatewayClaude = cloudflareAIGateway.model("anthropic/claude-haiku-4.5")
|
||||
const cloudflareAIGatewayOpenAI = cloudflareAIGateway.model("openai/gpt-5-nano")
|
||||
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
|
||||
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
|
||||
const deepseek = DeepSeek.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" }).model("deepseek-chat")
|
||||
@@ -76,7 +78,7 @@ const openrouterOpus = OpenRouter.configure({
|
||||
const redactCloudflareURL = (url: string) =>
|
||||
url
|
||||
.replace(/\/client\/v4\/accounts\/[^/]+\/ai\/v1\//, "/client/v4/accounts/{account}/ai/v1/")
|
||||
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
|
||||
.replace(/\/v1\/[^/]+\/[^/]+\/(anthropic|openai)\//, "/v1/{account}/{gateway}/$1/")
|
||||
|
||||
const cloudflareOptions = {
|
||||
redact: { url: redactCloudflareURL },
|
||||
@@ -177,6 +179,25 @@ describeRecordedGoldenScenarios([
|
||||
options: cloudflareOptions,
|
||||
scenarios: [{ id: "tool-call", maxTokens: 120 }],
|
||||
},
|
||||
{
|
||||
name: "Cloudflare AI Gateway Claude Haiku 4.5",
|
||||
prefix: "cloudflare-ai-gateway",
|
||||
model: cloudflareAIGatewayClaude,
|
||||
requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
|
||||
options: { redact: { url: redactCloudflareURL, allowRequestHeaders: ["anthropic-version"] } },
|
||||
scenarios: ["text", "tool-loop"],
|
||||
},
|
||||
{
|
||||
name: "Cloudflare AI Gateway OpenAI GPT-5 Nano",
|
||||
prefix: "cloudflare-ai-gateway",
|
||||
model: cloudflareAIGatewayOpenAI,
|
||||
requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
|
||||
options: cloudflareOptions,
|
||||
scenarios: [
|
||||
{ id: "text", temperature: false, maxTokens: 2000 },
|
||||
{ id: "tool-loop", temperature: false, maxTokens: 2000, timeout: 30_000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Cloudflare Workers AI Llama 3.1 8B",
|
||||
prefix: "cloudflare-workers-ai",
|
||||
|
||||
@@ -303,7 +303,8 @@ describe("Mistral Chat", () => {
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Con" }] }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "sider" }], closed: true }] }),
|
||||
chunk({ content: [{ type: "text", text: "Answer" }] }),
|
||||
chunk({}, "stop"),
|
||||
),
|
||||
@@ -313,6 +314,11 @@ describe("Mistral Chat", () => {
|
||||
|
||||
expect(response.reasoning).toBe("Consider")
|
||||
expect(response.text).toBe("Answer")
|
||||
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata])).toEqual([
|
||||
["Con", undefined],
|
||||
["sider", undefined],
|
||||
])
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
@@ -321,8 +327,12 @@ describe("Mistral Chat", () => {
|
||||
mistral: {
|
||||
thinking: {
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
thinking: [
|
||||
{ type: "text", text: "Con" },
|
||||
{ type: "text", text: "sider" },
|
||||
],
|
||||
marker: "empty",
|
||||
closed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -337,8 +347,12 @@ describe("Mistral Chat", () => {
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
thinking: [
|
||||
{ type: "text", text: "Con" },
|
||||
{ type: "text", text: "sider" },
|
||||
],
|
||||
marker: "empty",
|
||||
closed: true,
|
||||
},
|
||||
{ type: "text", text: "Answer" },
|
||||
],
|
||||
@@ -357,6 +371,8 @@ describe("Mistral Chat", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toEqual([])
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
|
||||
@@ -89,7 +89,14 @@ for (const item of cases) {
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toMatchObject([
|
||||
{ role: "assistant", content: response.text, reasoning: response.reasoning },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
item.model.route.id === "openrouter"
|
||||
? [{ type: "text", text: response.text, cache_control: { type: "ephemeral" } }]
|
||||
: response.text,
|
||||
reasoning: response.reasoning,
|
||||
},
|
||||
])
|
||||
const replayDetails =
|
||||
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
|
||||
|
||||
@@ -4996,6 +4996,42 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the token-sharing HTTP 429 body for retry hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" } })
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } }),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit", http: { status: 429 }, body } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the token-sharing response.failed event for retry hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "response.failed",
|
||||
response: {
|
||||
id: "resp_usage_limit",
|
||||
error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit" } })
|
||||
expect(error.reason.body).toContain("subscription_sharing_usage_limit_exceeded")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces response.failed code when no nested message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -32,6 +32,125 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("places default cache breakpoints on tools, system boundaries, and the conversation tail", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{ type: "text", text: "Model details" },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [
|
||||
{ name: "read", description: "Read", inputSchema: { type: "object", properties: {} } },
|
||||
{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } },
|
||||
],
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools?.map((tool) => tool.cache_control)).toEqual([undefined, { type: "ephemeral" }])
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ text: "Base agent", cache_control: { type: "ephemeral" } },
|
||||
{ text: "Model details" },
|
||||
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
|
||||
])
|
||||
expect(prepared.body.messages[0]?.content).not.toContainEqual(
|
||||
expect.objectContaining({ text: "Model details", cache_control: expect.anything() }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("places default cache breakpoints on OpenRouter latest-model aliases", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("~anthropic/claude-sonnet-latest"),
|
||||
system: "Base agent",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "system", content: [{ text: "Base agent", cache_control: { type: "ephemeral" } }] },
|
||||
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("skips the tool breakpoint for Qwen, which caches tools with the system prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3-coder-plus"),
|
||||
system: "Base agent",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools?.[0]?.cache_control).toBeUndefined()
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "system", content: [{ text: "Base agent", cache_control: { type: "ephemeral" } }] },
|
||||
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("places the default Qwen conversation-tail breakpoint inside tool-result text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3-coder-plus"),
|
||||
messages: [
|
||||
Message.user("Call the tool"),
|
||||
Message.assistant([{ type: "tool-call", id: "call_1", name: "lookup", input: {} }]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages.at(-1)).toMatchObject({
|
||||
role: "tool",
|
||||
tool_call_id: "call_1",
|
||||
content: [{ type: "text", text: '"Done"', cache_control: { type: "ephemeral" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends no default breakpoints to upstreams that cache without them", () =>
|
||||
Effect.gen(function* () {
|
||||
const openrouter = OpenRouter.configure({ apiKey: "test-key" })
|
||||
const bodies = yield* Effect.forEach(["google/gemini-2.5-flash", "openai/gpt-5-mini"], (id) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: openrouter.model(id),
|
||||
system: "Base agent",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "Hello",
|
||||
}),
|
||||
).pipe(Effect.map((prepared) => prepared.body)),
|
||||
)
|
||||
|
||||
bodies.forEach((body) => {
|
||||
expect(body.tools?.[0]?.cache_control).toBeUndefined()
|
||||
expect(body.messages).toMatchObject([
|
||||
{ role: "system", content: "Base agent" },
|
||||
{ role: "user", content: "Hello" },
|
||||
])
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -74,6 +193,8 @@ describe("OpenRouter", () => {
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user("Call the tool"),
|
||||
Message.assistant("Unmarked reply"),
|
||||
Message.user("Call again"),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
|
||||
@@ -90,8 +211,17 @@ describe("OpenRouter", () => {
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: "Call the tool" },
|
||||
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
|
||||
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
{ role: "assistant", content: "Unmarked reply" },
|
||||
{ role: "user", content: "Call again" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Calling", cache_control: { type: "ephemeral" } }],
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [{ type: "text", text: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -118,7 +248,7 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
|
||||
it.effect("does not emit text cache markers on reasoning-only assistant messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -130,8 +260,38 @@ describe("OpenRouter", () => {
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: "Think" },
|
||||
{ role: "assistant", cache_control: { type: "ephemeral" } },
|
||||
{ role: "assistant", content: "" },
|
||||
])
|
||||
expect(prepared.body.messages[1]).not.toHaveProperty("cache_control")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("counts wrapped system-update markers once so all four default breakpoints survive", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [Message.user("Start"), Message.system("Updated instructions")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
expect(prepared.body.messages.at(-1)).toMatchObject({
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Start" },
|
||||
{
|
||||
type: "text",
|
||||
text: "<system-update>\nUpdated instructions\n</system-update>",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal, render } from "solid-js/web"
|
||||
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
|
||||
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
|
||||
import type { BrowserPaneEvent, BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
|
||||
import type { createSessionBrowser } from "../src/session/browser/model"
|
||||
import { SessionBrowserPane } from "../src/session/browser/pane"
|
||||
import { SessionBrowserPane, type SessionBrowserComment } from "../src/session/browser/pane"
|
||||
|
||||
export function mountBrowserPane() {
|
||||
const host = document.createElement("main")
|
||||
@@ -30,9 +30,15 @@ export function mountBrowserPane() {
|
||||
covered: false,
|
||||
captures: 0,
|
||||
holdCapture: false,
|
||||
picker: {} as Record<string, boolean | undefined>,
|
||||
highlights: [] as string[],
|
||||
comments: [] as SessionBrowserComment[],
|
||||
})
|
||||
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
|
||||
const held: (() => void)[] = []
|
||||
const inspectors = new Set<(event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void>()
|
||||
const emitInspect = (event: Extract<BrowserPaneEvent, { type: "inspect" }>) =>
|
||||
inspectors.forEach((listener) => listener(event))
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
|
||||
title: name,
|
||||
@@ -60,6 +66,12 @@ export function mountBrowserPane() {
|
||||
}
|
||||
return canvas.convertToBlob()
|
||||
},
|
||||
// The desktop confirms each picker change, as the page does once inspect mode is armed.
|
||||
inspect: (tabID, enabled) => {
|
||||
setStore("picker", tab.title, enabled)
|
||||
emitInspect({ type: "inspect", tabID, active: enabled })
|
||||
},
|
||||
highlight: (_tabID, ref) => setStore("highlights", (items) => [...items, ref ?? "clear"]),
|
||||
close: () => undefined,
|
||||
},
|
||||
]),
|
||||
@@ -97,7 +109,26 @@ export function mountBrowserPane() {
|
||||
}
|
||||
if (command.type === "stop") setStore("loading", false)
|
||||
},
|
||||
onInspect: (listener) => {
|
||||
inspectors.add(listener)
|
||||
return () => inspectors.delete(listener)
|
||||
},
|
||||
reveal: () => undefined,
|
||||
}
|
||||
const pick = () =>
|
||||
emitInspect({
|
||||
type: "inspect",
|
||||
tabID: browser.active().id,
|
||||
active: false,
|
||||
element: {
|
||||
ref: Browser.Ref.make("e7"),
|
||||
selector: "main > button.primary",
|
||||
label: "button.primary",
|
||||
role: "button",
|
||||
name: "Save changes",
|
||||
rect: { x: 48, y: 40, width: 160, height: 36 },
|
||||
},
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
|
||||
@@ -137,13 +168,30 @@ export function mountBrowserPane() {
|
||||
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
|
||||
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
|
||||
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
|
||||
<button onClick={pick}>Pick element</button>
|
||||
</nav>
|
||||
<p>Captures: {store.captures}</p>
|
||||
<p>Picker: {store.picker[store.session] ? "on" : "off"}</p>
|
||||
<p>Highlights: {store.highlights.join(",")}</p>
|
||||
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<Show when={store.mounted}>
|
||||
<SessionBrowserPane browser={browser} visible={store.visible} />
|
||||
<SessionBrowserPane
|
||||
browser={browser}
|
||||
visible={store.visible}
|
||||
onComment={(comment) => setStore("comments", (items) => [...items, comment])}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<ul data-testid="fixture-comments">
|
||||
<For each={store.comments}>
|
||||
{(comment) => (
|
||||
<li>
|
||||
{comment.element.label} {comment.element.ref ? `@${comment.element.ref}` : "(no ref)"}:{" "}
|
||||
{comment.comment}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={store.covered}>
|
||||
{/* Floating content portals into <body> like a menu or hover card over the page. */}
|
||||
<Portal mount={document.body}>
|
||||
|
||||
@@ -79,6 +79,81 @@ story("keeps a still of the page under floating content that covers it", async (
|
||||
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("comments on a picked element over a still of the page", async ({ page }, testInfo) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
|
||||
await picker.click()
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(root.getByText("Picker: on", { exact: true })).toBeVisible()
|
||||
|
||||
await root.getByRole("button", { name: "Pick element", exact: true }).click()
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "false")
|
||||
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(root.locator('[data-slot="browser-comment-editor"]')).toContainText("button.primary")
|
||||
// The native page hides behind its still so the editor can float over it.
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.locator("#browser-panel img")).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("comment.png") })
|
||||
|
||||
await editor.fill("Make this the primary colour")
|
||||
await editor.press("Enter")
|
||||
await expect(root.getByTestId("fixture-comments")).toHaveText("button.primary @e7: Make this the primary colour")
|
||||
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
|
||||
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("cancels the picker and a comment with Escape", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
|
||||
await picker.click()
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "true")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(root.getByText("Picker: off", { exact: true })).toBeVisible()
|
||||
|
||||
await picker.click()
|
||||
await root.getByRole("button", { name: "Pick element", exact: true }).click()
|
||||
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.press("Escape")
|
||||
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
|
||||
await expect(root.getByTestId("fixture-comments")).toHaveText("")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("keeps a comment draft but drops its ref when the page navigates", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Pick element", exact: true }).click()
|
||||
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
|
||||
await editor.fill("Still misaligned after the reload")
|
||||
await root.getByRole("button", { name: "Load current page", exact: true }).click()
|
||||
await expect(editor).toHaveValue("Still misaligned after the reload")
|
||||
await editor.press("Enter")
|
||||
await expect(root.getByTestId("fixture-comments")).toHaveText(
|
||||
"button.primary (no ref): Still misaligned after the reload",
|
||||
)
|
||||
})
|
||||
|
||||
story("keeps the comment editor and its actions inside the page", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Pick element", exact: true }).click()
|
||||
const editor = root.locator('[data-slot="browser-comment-editor"]')
|
||||
// As if the user dragged the textarea's resize handle far past the page.
|
||||
await editor.locator("textarea").evaluate((element) => {
|
||||
element.style.height = "900px"
|
||||
})
|
||||
const surface = await root.locator('[data-component="browser-comment"]').boundingBox()
|
||||
const box = await editor.boundingBox()
|
||||
expect(surface && box && box.y + box.height <= surface.y + surface.height).toBe(true)
|
||||
const submit = editor.getByRole("button", { name: "Comment", exact: true })
|
||||
await submit.scrollIntoViewIfNeeded()
|
||||
await expect(submit).toBeInViewport()
|
||||
})
|
||||
|
||||
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
|
||||
@@ -186,11 +186,17 @@ for (const scenario of scenarios) {
|
||||
const toolTitle = element
|
||||
.closest('[data-component="edit-tool"]')
|
||||
?.querySelector('[data-slot="collapsible-trigger"][data-locked]')
|
||||
const top = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
|
||||
// Grouped file headers stack below the stuck Used header.
|
||||
const group = element
|
||||
.closest('[data-component="collapsed-tool-group"]')
|
||||
?.querySelector(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
const sessionTop = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
|
||||
const top = sessionTop + (group?.getBoundingClientRect().height ?? 0)
|
||||
const rect = element.getBoundingClientRect()
|
||||
const trigger = element.querySelector("button")!
|
||||
return {
|
||||
gap: Math.abs(rect.top - top - (toolTitle?.getBoundingClientRect().height ?? 0)),
|
||||
groupGap: group ? Math.abs(group.getBoundingClientRect().top - sessionTop) : 0,
|
||||
titleGap: toolTitle ? Math.abs(toolTitle.getBoundingClientRect().top - top) : 0,
|
||||
clickable: trigger.contains(
|
||||
document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2),
|
||||
@@ -198,7 +204,7 @@ for (const scenario of scenarios) {
|
||||
}
|
||||
}),
|
||||
)
|
||||
.toEqual({ gap: 0, titleGap: 0, clickable: true })
|
||||
.toEqual({ gap: 0, groupGap: 0, titleGap: 0, clickable: true })
|
||||
await page.screenshot({ path: info.outputPath(`${file}.png`) })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import {
|
||||
assistantMessage,
|
||||
session,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
const target = "ses_0123456789abcdefghijklmnop"
|
||||
const missing = "ses_abcdefghijklmnopqrstuvwxyz"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("opens a verified session from agent prose or inline code with the keyboard", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
sessions: [session(), session({ id: target, title: "Linked session" })],
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart("prt_session_links", `Visit ${target} or \`${target}\` to see the result.`)]),
|
||||
],
|
||||
})
|
||||
const markdown = page.locator('[data-component="markdown"]').filter({ hasText: `Visit ${target}` })
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(markdown.getByRole("button", { name: target })).toHaveCount(2)
|
||||
await markdown
|
||||
.getByRole("button", { name: target })
|
||||
.filter({ has: page.locator("code") })
|
||||
.press("Enter")
|
||||
await expect(page).toHaveURL(`/server/${base64Encode(server)}/session/${target}`)
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href$="/session/${target}"]`)).toContainText("Linked session")
|
||||
})
|
||||
|
||||
test("does not navigate to an ID that is absent from the current server", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([textPart("prt_session_missing", `See ${missing}.`)])],
|
||||
})
|
||||
const markdown = page.locator('[data-component="markdown"]').filter({ hasText: `See ${missing}.` })
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
await markdown.getByRole("button", { name: missing }).click()
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/session\/ses_timeline_stability$/)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -30,6 +30,10 @@ export type ComposerControls = {
|
||||
opened: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
/** Present in sessions with a desktop browser pane. */
|
||||
browser?: {
|
||||
reveal: (tabID: string, ref?: string) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { commentContextItem, formatBrowserCommentNote, readPromptPresentation } from "./comment-note"
|
||||
|
||||
const browser = {
|
||||
type: "browser" as const,
|
||||
tabID: "tab_00000000-0000-4000-8000-000000000000",
|
||||
url: "http://localhost:5173/",
|
||||
element: { ref: "e42", selector: "#save", label: "button#save" },
|
||||
comment: "Rename this",
|
||||
}
|
||||
|
||||
describe("browser element comments", () => {
|
||||
test("read from message metadata beside file comments and skip malformed entries", () => {
|
||||
const value = readPromptPresentation({
|
||||
displayText: "hi",
|
||||
comments: [browser, { ...browser, element: { label: "button" } }, { path: "src/app.ts", comment: "Keep" }],
|
||||
})
|
||||
expect(value?.comments).toEqual([browser, { path: "src/app.ts", comment: "Keep" }])
|
||||
})
|
||||
|
||||
test("explain a selector that crosses into a shadow root", () => {
|
||||
expect(
|
||||
formatBrowserCommentNote({ ...browser, element: { ...browser.element, selector: "#card >>> div > button" } }),
|
||||
).toContain('selector "#card >>> div > button" (">>>" enters a shadow root)')
|
||||
expect(formatBrowserCommentNote(browser)).not.toContain("shadow root")
|
||||
})
|
||||
|
||||
test("leave out a selector that was too long to keep", () => {
|
||||
expect(formatBrowserCommentNote({ ...browser, element: { ...browser.element, selector: "" } })).toBe(
|
||||
'The user made the following comment regarding the "button#save" element in browser tab tab_00000000-0000-4000-8000-000000000000 at http://localhost:5173/ (browser ref @e42, usable as ref in any browser tool including browser.evaluate until the page navigates): Rename this',
|
||||
)
|
||||
})
|
||||
|
||||
test("return to the composer without their element ref", () => {
|
||||
expect(commentContextItem(browser)).toEqual({
|
||||
...browser,
|
||||
element: { selector: "#save", label: "button#save" },
|
||||
commentID: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { BrowserComment, durableBrowserElement, type ContextItem } from "./schema"
|
||||
|
||||
export type PromptComment = {
|
||||
export type PromptFileComment = {
|
||||
type?: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment: string
|
||||
preview?: string
|
||||
origin?: "review" | "file"
|
||||
}
|
||||
export type PromptComment = PromptFileComment | BrowserComment
|
||||
|
||||
const decodeBrowserComment = Schema.decodeUnknownOption(BrowserComment)
|
||||
|
||||
/** An attachment the model receives as a path on the server rather than inline bytes. */
|
||||
export type PromptAttachmentReference = {
|
||||
@@ -30,7 +36,7 @@ function selection(selection: unknown) {
|
||||
} satisfies FileSelection
|
||||
}
|
||||
|
||||
export function createCommentMetadata(input: PromptComment) {
|
||||
export function createCommentMetadata(input: PromptFileComment) {
|
||||
return {
|
||||
opencodeComment: {
|
||||
path: input.path,
|
||||
@@ -78,6 +84,7 @@ export function readPromptPresentation(value: unknown) {
|
||||
}),
|
||||
comments: comments.flatMap((item): PromptComment[] => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
if ((item as { type?: unknown }).type === "browser") return Option.toArray(decodeBrowserComment(item))
|
||||
const path = (item as { path?: unknown }).path
|
||||
const comment = (item as { comment?: unknown }).comment
|
||||
if (typeof path !== "string" || typeof comment !== "string") return []
|
||||
@@ -100,6 +107,39 @@ export function formatAttachmentReference(input: PromptAttachmentReference) {
|
||||
return `Attached file: \`${input.path}\``
|
||||
}
|
||||
|
||||
export function formatBrowserCommentNote(input: BrowserComment) {
|
||||
const element = input.element
|
||||
// Page-provided strings are quoted so they read as data, not as part of the user's request.
|
||||
const details = [
|
||||
element.role ? `role ${element.role}` : undefined,
|
||||
element.name ? `accessible name ${JSON.stringify(element.name)}` : undefined,
|
||||
element.text && element.text !== element.name ? `text ${JSON.stringify(element.text.slice(0, 80))}` : undefined,
|
||||
// A selector too long to keep is empty rather than cut into invalid syntax.
|
||||
element.selector
|
||||
? `selector ${JSON.stringify(element.selector)}${element.selector.includes(" >>> ") ? ' (">>>" enters a shadow root)' : ""}`
|
||||
: undefined,
|
||||
element.ref
|
||||
? `browser ref @${element.ref}, usable as ref in any browser tool including browser.evaluate until the page navigates`
|
||||
: undefined,
|
||||
].filter((detail) => detail !== undefined)
|
||||
return `The user made the following comment regarding the ${JSON.stringify(element.label)} element in browser tab ${input.tabID} at ${input.url}${details.length ? ` (${details.join("; ")})` : ""}: ${input.comment}`
|
||||
}
|
||||
|
||||
/** Restores a sent comment to the composer, for example after a revert or fork. */
|
||||
export function commentContextItem(comment: PromptComment): ContextItem {
|
||||
// The message may predate the desktop process, so its element ref can no longer be trusted.
|
||||
if (comment.type === "browser")
|
||||
return { ...comment, element: durableBrowserElement(comment.element), commentID: crypto.randomUUID() }
|
||||
return {
|
||||
type: "file",
|
||||
path: comment.path,
|
||||
selection: comment.selection,
|
||||
comment: comment.comment,
|
||||
preview: comment.preview,
|
||||
commentOrigin: comment.origin,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
|
||||
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
|
||||
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
|
||||
|
||||
@@ -279,6 +279,39 @@ export const MixedAttachments = {
|
||||
),
|
||||
}
|
||||
|
||||
export const BrowserElementComment = {
|
||||
render: () => (
|
||||
<ComposerStory
|
||||
prompt={text("Tidy up the settings page")}
|
||||
comments={[
|
||||
{
|
||||
type: "browser",
|
||||
key: "browser:tab_story:c=save",
|
||||
tabID: "tab_00000000-0000-4000-8000-000000000000",
|
||||
url: "http://localhost:5173/settings",
|
||||
title: "Settings",
|
||||
element: {
|
||||
ref: "e42",
|
||||
selector: "#settings > form > button.btn.primary",
|
||||
label: "button.btn.primary",
|
||||
role: "button",
|
||||
name: "Save changes",
|
||||
},
|
||||
comment: "Match the secondary button height",
|
||||
commentID: "save",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
key: "comment:src/app.tsx",
|
||||
path: "src/app.tsx",
|
||||
selection: { startLine: 12, startChar: 0, endLine: 14, endChar: 0 },
|
||||
comment: "Keep the normal flow flat",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const ModelAndVariant = { render: () => <ComposerStory prompt={text("Compare both variants")} /> }
|
||||
|
||||
export const SlashSuggestions = { render: () => <ComposerStory suggestions="command" /> }
|
||||
|
||||
@@ -533,8 +533,11 @@ export function ComposerAttachments(props: {
|
||||
>
|
||||
<CommentCard
|
||||
comment={comment.comment ?? ""}
|
||||
path={comment.path}
|
||||
selection={comment.selection}
|
||||
target={
|
||||
comment.type === "browser"
|
||||
? { type: "browser", element: comment.element.label }
|
||||
: { type: "file", path: comment.path, selection: comment.selection }
|
||||
}
|
||||
active={comment.key === props.activeCommentID}
|
||||
onClick={() => props.onCommentClick?.(comment)}
|
||||
/>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return []
|
||||
if (!comment || item.type !== "file") return []
|
||||
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
||||
const nextSelection =
|
||||
selection ??
|
||||
@@ -129,9 +129,11 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
time: item.time,
|
||||
})),
|
||||
)
|
||||
prompt.context.replaceComments(
|
||||
items.map((item) => ({
|
||||
type: "file",
|
||||
// History records file comments only; browser comments stay with the draft while it is browsed.
|
||||
prompt.context.replaceComments([
|
||||
...prompt.context.items().filter((item) => item.type === "browser"),
|
||||
...items.map((item) => ({
|
||||
type: "file" as const,
|
||||
path: item.path,
|
||||
selection: selectionFromLines(item.selection),
|
||||
comment: item.comment,
|
||||
@@ -139,7 +141,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
commentOrigin: item.origin,
|
||||
preview: item.preview,
|
||||
})),
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
const referenceDescription = (reference: ReferenceInfo) =>
|
||||
@@ -298,7 +300,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
|
||||
})),
|
||||
onContextRemove(item) {
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
if (item.type === "file" && item.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) => {
|
||||
if (attachment.type !== "image") return
|
||||
@@ -308,6 +310,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
},
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item?.type === "browser") {
|
||||
adapter.controls().session.browser?.reveal(item.tabID, item.element.ref)
|
||||
return
|
||||
}
|
||||
if (item) openComment(item, adapter.controls(), layout, files, comments)
|
||||
},
|
||||
onEditor(element) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
} from "./state"
|
||||
export type {
|
||||
AgentPart,
|
||||
BrowserContextItem,
|
||||
ContentPart,
|
||||
ContextItem,
|
||||
FileAttachmentPart,
|
||||
@@ -160,7 +161,7 @@ export const { use: useComposerState, provider: ComposerPersistenceProvider } =
|
||||
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
|
||||
updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
|
||||
session().context.updateComment(path, commentID, next),
|
||||
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
||||
replaceComments: (items: ContextItem[]) => session().context.replaceComments(items),
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
|
||||
reset: (scope?: PromptScope) => pick(scope).reset(),
|
||||
|
||||
@@ -144,6 +144,57 @@ describe("buildPromptRequest", () => {
|
||||
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
|
||||
})
|
||||
|
||||
test("sends a browser element comment as a note the browser tools can act on", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [{ type: "text", content: "tidy up", start: 0, end: 7 }],
|
||||
context: [
|
||||
{
|
||||
key: "browser:tab:c=1",
|
||||
type: "browser",
|
||||
tabID: "tab_00000000-0000-4000-8000-000000000000",
|
||||
url: "http://localhost:5173/settings",
|
||||
title: "Settings",
|
||||
element: {
|
||||
ref: "e42",
|
||||
selector: "#settings > button.primary",
|
||||
label: "button.primary",
|
||||
role: "button",
|
||||
name: "Save",
|
||||
text: "Save",
|
||||
},
|
||||
comment: "Match @src/button.css",
|
||||
commentID: "1",
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
text: "tidy up",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.text).toBe(
|
||||
'tidy up\nThe user made the following comment regarding the "button.primary" element in browser tab tab_00000000-0000-4000-8000-000000000000 at http://localhost:5173/settings (role button; accessible name "Save"; selector "#settings > button.primary"; browser ref @e42, usable as ref in any browser tool including browser.evaluate until the page navigates): Match @src/button.css',
|
||||
)
|
||||
expect(result.comments).toEqual([
|
||||
{
|
||||
type: "browser",
|
||||
tabID: "tab_00000000-0000-4000-8000-000000000000",
|
||||
url: "http://localhost:5173/settings",
|
||||
title: "Settings",
|
||||
element: {
|
||||
ref: "e42",
|
||||
selector: "#settings > button.primary",
|
||||
label: "button.primary",
|
||||
role: "button",
|
||||
name: "Save",
|
||||
text: "Save",
|
||||
},
|
||||
comment: "Match @src/button.css",
|
||||
},
|
||||
])
|
||||
// A browser comment has no file of its own; only files it mentions are attached.
|
||||
expect(result.files).toEqual([{ uri: "file:///repo/src/button.css", mime: "text/plain", name: "button.css" }])
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
|
||||
import type {
|
||||
AgentPart,
|
||||
ContextItem,
|
||||
FileAttachmentPart,
|
||||
ImageAttachmentPart,
|
||||
PathAttachmentPart,
|
||||
Prompt,
|
||||
SkillPart,
|
||||
} from "@/composer/state"
|
||||
import {
|
||||
formatAttachmentReference,
|
||||
formatBrowserCommentNote,
|
||||
formatCommentNote,
|
||||
type PromptAttachmentReference,
|
||||
type PromptComment,
|
||||
@@ -20,20 +29,9 @@ type PromptRequest = {
|
||||
attachments: PromptAttachmentReference[]
|
||||
}
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
type BuildPromptRequestInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
context: (ContextItem & { key: string })[]
|
||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||
text: string
|
||||
sessionDirectory: string
|
||||
@@ -87,7 +85,27 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
|
||||
const used = new Set(files.map((file) => file.uri))
|
||||
const comments: PromptComment[] = []
|
||||
const mentioned = (comment: string) =>
|
||||
parseCommentMentions(comment).flatMap((path) => {
|
||||
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(uri)) return []
|
||||
used.add(uri)
|
||||
return [{ uri, mime: "text/plain", name: getFilename(path) }]
|
||||
})
|
||||
const context = input.context.flatMap((item) => {
|
||||
if (item.type === "browser") {
|
||||
const comment = item.comment.trim()
|
||||
if (!comment) return []
|
||||
comments.push({
|
||||
type: "browser",
|
||||
tabID: item.tabID,
|
||||
url: item.url,
|
||||
...(item.title ? { title: item.title } : {}),
|
||||
element: { ...item.element },
|
||||
comment,
|
||||
})
|
||||
return mentioned(comment)
|
||||
}
|
||||
const path = absolute(input.sessionDirectory, item.path)
|
||||
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
||||
const comment = item.comment?.trim()
|
||||
@@ -104,13 +122,7 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
preview: item.preview,
|
||||
origin: item.commentOrigin,
|
||||
})
|
||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(uri)) return []
|
||||
used.add(uri)
|
||||
return [{ uri, mime: "text/plain", name: getFilename(path) }]
|
||||
})
|
||||
return [file, ...mentions]
|
||||
return [file, ...mentioned(comment)]
|
||||
})
|
||||
|
||||
const inline = input.images.map((attachment) => ({
|
||||
@@ -127,7 +139,9 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
|
||||
text: [
|
||||
...(input.text.trim() ? [input.text] : []),
|
||||
...attachments.map(formatAttachmentReference),
|
||||
...comments.map(formatCommentNote),
|
||||
...comments.map((comment) =>
|
||||
comment.type === "browser" ? formatBrowserCommentNote(comment) : formatCommentNote(comment),
|
||||
),
|
||||
].join("\n"),
|
||||
displayText: input.text,
|
||||
files: [...files, ...context, ...inline],
|
||||
|
||||
@@ -53,6 +53,28 @@ describe("composer persistence schemas", () => {
|
||||
expect(decode({}).prompt).toEqual(DEFAULT_PROMPT)
|
||||
})
|
||||
|
||||
test("restores browser element comments without the process-bound element ref", () => {
|
||||
const decode = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)
|
||||
const browser = {
|
||||
type: "browser" as const,
|
||||
tabID: "tab_00000000-0000-4000-8000-000000000000",
|
||||
url: "http://localhost:5173/",
|
||||
element: { ref: "e42", selector: "#save", label: "button#save", role: 42 },
|
||||
comment: "Rename this",
|
||||
commentID: "note",
|
||||
}
|
||||
const value = decode({ context: { items: [browser, { ...browser, url: null }] } })
|
||||
expect(value.context.items).toEqual([
|
||||
{
|
||||
...browser,
|
||||
element: { selector: "#save", label: "button#save" },
|
||||
key: "browser:tab_00000000-0000-4000-8000-000000000000:c=note",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("drops invalid parts without losing valid mentions or optional field recovery", () => {
|
||||
const value = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
|
||||
@@ -134,9 +134,49 @@ export const FileContextItem = Persistence.struct({
|
||||
preview: Persistence.optional(Schema.String),
|
||||
})
|
||||
export type FileContextItem = typeof FileContextItem.Type
|
||||
export type ContextItem = FileContextItem
|
||||
|
||||
/** An element the user picked in a desktop browser tab. The ref is valid until the page navigates. */
|
||||
export const BrowserElement = Persistence.struct({
|
||||
ref: Persistence.optional(Schema.String),
|
||||
selector: Schema.String,
|
||||
label: Schema.String,
|
||||
role: Persistence.optional(Schema.String),
|
||||
name: Persistence.optional(Schema.String),
|
||||
text: Persistence.optional(Schema.String),
|
||||
})
|
||||
export type BrowserElement = typeof BrowserElement.Type
|
||||
|
||||
/**
|
||||
* A ref names an element only inside the desktop process that picked it; a later process can hand
|
||||
* the same ref to another element. Anything that may outlive the pick keeps the description only.
|
||||
*/
|
||||
export function durableBrowserElement(element: BrowserElement): BrowserElement {
|
||||
return {
|
||||
selector: element.selector,
|
||||
label: element.label,
|
||||
...(element.role ? { role: element.role } : {}),
|
||||
...(element.name ? { name: element.name } : {}),
|
||||
...(element.text ? { text: element.text } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const BrowserCommentFields = {
|
||||
type: Schema.Literal("browser"),
|
||||
tabID: Schema.String,
|
||||
url: Schema.String,
|
||||
title: Persistence.optional(Schema.String),
|
||||
element: BrowserElement,
|
||||
comment: Schema.String,
|
||||
}
|
||||
/** A comment on a browser element as sent in message metadata. */
|
||||
export const BrowserComment = Persistence.struct(BrowserCommentFields)
|
||||
export type BrowserComment = typeof BrowserComment.Type
|
||||
export const BrowserContextItem = Persistence.struct({ ...BrowserCommentFields, commentID: Schema.String })
|
||||
export type BrowserContextItem = typeof BrowserContextItem.Type
|
||||
export type ContextItem = FileContextItem | BrowserContextItem
|
||||
|
||||
export function contextItemKey(item: ContextItem) {
|
||||
if (item.type === "browser") return `browser:${item.tabID}:c=${item.commentID}`
|
||||
const key = `${item.type}:${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
||||
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||
const comment = item.comment?.trim()
|
||||
@@ -145,12 +185,27 @@ export function contextItemKey(item: ContextItem) {
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
const ContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persistence.optional(Schema.String) }).pipe(
|
||||
const FileContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persistence.optional(Schema.String) }).pipe(
|
||||
Schema.decodeTo(Persistence.struct({ ...FileContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
|
||||
decode: SchemaGetter.transform((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
encode: SchemaGetter.transform((item) => item),
|
||||
}),
|
||||
)
|
||||
const BrowserContextEntry = Schema.Struct({
|
||||
...BrowserContextItem.fields,
|
||||
key: Persistence.optional(Schema.String),
|
||||
}).pipe(
|
||||
Schema.decodeTo(Persistence.struct({ ...BrowserContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
|
||||
// A stored draft can outlive the desktop process that picked the element.
|
||||
decode: SchemaGetter.transform((item) => ({
|
||||
...item,
|
||||
element: durableBrowserElement(item.element),
|
||||
key: contextItemKey(item),
|
||||
})),
|
||||
encode: SchemaGetter.transform((item) => item),
|
||||
}),
|
||||
)
|
||||
const ContextEntry = Schema.Union([FileContextEntry, BrowserContextEntry])
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } fro
|
||||
import { useComposerState } from "./persistence"
|
||||
import { useConfiguredModel } from "@/providers/models/configured"
|
||||
|
||||
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
|
||||
export function createComposerControls(input: {
|
||||
sessionKey: Accessor<string>
|
||||
model?: ModelSelection
|
||||
browser?: ComposerControls["session"]["browser"]
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
@@ -40,6 +44,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
|
||||
session: {
|
||||
tabs: layout.tabs(input.sessionKey),
|
||||
reviewPanel: view.reviewPanel,
|
||||
browser: input.browser,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
export { DEFAULT_PROMPT } from "./schema"
|
||||
export type {
|
||||
AgentPart,
|
||||
BrowserComment,
|
||||
BrowserContextItem,
|
||||
BrowserElement,
|
||||
ComposerStore,
|
||||
ContentPart,
|
||||
ContextItem,
|
||||
@@ -38,7 +41,7 @@ type InitialPrompt = {
|
||||
}
|
||||
|
||||
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
return !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
|
||||
@@ -137,7 +140,7 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
|
||||
)
|
||||
clearRetry()
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
replaceComments(items: ContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { PromptComment } from "./comment-note"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import type { ImageAttachmentPart, Prompt } from "./state"
|
||||
import { clonePrompt, promptLength } from "./prompt-parts"
|
||||
@@ -73,10 +74,13 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const submission = createComposerSubmission({
|
||||
target: input.adapter.state,
|
||||
prompt,
|
||||
context: input.adapter.state.context.items().map((item) => ({
|
||||
...item,
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
context: input.adapter.state.context
|
||||
.items()
|
||||
.map((item) =>
|
||||
item.type === "browser"
|
||||
? { ...item, element: { ...item.element } }
|
||||
: { ...item, selection: item.selection ? { ...item.selection } : undefined },
|
||||
),
|
||||
})
|
||||
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
|
||||
if (!read) {
|
||||
@@ -195,19 +199,30 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
|
||||
attachments: value.prompt.flatMap((part) =>
|
||||
part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [],
|
||||
),
|
||||
comments: value.context.flatMap((item) =>
|
||||
item.comment?.trim()
|
||||
? [
|
||||
{
|
||||
path: item.path,
|
||||
comment: item.comment.trim(),
|
||||
...(item.selection ? { selection: { ...item.selection } } : {}),
|
||||
...(item.preview !== undefined ? { preview: item.preview } : {}),
|
||||
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
comments: value.context.flatMap((item): PromptComment[] => {
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return []
|
||||
if (item.type === "browser")
|
||||
return [
|
||||
{
|
||||
type: "browser",
|
||||
tabID: item.tabID,
|
||||
url: item.url,
|
||||
...(item.title ? { title: item.title } : {}),
|
||||
element: { ...item.element },
|
||||
comment,
|
||||
},
|
||||
]
|
||||
return [
|
||||
{
|
||||
path: item.path,
|
||||
comment,
|
||||
...(item.selection ? { selection: { ...item.selection } } : {}),
|
||||
...(item.preview !== undefined ? { preview: item.preview } : {}),
|
||||
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
|
||||
},
|
||||
]
|
||||
}),
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
...value.selection.model,
|
||||
@@ -287,15 +302,27 @@ function restoreSubmission(
|
||||
restored.target.context.replaceComments(
|
||||
restored.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.map((item) => ({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})),
|
||||
.map((item) =>
|
||||
item.type === "browser"
|
||||
? {
|
||||
type: "browser",
|
||||
tabID: item.tabID,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
element: item.element,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
}
|
||||
: {
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
},
|
||||
),
|
||||
)
|
||||
// A recovered follow-up changes the payload, so it must use a new admission ID.
|
||||
if (value.mode === "normal" && restored.prompt === submission.prompt) {
|
||||
|
||||
@@ -5,6 +5,7 @@ export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./run
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export type {
|
||||
BrowserPaneCommand,
|
||||
BrowserPaneElement,
|
||||
BrowserPaneEndpoint,
|
||||
BrowserPaneEvent,
|
||||
BrowserPaneLayout,
|
||||
|
||||
@@ -445,6 +445,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes browser-comment-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes browser-comment-editor-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="browser-comment"] {
|
||||
animation: browser-comment-enter 160ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
/* Dim the frozen page around the picked element, whose DevTools highlight stays in the still. */
|
||||
[data-component="browser-comment"] [data-slot="browser-comment-spotlight"] {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--v2-border-border-focus),
|
||||
0 0 0 100vmax color-mix(in srgb, var(--v2-overlay-simple-overlay-scrim) 50%, transparent);
|
||||
}
|
||||
|
||||
[data-component="browser-comment"] [data-slot="browser-comment-editor"] {
|
||||
animation: browser-comment-editor-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="browser-comment"],
|
||||
[data-component="browser-comment"] [data-slot="browser-comment-editor"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"] {
|
||||
timeline-scope: --home-projects-scroll;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
||||
return createMemo<PromptProjectControls>(() => ({
|
||||
available: projects(),
|
||||
directory: location().directory,
|
||||
projectID: location().current?.project.id,
|
||||
server: servers.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import type { PromptProjectControls } from "./selector"
|
||||
|
||||
mock.module("@/runtime/i18n/language", () => ({ useLanguage: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
const { createPromptProjectController } = await import("./selector")
|
||||
|
||||
describe("new session project selection", () => {
|
||||
test("shows the current project's appearance before its worktree inventory loads", () => {
|
||||
const generic = { id: "other", worktree: "/other" }
|
||||
const current = {
|
||||
id: "current",
|
||||
worktree: "/repo",
|
||||
name: "My custom name",
|
||||
icon: { override: "data:image/png;base64,AAAA" },
|
||||
}
|
||||
const controls: PromptProjectControls = {
|
||||
available: [generic, current],
|
||||
directory: "/repo/.opencode/worktree/feature",
|
||||
projectID: "current",
|
||||
select() {},
|
||||
add() {},
|
||||
}
|
||||
|
||||
createRoot((dispose) => {
|
||||
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
|
||||
expect(controller.selected()).toEqual(current)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("does not select a matching project ID from another server", () => {
|
||||
const remote = { id: "shared", worktree: "/remote", server: { key: "remote", name: "Remote" } }
|
||||
const local = { id: "shared", worktree: "/local", server: { key: "local", name: "Local" } }
|
||||
const controls: PromptProjectControls = {
|
||||
available: [remote, local],
|
||||
directory: "/local/feature",
|
||||
projectID: "shared",
|
||||
server: "local",
|
||||
select() {},
|
||||
add() {},
|
||||
}
|
||||
|
||||
createRoot((dispose) => {
|
||||
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
|
||||
expect(controller.selected()).toEqual(local)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,7 @@ export type PromptProject = {
|
||||
export type PromptProjectControls = {
|
||||
available: PromptProject[]
|
||||
directory: string
|
||||
projectID?: string
|
||||
server?: string
|
||||
select: (worktree: string, server?: string) => void
|
||||
add: (title: string, server?: string) => void
|
||||
@@ -56,14 +57,15 @@ export function createPromptProjectController(input: {
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
|
||||
const current = () => {
|
||||
const key = pathKey(input.controls().directory)
|
||||
return input
|
||||
.controls()
|
||||
.available.find(
|
||||
const controls = input.controls()
|
||||
const key = pathKey(controls.directory)
|
||||
const projects = controls.available.filter((project) => !project.server || project.server.key === controls.server)
|
||||
return (
|
||||
projects.find(
|
||||
(project) =>
|
||||
(!project.server || project.server.key === input.controls().server) &&
|
||||
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
||||
)
|
||||
pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
) ?? projects.find((project) => controls.projectID && project.id === controls.projectID)
|
||||
)
|
||||
}
|
||||
const selected = () => current() ?? input.controls().available[0]
|
||||
const projects = () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogChatGPTUsageLimit } from "./chatgpt-usage-limit"
|
||||
|
||||
function UsageLimitStory() {
|
||||
const dialog = useDialog()
|
||||
const open = () => dialog.show(() => <DialogChatGPTUsageLimit />)
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="neutral" onClick={open}>
|
||||
Open ChatGPT usage limit
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Connect Provider",
|
||||
id: "app-dialog-chatgpt-usage-limit",
|
||||
}
|
||||
|
||||
export const ChatGPTUsageLimit = { render: () => <UsageLimitStory /> }
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogTitle } from "@opencode/ui/dialog"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
|
||||
export function DialogChatGPTUsageLimit() {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),390px)] !rounded-xl">
|
||||
<div class="flex w-full flex-col items-center px-8 pb-8 pt-9 text-center [font-family:var(--v2-font-family-sans)]">
|
||||
<ProviderIcon id="openai" class="!size-12 text-v2-icon-icon-base" aria-hidden="true" />
|
||||
<div class="mt-6 max-w-[270px] text-[20px] font-[530] leading-7 tracking-[-0.3px] text-v2-text-text-base">
|
||||
<DialogTitle>
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptUsageLimit.title")}</bdi>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<p class="mt-3 text-[13px] leading-5 text-v2-text-text-muted">
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptUsageLimit.description")}</bdi>
|
||||
</p>
|
||||
<Button
|
||||
variant="contrast"
|
||||
size="large"
|
||||
class="mt-8 w-full"
|
||||
autofocus
|
||||
onClick={() => {
|
||||
platform.openExternal("https://chatgpt.com/settings/usage")
|
||||
dialog.close()
|
||||
}}
|
||||
>
|
||||
{language.t("provider.connect.chatgptUsageLimit.manage")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="mt-2 w-full"
|
||||
onClick={() => dialog.close()}
|
||||
>
|
||||
{language.t("provider.connect.chatgptUsageLimit.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
|
||||
|
||||
function WelcomeStory() {
|
||||
const dialog = useDialog()
|
||||
const open = () => dialog.show(() => <DialogChatGPTPlanWelcome />)
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="neutral" onClick={open}>
|
||||
Open ChatGPT plan welcome
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Connect Provider",
|
||||
id: "app-dialog-chatgpt-welcome",
|
||||
}
|
||||
|
||||
export const ChatGPTPlanWelcome = { render: () => <WelcomeStory /> }
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogTitle } from "@opencode/ui/dialog"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
|
||||
export function DialogChatGPTPlanWelcome() {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),390px)] !rounded-xl">
|
||||
<div class="flex w-full flex-col items-center px-8 pb-8 pt-9 text-center [font-family:var(--v2-font-family-sans)]">
|
||||
<ProviderIcon id="openai" class="!size-12 text-v2-icon-icon-base" aria-hidden="true" />
|
||||
<div class="mt-6 max-w-[270px] text-[20px] font-[530] leading-7 tracking-[-0.3px] text-v2-text-text-base">
|
||||
<DialogTitle>
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.title")}</bdi>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<p class="mt-3 text-[13px] leading-5 text-v2-text-text-muted">
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.description")}</bdi>
|
||||
</p>
|
||||
<ExternalLink
|
||||
href="https://chatgpt.com/settings/usage"
|
||||
dir="auto"
|
||||
class="mt-1 rounded-sm text-[13px] leading-5 text-v2-text-text-muted underline-offset-2 hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
|
||||
>
|
||||
{language.t("provider.connect.chatgptWelcome.usage")}
|
||||
</ExternalLink>
|
||||
<Button variant="contrast" size="large" class="mt-8 w-full" autofocus onClick={() => dialog.close()}>
|
||||
{language.t("provider.connect.chatgptWelcome.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,9 @@ import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
CONSOLE_INTEGRATION,
|
||||
CONSOLE_PROVIDERS,
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
type ProviderConnectMethod,
|
||||
} from "./controller"
|
||||
import { ConsoleAuthorization } from "./console"
|
||||
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
|
||||
import { authServerName, RemoteAuthNotice } from "./remote"
|
||||
import "./models.css"
|
||||
|
||||
@@ -81,8 +85,15 @@ export const DialogConnectProvider: Component<{
|
||||
completed: false,
|
||||
modelProvider: undefined as { id: string; name: string } | undefined,
|
||||
authorization: false,
|
||||
chatgptWelcome: false,
|
||||
})
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [welcome, setWelcome, , welcomeReady] = persisted(
|
||||
Persist.global("chatgpt-plan-welcome.v1"),
|
||||
Persistence.struct({ seen: Schema.Boolean }),
|
||||
{ seen: false },
|
||||
)
|
||||
const reset = controller.reset
|
||||
const back = { current: reset }
|
||||
const consoleSelected = () => CONSOLE_PROVIDERS.has(controller.selected() ?? "")
|
||||
@@ -112,7 +123,11 @@ export const DialogConnectProvider: Component<{
|
||||
setBack={(handler) => (back.current = handler)}
|
||||
selection={props.selection}
|
||||
onDone={props.onDone ? () => setState("completed", true) : undefined}
|
||||
onConnected={() => props.onConnected?.(provider)}
|
||||
onConnected={(methodID) => {
|
||||
props.onConnected?.(provider)
|
||||
if (provider === "openai" && methodID === "chatgpt-token-sharing")
|
||||
setState("chatgptWelcome", true)
|
||||
}}
|
||||
onFirstConnection={(provider) => setState("modelProvider", provider)}
|
||||
onAuthorization={(authorization) => setState("authorization", authorization)}
|
||||
/>
|
||||
@@ -136,9 +151,16 @@ export const DialogConnectProvider: Component<{
|
||||
: "!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||
}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!state.completed || !props.onDone) return
|
||||
event.preventDefault()
|
||||
props.onDone()
|
||||
if (state.completed && props.onDone) {
|
||||
event.preventDefault()
|
||||
props.onDone()
|
||||
}
|
||||
if (!state.chatgptWelcome) return
|
||||
void Promise.resolve(welcomeReady.promise).then(() => {
|
||||
if (welcome.seen) return
|
||||
setWelcome("seen", true)
|
||||
void dialog.show(() => <DialogChatGPTPlanWelcome />)
|
||||
})
|
||||
}}
|
||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||
classList={{
|
||||
@@ -354,7 +376,7 @@ function ProviderConnection(props: {
|
||||
setBack: (handler: () => void) => void
|
||||
selection?: ModelSelection
|
||||
onDone?: () => void
|
||||
onConnected?: () => void
|
||||
onConnected?: (methodID?: string) => void
|
||||
onFirstConnection: (provider: { id: string; name: string }) => void
|
||||
onAuthorization: (authorization: boolean) => void
|
||||
}) {
|
||||
@@ -401,7 +423,8 @@ function ProviderConnection(props: {
|
||||
prepare: isConsole ? prepareConsoleCatalog : undefined,
|
||||
pollInterval: isConsole ? 500 : undefined,
|
||||
onComplete: () => {
|
||||
props.onConnected?.()
|
||||
const method = controller.currentMethod()
|
||||
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
|
||||
// The picker only lists the newest model per family by default, which hides most of
|
||||
// what a new connection just unlocked. Show everything the connected integration offers.
|
||||
global.models.show(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { createSignal } from "solid-js"
|
||||
import type { ModelSelection } from "./selection"
|
||||
import { ModelSelectorPopoverView } from "./select-dialog"
|
||||
|
||||
const chatgpt = {
|
||||
id: "gpt-5.6-sol",
|
||||
providerID: "openai",
|
||||
api: { id: "gpt-5.6-sol", url: "https://api.openai.com/v1", npm: "@opencode/ai/providers/openai" },
|
||||
name: "GPT-5.6 Sol",
|
||||
family: "gpt",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: true,
|
||||
},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 400_000, output: 64_000 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-09-01",
|
||||
variants: {},
|
||||
provider: {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {},
|
||||
},
|
||||
latest: true,
|
||||
} satisfies NonNullable<ReturnType<ModelSelection["current"]>>
|
||||
|
||||
const models = [
|
||||
chatgpt,
|
||||
{
|
||||
...chatgpt,
|
||||
id: "gpt-5.6-terra",
|
||||
api: { ...chatgpt.api, id: "gpt-5.6-terra" },
|
||||
name: "GPT-5.6 Terra",
|
||||
latest: false,
|
||||
},
|
||||
]
|
||||
|
||||
function SelectorStory(props: { plan: boolean }) {
|
||||
const [current, setCurrent] = createSignal(models[0].id)
|
||||
return (
|
||||
<div class="flex min-h-[280px] items-end justify-center">
|
||||
<ModelSelectorPopoverView
|
||||
trigger={(trigger) => (
|
||||
<Button {...trigger} variant="ghost-muted">
|
||||
{models.find((model) => model.id === current())?.name}
|
||||
</Button>
|
||||
)}
|
||||
models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))}
|
||||
groups={(items) => [{ category: "openai", items }]}
|
||||
current={`openai:${current()}`}
|
||||
chatgptPlan={props.plan}
|
||||
select={(item) => setCurrent(item.id)}
|
||||
onManage={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Model Selector",
|
||||
id: "app-dialog-model-selector",
|
||||
}
|
||||
|
||||
export const ChatGPTPlan = { render: () => <SelectorStory plan /> }
|
||||
export const ApiKey = { render: () => <SelectorStory plan={false} /> }
|
||||
@@ -15,13 +15,16 @@ import { Menu } from "@opencode/ui/menu"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { ModelTooltip } from "./tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
|
||||
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./search"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelSections } from "@/providers/models/provider-group"
|
||||
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
@@ -218,11 +221,21 @@ export function ModelSelectorPopover(props: {
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
const location = useWorkspaceLocation()
|
||||
const controller = createModelSelectorController({
|
||||
model: props.model,
|
||||
provider: () => props.provider,
|
||||
onSelect: () => props.onClose?.(),
|
||||
})
|
||||
const chatgptPlan = () => {
|
||||
if (!controller.current()?.startsWith("openai:")) return false
|
||||
const connection = data.location.integration
|
||||
.list(location().ref)
|
||||
?.find((integration) => integration.id === "openai")
|
||||
?.connections[0]
|
||||
return connection?.type === "credential" && connection.method === "oauth"
|
||||
}
|
||||
|
||||
return (
|
||||
<ModelSelectorPopoverView
|
||||
@@ -230,6 +243,7 @@ export function ModelSelectorPopover(props: {
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current()}
|
||||
chatgptPlan={chatgptPlan()}
|
||||
select={controller.select}
|
||||
onManage={() => {
|
||||
void import("./manage").then((module) => {
|
||||
@@ -281,11 +295,12 @@ function createModelSelectorController(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function ModelSelectorPopoverView(props: {
|
||||
export function ModelSelectorPopoverView(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: string | undefined
|
||||
chatgptPlan?: boolean
|
||||
select: (item: ModelItem) => void
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
@@ -368,7 +383,8 @@ function ModelSelectorPopoverView(props: {
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
ref={(element: HTMLDivElement) => (contentRef = element)}
|
||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
class="w-[284px] max-w-[calc(100vw-16px)] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
classList={{ "!w-[320px]": props.chatgptPlan }}
|
||||
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||
onFocusOutside={dismiss.preventTriggerRestore}
|
||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||
@@ -505,6 +521,20 @@ function ModelSelectorPopoverView(props: {
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<Show when={props.chatgptPlan}>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex min-h-10 items-center gap-2 px-3 py-2 text-[13px] leading-5 text-v2-text-text-base">
|
||||
<ProviderModelIcon provider={{ id: "openai", name: "OpenAI" }} class="shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.chatgptPlan")}</span>
|
||||
<ExternalLink
|
||||
href="https://chatgpt.com/settings/usage"
|
||||
class="flex shrink-0 items-center gap-1 rounded-sm text-v2-text-text-muted no-underline hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
|
||||
>
|
||||
{language.t("dialog.model.chatgptManageUsage")}
|
||||
<Icon name="arrow-up-right" size="small" />
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
|
||||
@@ -111,6 +111,7 @@ export const dict = {
|
||||
"command.file.open": "Open file",
|
||||
"command.browser.open": "Open browser",
|
||||
"command.browser.reload": "Reload browser page",
|
||||
"command.browser.inspect": "Select an element in the browser page",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
@@ -186,6 +187,8 @@ export const dict = {
|
||||
"dialog.model.empty": "No model results",
|
||||
"dialog.model.manage": "Manage models",
|
||||
"dialog.model.manage.description": "Customize which models appear in the model selector.",
|
||||
"dialog.model.chatgptPlan": "Using ChatGPT plan",
|
||||
"dialog.model.chatgptManageUsage": "Manage usage",
|
||||
"dialog.model.manage.provider.toggle": "Toggle all {{provider}} models",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
|
||||
@@ -217,7 +220,15 @@ export const dict = {
|
||||
"provider.connect.models.title": "Connected to {{provider}}",
|
||||
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
|
||||
"provider.connect.models.available": "Available models",
|
||||
"provider.connect.chatgptWelcome.title": "ChatGPT connected",
|
||||
"provider.connect.chatgptWelcome.description": "Eligible requests in OpenCode can use your ChatGPT plan.",
|
||||
"provider.connect.chatgptWelcome.usage": "Manage usage in ChatGPT settings",
|
||||
"provider.connect.chatgptWelcome.confirm": "Got it",
|
||||
"provider.connect.models.list": "Models available from {{provider}}",
|
||||
"provider.connect.chatgptUsageLimit.title": "ChatGPT usage limit reached",
|
||||
"provider.connect.chatgptUsageLimit.description": "Check your usage and limits in ChatGPT settings.",
|
||||
"provider.connect.chatgptUsageLimit.manage": "Manage usage",
|
||||
"provider.connect.chatgptUsageLimit.close": "Close",
|
||||
"provider.connect.console.refreshFailed":
|
||||
"Your account is connected, but we couldn't load your models. Try again to refresh them.",
|
||||
"provider.connect.console.connected": "OpenCode connected",
|
||||
@@ -1012,6 +1023,9 @@ export const dict = {
|
||||
"session.browser.unsupported": "This desktop app does not support the browser pane.",
|
||||
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
|
||||
"session.browser.address.placeholder": "Enter URL",
|
||||
"session.browser.inspect": "Select an element to comment on",
|
||||
"session.browser.inspect.active": "Click an element in the page to comment on it. Press Escape to cancel.",
|
||||
"session.browser.inspect.pageShortcut": "While the page has focus",
|
||||
"session.browser.empty.title": "Enter URL",
|
||||
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
|
||||
"session.browser.failed.title": "URL can't be reached",
|
||||
|
||||
@@ -17,16 +17,32 @@ export type BrowserPaneLayout = {
|
||||
|
||||
export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
/** An element the user picked in the page. The ref stays valid for browser tools until the page navigates. */
|
||||
export type BrowserPaneElement = {
|
||||
ref: Browser.Ref
|
||||
selector: string
|
||||
label: string
|
||||
role?: string
|
||||
name?: string
|
||||
text?: string
|
||||
/** Border box in the native view's DIPs. */
|
||||
rect: { x: number; y: number; width: number; height: number }
|
||||
}
|
||||
export type BrowserPaneEvent =
|
||||
| { type: "focus"; tabID: Browser.TabID }
|
||||
| { type: "preview"; path: string }
|
||||
| { type: "state"; state: BrowserPaneState; error?: string }
|
||||
| { type: "inspect"; tabID: Browser.TabID; active: boolean; element?: BrowserPaneElement }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
/** Captures the shown page, or resolves null when nothing is on screen. */
|
||||
capture(tabID: Browser.TabID): Promise<Blob | null>
|
||||
/** Starts or stops the page's element picker; the page reports picks and exits as inspect events. */
|
||||
inspect(tabID: Browser.TabID, enabled: boolean): void
|
||||
/** Highlights a picked element briefly, or clears any highlight when ref is omitted. */
|
||||
highlight(tabID: Browser.TabID, ref?: Browser.Ref): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, r
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import type { BrowserPaneCommand, BrowserPaneEvent } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { useServer } from "@/runtime/server/current"
|
||||
import type { SessionStateKey } from "@/runtime/server/scope"
|
||||
@@ -13,6 +14,7 @@ import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
|
||||
|
||||
type Server = ReturnType<typeof useServer>
|
||||
type InspectEvent = Extract<BrowserPaneEvent, { type: "inspect" }>
|
||||
|
||||
export type BrowserAttachment = BrowserConnectionState
|
||||
|
||||
@@ -42,6 +44,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const preview = new Map<string, Set<(path: string) => void>>()
|
||||
const inspect = new Map<string, Set<(event: InspectEvent) => void>>()
|
||||
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
|
||||
const enabled = createMemo(() => !!platform.browserPane)
|
||||
const close = (id: string) => {
|
||||
@@ -110,6 +113,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
})
|
||||
},
|
||||
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
|
||||
inspect: (event) => inspect.get(id)?.forEach((listener) => listener(event)),
|
||||
change: (state) => {
|
||||
if (state.error === "browser.pane.unsupported") {
|
||||
setUnsupported(server.key, true)
|
||||
@@ -152,20 +156,34 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
},
|
||||
/** Agent requests to show a file in this session's Review pane. */
|
||||
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
|
||||
const id = key(server, sessionID)
|
||||
const listeners = preview.get(id) ?? new Set()
|
||||
listeners.add(listener)
|
||||
preview.set(id, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (!listeners.size) preview.delete(id)
|
||||
}
|
||||
return subscribe(preview, key(server, sessionID), listener)
|
||||
},
|
||||
/** The page's element picker starting, stopping, or picking an element. */
|
||||
onInspect(server: Server, sessionID: string, listener: (event: InspectEvent) => void) {
|
||||
return subscribe(inspect, key(server, sessionID), listener)
|
||||
},
|
||||
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
|
||||
const connection = live.get(key(server, sessionID))?.connection
|
||||
if (!connection) return Promise.reject(new Error("browser.pane.unavailable"))
|
||||
return connection.command(command)
|
||||
},
|
||||
highlight(server: Server, sessionID: string, tabID: Browser.TabID, ref?: Browser.Ref) {
|
||||
live.get(key(server, sessionID))?.connection.highlight(tabID, ref)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function subscribe<Value>(
|
||||
listeners: Map<string, Set<(value: Value) => void>>,
|
||||
id: string,
|
||||
listener: (value: Value) => void,
|
||||
) {
|
||||
const set = listeners.get(id) ?? new Set()
|
||||
set.add(listener)
|
||||
listeners.set(id, set)
|
||||
return () => {
|
||||
set.delete(listener)
|
||||
if (!set.size) listeners.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,14 @@ function fixture() {
|
||||
}[] = []
|
||||
const endpoint = { url: "http://localhost:4096" }
|
||||
const previews: string[] = []
|
||||
const inspections: Extract<BrowserPaneEvent, { type: "inspect" }>[] = []
|
||||
const highlights: (Browser.Ref | undefined)[] = []
|
||||
const connection = createBrowserConnection({
|
||||
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
|
||||
change: (state) => states.push(state),
|
||||
focus: () => {},
|
||||
preview: (path) => previews.push(path),
|
||||
inspect: (event) => inspections.push(event),
|
||||
pane: {
|
||||
register(target, emit) {
|
||||
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
|
||||
@@ -46,6 +49,10 @@ function fixture() {
|
||||
async capture() {
|
||||
return null
|
||||
},
|
||||
inspect() {},
|
||||
highlight(_tabID, ref) {
|
||||
highlights.push(ref)
|
||||
},
|
||||
close() {
|
||||
call.closed = true
|
||||
},
|
||||
@@ -55,9 +62,30 @@ function fixture() {
|
||||
})
|
||||
connection.wake()
|
||||
calls[0].emit({ type: "state", state: browser })
|
||||
return { connection, calls, states, endpoint, previews }
|
||||
return { connection, calls, states, endpoint, previews, inspections, highlights }
|
||||
}
|
||||
|
||||
test("picked elements reach the session and highlights reach the page without touching connection state", () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
const before = app.states.length
|
||||
const element = {
|
||||
ref: Browser.Ref.make("e4"),
|
||||
selector: "#save",
|
||||
label: "button#save",
|
||||
rect: { x: 1, y: 2, width: 3, height: 4 },
|
||||
}
|
||||
app.calls[0].emit({ type: "inspect", tabID, active: false, element })
|
||||
expect(app.inspections).toEqual([{ type: "inspect", tabID, active: false, element }])
|
||||
app.connection.highlight(tabID, element.ref)
|
||||
app.connection.highlight(tabID)
|
||||
expect(app.highlights).toEqual([element.ref, undefined])
|
||||
expect(app.states).toHaveLength(before)
|
||||
} finally {
|
||||
app.connection.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("preview requests reach the session without touching connection state", () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BrowserPaneEvent,
|
||||
BrowserPanePlatform,
|
||||
BrowserPaneRegistration,
|
||||
BrowserPaneState,
|
||||
@@ -20,6 +21,7 @@ export function createBrowserConnection(input: {
|
||||
change: (state: BrowserConnectionState) => void
|
||||
focus: (tabID: Browser.TabID) => void
|
||||
preview: (path: string) => void
|
||||
inspect: (event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void
|
||||
}) {
|
||||
const state: BrowserConnectionState = { browser: null, suspended: false }
|
||||
let disposed = false
|
||||
@@ -35,6 +37,7 @@ export function createBrowserConnection(input: {
|
||||
if (disposed || state.registration !== registration) return
|
||||
if (event.type === "focus") return input.focus(event.tabID)
|
||||
if (event.type === "preview") return input.preview(event.path)
|
||||
if (event.type === "inspect") return input.inspect(event)
|
||||
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
|
||||
blocked = true
|
||||
registration.close()
|
||||
@@ -72,6 +75,9 @@ export function createBrowserConnection(input: {
|
||||
register()
|
||||
return state.registration?.command(command) ?? Promise.reject(new Error("browser.pane.unavailable"))
|
||||
},
|
||||
highlight(tabID: Browser.TabID, ref?: Browser.Ref) {
|
||||
state.registration?.highlight(tabID, ref)
|
||||
},
|
||||
dispose() {
|
||||
disposed = true
|
||||
clearTimeout(retry)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { batch, createEffect, createMemo, on } from "solid-js"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import type { BrowserPaneCommand, BrowserPaneEvent } from "@/runtime/platform/browser-pane"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import type { SessionModel } from "../model"
|
||||
@@ -120,5 +120,24 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
|
||||
open,
|
||||
command,
|
||||
onInspect(listener: (event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void) {
|
||||
const sessionID = session.identity.sessionID()
|
||||
return sessionID ? attachments.onInspect(server, sessionID, listener) : () => undefined
|
||||
},
|
||||
/** Shows the browser tab that owns a picked element and flashes the element. */
|
||||
reveal(tabID: string, ref?: string) {
|
||||
const sessionID = session.identity.sessionID()
|
||||
const tab = browserTabs().find((tab) => tab.id === tabID)
|
||||
if (!sessionID || !tab) return
|
||||
const view = session.layout.view()
|
||||
if (!view.reviewPanel.opened()) view.reviewPanel.open()
|
||||
session.layout.tabs().setActive(sessionBrowserTab(tab.id))
|
||||
if (ref && isRef(ref)) attachments.highlight(server, sessionID, tab.id, ref)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Stored comments keep refs as plain strings; this matches the desktop's ref format.
|
||||
function isRef(value: string): value is Browser.Ref {
|
||||
return /^e[1-9][0-9]*$/.test(value)
|
||||
}
|
||||
@@ -1,20 +1,36 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { LineCommentEditor, type LineCommentEditorMention } from "@opencode/ui/line-comment"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createEffect, For, on, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneElement } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { formatKeybindParts, useCommand } from "@/shell/commands/command"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
|
||||
/** A comment on an element the user picked in the page. The ref is absent once the page navigated. */
|
||||
export type SessionBrowserComment = {
|
||||
tabID: Browser.TabID
|
||||
url: string
|
||||
title: string
|
||||
element: Omit<BrowserPaneElement, "rect" | "ref"> & { ref?: Browser.Ref }
|
||||
comment: string
|
||||
}
|
||||
|
||||
export function SessionBrowserPane(props: {
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
visible: boolean
|
||||
onComment?: (comment: SessionBrowserComment) => void
|
||||
mention?: LineCommentEditorMention
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
@@ -33,8 +49,91 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
// A still of the page shown in the DOM while floating content covers the hidden native view.
|
||||
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
|
||||
// The tab whose element picker is on.
|
||||
picking: undefined as Browser.TabID | undefined,
|
||||
// A picked element awaiting its comment. The page stays frozen as a still until it closes.
|
||||
comment: undefined as
|
||||
| {
|
||||
tabID: Browser.TabID
|
||||
url: string
|
||||
title: string
|
||||
// The tab's navigation count at the pick; the element's ref dies when it changes.
|
||||
generation: number
|
||||
element: BrowserPaneElement
|
||||
draft: string
|
||||
}
|
||||
| undefined,
|
||||
size: { width: 0, height: 0 },
|
||||
editorHeight: 0,
|
||||
})
|
||||
const empty = () => !address() && !state()?.loading && !store.navigating
|
||||
const inspectable = () => !!props.onComment && !!address() && !failed() && !props.browser.suspended()
|
||||
const picking = () => !!state() && store.picking === state()?.id
|
||||
const setPicking = (tabID: Browser.TabID, enabled: boolean) => {
|
||||
registration()?.inspect(tabID, enabled)
|
||||
setStore("picking", enabled ? tabID : undefined)
|
||||
}
|
||||
const toggleInspect = () => {
|
||||
const tab = state()
|
||||
if (!tab || !inspectable()) return
|
||||
if (store.comment) closeComment()
|
||||
setPicking(tab.id, !picking())
|
||||
}
|
||||
const closeComment = () => {
|
||||
const current = store.comment
|
||||
if (!current) return
|
||||
registration()?.highlight(current.tabID)
|
||||
setStore("comment", undefined)
|
||||
}
|
||||
const submitComment = (value: string) => {
|
||||
const current = store.comment
|
||||
if (!current) return
|
||||
const tab = state()
|
||||
// The draft outlives a reload or agent navigation, but the ref no longer names anything.
|
||||
const live = tab?.id === current.tabID && tab.generation === current.generation
|
||||
props.onComment?.({
|
||||
tabID: current.tabID,
|
||||
url: current.url,
|
||||
title: current.title,
|
||||
element: {
|
||||
...(live ? { ref: current.element.ref } : {}),
|
||||
selector: current.element.selector,
|
||||
label: current.element.label,
|
||||
...(current.element.role ? { role: current.element.role } : {}),
|
||||
...(current.element.name ? { name: current.element.name } : {}),
|
||||
...(current.element.text ? { text: current.element.text } : {}),
|
||||
},
|
||||
comment: value,
|
||||
})
|
||||
closeComment()
|
||||
}
|
||||
// The picked element in surface pixels, and the editor anchored below it, above it, or over it.
|
||||
const spotlight = () => {
|
||||
const rect = store.comment?.element.rect
|
||||
if (!rect) return
|
||||
const zoom = platform.webviewZoom?.() ?? 1
|
||||
return { x: rect.x / zoom, y: rect.y / zoom, width: rect.width / zoom, height: rect.height / zoom }
|
||||
}
|
||||
const placement = () => {
|
||||
const rect = spotlight()
|
||||
if (!rect) return
|
||||
const gap = 8
|
||||
const width = Math.max(0, Math.min(400, store.size.width - gap * 2))
|
||||
// The editor scrolls rather than growing past the surface, so its actions stay reachable.
|
||||
const maxHeight = Math.max(0, store.size.height - gap * 2)
|
||||
// Until the editor has been measured once, assume its default three-row height.
|
||||
const height = Math.min(store.editorHeight || 176, maxHeight)
|
||||
const left = Math.min(Math.max(gap, rect.x), Math.max(gap, store.size.width - width - gap))
|
||||
const below = rect.y + rect.height + gap
|
||||
const above = rect.y - gap - height
|
||||
const top =
|
||||
below + height <= store.size.height - gap
|
||||
? below
|
||||
: above >= gap
|
||||
? above
|
||||
: Math.max(gap, store.size.height - height - gap)
|
||||
return { left, top, width, maxHeight }
|
||||
}
|
||||
let surface: HTMLDivElement | undefined
|
||||
let addressDisplay: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
@@ -65,8 +164,62 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
|
||||
},
|
||||
},
|
||||
// Ctrl+Shift+C copies in the terminal, so only the focused page claims it, as in Chromium.
|
||||
{
|
||||
id: "browser.inspect",
|
||||
title: language.t("command.browser.inspect"),
|
||||
category: language.t("command.category.view"),
|
||||
disabled: !props.visible || !inspectable(),
|
||||
onSelect: toggleInspect,
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
onCleanup(
|
||||
props.browser.onInspect((event) => {
|
||||
if (event.active) {
|
||||
setStore("picking", event.tabID)
|
||||
return
|
||||
}
|
||||
if (store.picking === event.tabID) setStore("picking", undefined)
|
||||
if (!event.element) return
|
||||
const tab = state()
|
||||
if (!props.onComment || tab?.id !== event.tabID || !props.visible || !store.visible) {
|
||||
registration()?.highlight(event.tabID)
|
||||
return
|
||||
}
|
||||
setStore("comment", {
|
||||
tabID: tab.id,
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
generation: tab.generation,
|
||||
element: event.element,
|
||||
draft: "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
// A picker or comment belongs to the page on screen; switching tabs or hiding the pane ends it.
|
||||
createEffect(
|
||||
on([() => state()?.id, () => props.visible && store.visible], ([id, shown]) => {
|
||||
const tabID = untrack(() => store.picking)
|
||||
if (tabID && (tabID !== id || !shown)) setPicking(tabID, false)
|
||||
if (untrack(() => store.comment)?.tabID !== id) closeComment()
|
||||
}),
|
||||
)
|
||||
// The page does not have focus while the picker waits for a hover, so Escape reaches the app.
|
||||
createEventListener(
|
||||
window,
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key !== "Escape" || !store.picking) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setPicking(store.picking, false)
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
// The native page always paints above the DOM, so hide it while a floating
|
||||
// menu, select, or popover overlaps it. Tooltips are excluded.
|
||||
const covered = (rect: DOMRect) =>
|
||||
@@ -129,7 +282,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
// The desktop page hides blank and loading documents itself; only hide here
|
||||
// while the pane shows its own empty or failed state over the surface.
|
||||
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
|
||||
const cover = covered(rect)
|
||||
// A comment on a picked element freezes the page so its editor can float above it.
|
||||
const cover = store.comment?.tabID === tab.id || covered(rect)
|
||||
if (shown && cover) freeze(tab.id)
|
||||
if (!cover) thaw()
|
||||
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
|
||||
@@ -202,6 +356,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
() => store.comment?.tabID,
|
||||
empty,
|
||||
failed,
|
||||
registration,
|
||||
@@ -220,7 +375,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
)
|
||||
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
|
||||
// animation frame keeps the native view in step with a pane drag.
|
||||
createResizeObserver(() => surface, measure)
|
||||
createResizeObserver(
|
||||
() => surface,
|
||||
(rect) => {
|
||||
measure()
|
||||
setStore("size", { width: rect.width, height: rect.height })
|
||||
},
|
||||
)
|
||||
createEventListener(window, "resize", () => schedule(300))
|
||||
// Floating content portals directly into <body>; keep measuring briefly so
|
||||
// the positioner has settled before the overlap check runs.
|
||||
@@ -290,6 +451,39 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Show when={props.onComment}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("session.browser.inspect")}</span>
|
||||
<Show when={command.keybindParts("browser.inspect").length > 0}>
|
||||
<Keybind keys={command.keybindParts("browser.inspect")} variant="neutral" />
|
||||
</Show>
|
||||
</div>
|
||||
{/* The page claims Chromium's picker chord itself; the app leaves it to the terminal. */}
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("session.browser.inspect.pageShortcut")}</span>
|
||||
<Keybind keys={formatKeybindParts("mod+shift+c", language.t)} variant="neutral" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* The ghost variant sets the button color, so the active accent needs precedence over it. */}
|
||||
<IconButton
|
||||
{...button}
|
||||
data-action="browser-inspect"
|
||||
disabled={!inspectable()}
|
||||
state={picking() ? "pressed" : undefined}
|
||||
classList={{ "!text-v2-icon-icon-accent": picking() || !!store.comment }}
|
||||
aria-pressed={picking()}
|
||||
aria-label={language.t("session.browser.inspect")}
|
||||
onClick={toggleInspect}
|
||||
icon={<Icon name="select-element" size="small" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<form
|
||||
dir="ltr"
|
||||
class="relative min-w-0 flex-1 h-7 rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover text-12-regular"
|
||||
@@ -378,7 +572,71 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
{language.t("session.browser.suspended")}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={store.comment?.tabID === state()?.id && store.comment}>
|
||||
{(current) => (
|
||||
<div
|
||||
data-component="browser-comment"
|
||||
class="absolute inset-0 z-10"
|
||||
onPointerDown={(event) => {
|
||||
// A click beside the editor dismisses it unless it would discard a draft.
|
||||
if (event.target === event.currentTarget && !current().draft.trim()) closeComment()
|
||||
}}
|
||||
>
|
||||
<Show when={spotlight()}>
|
||||
{(rect) => (
|
||||
<div
|
||||
data-slot="browser-comment-spotlight"
|
||||
class="pointer-events-none absolute rounded-[2px]"
|
||||
style={{
|
||||
left: `${rect().x}px`,
|
||||
top: `${rect().y}px`,
|
||||
width: `${rect().width}px`,
|
||||
height: `${rect().height}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={placement()}>
|
||||
{(position) => (
|
||||
<div
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, (rect) => setStore("editorHeight", Math.ceil(rect.height)))
|
||||
}
|
||||
data-slot="browser-comment-editor"
|
||||
data-prevent-autofocus
|
||||
class="absolute overflow-y-auto rounded-[6px] shadow-[var(--v2-elevation-raised)]"
|
||||
style={{
|
||||
left: `${position().left}px`,
|
||||
top: `${position().top}px`,
|
||||
width: `${position().width}px`,
|
||||
"max-height": `${position().maxHeight}px`,
|
||||
}}
|
||||
>
|
||||
<LineCommentEditor
|
||||
value={current().draft}
|
||||
onInput={(value) => setStore("comment", "draft", value)}
|
||||
onCancel={closeComment}
|
||||
onSubmit={submitComment}
|
||||
mention={props.mention}
|
||||
selection={
|
||||
<span class="flex min-w-0 items-center gap-1" dir="ltr">
|
||||
<Icon name="select-element" size="small" class="shrink-0" />
|
||||
<span class="min-w-0 truncate leading-[var(--line-height-tight)]">
|
||||
{current().element.label}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<p class="sr-only" role="status" aria-live="polite">
|
||||
{picking() ? language.t("session.browser.inspect.active") : ""}
|
||||
</p>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { commentContextItem } from "@/composer/comment-note"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
@@ -75,16 +76,7 @@ export const DialogFork: Component = () => {
|
||||
dialog.close()
|
||||
const target = prompt.capture({ dir, id: forked.id })
|
||||
target.set(restored)
|
||||
target.context.replaceComments(
|
||||
extractPromptComments(message).map((comment) => ({
|
||||
type: "file",
|
||||
path: comment.path,
|
||||
selection: comment.selection,
|
||||
comment: comment.comment,
|
||||
preview: comment.preview,
|
||||
commentOrigin: comment.origin,
|
||||
})),
|
||||
)
|
||||
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
|
||||
navigate(sessionHref(server.key, forked.id))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { requireServerKey, sessionHref } from "@/shell/routes/session"
|
||||
import { useComposerCommands } from "@/composer/commands"
|
||||
import { useSessionCommands } from "../commands/use-session-commands"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
import type { SessionModel } from "../model"
|
||||
import type { SessionScreenLayout } from "../screen-layout"
|
||||
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
|
||||
@@ -34,6 +35,7 @@ export function createActiveSessionRegion(input: {
|
||||
screen: SessionScreenLayout
|
||||
timeline: SessionTimelineInteraction
|
||||
visible: Accessor<boolean>
|
||||
browser: Pick<ReturnType<typeof createSessionBrowser>, "reveal">
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
@@ -46,6 +48,7 @@ export function createActiveSessionRegion(input: {
|
||||
const state = createSessionRequestModel()
|
||||
const controls = createComposerControls({
|
||||
sessionKey: input.session.identity.sessionKey,
|
||||
browser: { reveal: input.browser.reveal },
|
||||
})
|
||||
let promptRef: HTMLDivElement | undefined
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { SessionContextTab } from "@/session/files/session-context-tab"
|
||||
import { SortableTab } from "@/session/files/tab"
|
||||
import { OpenInAppButton } from "@/session/files/open-in-app-button"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { useFile, type SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
@@ -80,6 +81,7 @@ export function SessionSidePanel(props: {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
const file = useFile()
|
||||
const prompt = useComposerState()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const sdk = useWorkspaceLocation()
|
||||
@@ -614,6 +616,10 @@ export function SessionSidePanel(props: {
|
||||
<SessionBrowserPane
|
||||
browser={props.browser}
|
||||
visible={reviewOpen() && isSessionBrowserTab(activeTab())}
|
||||
mention={{ items: file.searchFilesAndDirectories }}
|
||||
onComment={(comment) =>
|
||||
prompt.context.add({ type: "browser", ...comment, commentID: crypto.randomUUID() })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { commentContextItem } from "@/composer/comment-note"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import type { SessionModel } from "./model"
|
||||
@@ -34,16 +35,7 @@ export function createSessionRevert(input: {
|
||||
directory: location().directory,
|
||||
}),
|
||||
)
|
||||
target.context.replaceComments(
|
||||
extractPromptComments(message).map((comment) => ({
|
||||
type: "file",
|
||||
path: comment.path,
|
||||
selection: comment.selection,
|
||||
comment: comment.comment,
|
||||
preview: comment.preview,
|
||||
commentOrigin: comment.origin,
|
||||
})),
|
||||
)
|
||||
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
|
||||
}
|
||||
|
||||
const stage = async (message: SessionMessageUser, previous: SessionMessageUser | undefined) => {
|
||||
|
||||
@@ -178,6 +178,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
|
||||
screen,
|
||||
timeline,
|
||||
visible: conversationVisible,
|
||||
browser,
|
||||
})
|
||||
useUsageExceededDialogs()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { GoUpsellState } from "./usage-exceeded-dialogs"
|
||||
import { GoUpsellState, isChatGPTUsageLimit } from "./usage-exceeded-dialogs"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(
|
||||
@@ -38,3 +38,31 @@ describe("usage exceeded preferences", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChatGPT usage limit", () => {
|
||||
const message =
|
||||
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
|
||||
|
||||
test("detects the mapped message regardless of failure type", () => {
|
||||
expect(
|
||||
isChatGPTUsageLimit({
|
||||
type: "provider.rate-limit",
|
||||
message,
|
||||
status: 429,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(isChatGPTUsageLimit({ type: "provider.unknown", message })).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores other rate limits and similar messages", () => {
|
||||
expect(isChatGPTUsageLimit({ type: "provider.rate-limit", message: "Rate limit exceeded", status: 429 })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
isChatGPTUsageLimit({
|
||||
type: "provider.rate-limit",
|
||||
message: "ChatGPT usage limit reached",
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import type { SessionStatus } from "@opencode/client/promise"
|
||||
import type { SessionStatus, SessionStepFailed } from "@opencode/client/promise"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
@@ -14,6 +14,14 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_
|
||||
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
|
||||
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
|
||||
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
|
||||
const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs
|
||||
|
||||
export function isChatGPTUsageLimit(error: SessionStepFailed["data"]["error"]) {
|
||||
return (
|
||||
error.message ===
|
||||
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
|
||||
)
|
||||
}
|
||||
|
||||
export const GoUpsellState = Persistence.struct({
|
||||
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
|
||||
@@ -52,6 +60,25 @@ export function useUsageExceededDialogs() {
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null,
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null,
|
||||
})
|
||||
const [chatgptUsageLimit, setChatGPTUsageLimit] = persisted(
|
||||
Persist.global("chatgpt-usage-limit"),
|
||||
Persistence.struct({ lastSeenAt: Schema.NullOr(Schema.Finite) }),
|
||||
{ lastSeenAt: null },
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
sdk().event.on("session.step.failed", (evt) => {
|
||||
if (evt.data.sessionID !== params.id) return
|
||||
if (!isChatGPTUsageLimit(evt.data.error) || dialog.active) return
|
||||
if (chatgptUsageLimit.lastSeenAt && Date.now() - chatgptUsageLimit.lastSeenAt < CHATGPT_USAGE_LIMIT_WINDOW) return
|
||||
|
||||
void import("@/providers/connect/chatgpt-usage-limit").then((usage) => {
|
||||
if (dialog.active) return
|
||||
setChatGPTUsageLimit("lastSeenAt", Date.now())
|
||||
dialog.show(() => <usage.DialogChatGPTUsageLimit />)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
sdk().event.on("session.status", (evt) => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterAll, beforeAll, expect, mock, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { Data } from "@opencode/client/solid"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { Tab } from "@/shell/tabs/tabs"
|
||||
|
||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||
const session = { id: "session-1", title: "Test session", location: { directory: "/project" } }
|
||||
const alerts: string[] = []
|
||||
const tabs: { store: Tab[] } = { store: [] }
|
||||
let createServerNotificationState: typeof import("./notification").createServerNotificationState
|
||||
let storage: typeof import("@/runtime/persistence/storage")
|
||||
|
||||
beforeAll(async () => {
|
||||
storage = await import("@/runtime/persistence/storage")
|
||||
const { sessionIDHasOpenTab } = await import("@/shell/tabs/tabs")
|
||||
mock.module("@/runtime/platform/platform", () => ({
|
||||
usePlatform: () => ({
|
||||
platform: "web",
|
||||
notify: async (title: string) => {
|
||||
alerts.push(title)
|
||||
},
|
||||
}),
|
||||
}))
|
||||
mock.module("@/settings/model", () => ({
|
||||
useSettings: () => ({
|
||||
sounds: { agentEnabled: () => false, errorsEnabled: () => false },
|
||||
notifications: { agent: () => true, errors: () => true },
|
||||
}),
|
||||
}))
|
||||
mock.module("@/runtime/i18n/language", () => ({ useLanguage: () => ({ t: (key: string) => key }) }))
|
||||
mock.module("@/shell/tabs/tabs", () => ({
|
||||
useTabs: () => tabs,
|
||||
sessionIDHasOpenTab,
|
||||
}))
|
||||
mock.module("@/runtime/persistence/storage", () => ({
|
||||
...storage,
|
||||
persisted: () => {
|
||||
const [store, setStore] = createStore({ list: [] })
|
||||
return [store, setStore, undefined, () => false]
|
||||
},
|
||||
}))
|
||||
createServerNotificationState = (await import("./notification")).createServerNotificationState
|
||||
})
|
||||
|
||||
afterAll(() => mock.module("@/runtime/persistence/storage", () => storage))
|
||||
|
||||
test.each([
|
||||
["session.execution.succeeded", "notification.session.responseReady.title"],
|
||||
["session.execution.failed", "notification.session.error.title"],
|
||||
] as const)("system alert for %s requires an open session tab", async (type, title) => {
|
||||
alerts.length = 0
|
||||
tabs.store = [{ type: "session", server, sessionId: "another-session" }]
|
||||
let listener: ((event: unknown) => void) | undefined
|
||||
const dispose = createRoot((dispose) => {
|
||||
const state = createServerNotificationState({
|
||||
key: server,
|
||||
sdk: {
|
||||
scope: ServerScope.local,
|
||||
event: {
|
||||
listen: (fn: typeof listener) => {
|
||||
listener = fn
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
} as unknown as ServerSDK,
|
||||
data: { session: { get: () => session } } as unknown as Data,
|
||||
coordinator: { system: async (_id: string, fn: () => Promise<void>) => fn() },
|
||||
} as Parameters<typeof createServerNotificationState>[0])
|
||||
return { dispose, state }
|
||||
})
|
||||
|
||||
listener?.({
|
||||
type,
|
||||
id: "event-1",
|
||||
data: { sessionID: session.id, error: { type: "api", message: "failed", status: 500 } },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(dispose.state.session.all(session.id)).toHaveLength(1)
|
||||
expect(alerts).toEqual([])
|
||||
|
||||
tabs.store = [{ type: "session", server, sessionId: session.id }]
|
||||
listener?.({
|
||||
type,
|
||||
id: "event-2",
|
||||
data: { sessionID: session.id, error: { type: "api", message: "failed", status: 500 } },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(alerts).toEqual([title])
|
||||
dispose.dispose()
|
||||
})
|
||||
@@ -228,7 +228,8 @@ export function createServerNotificationState(input: {
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
|
||||
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
|
||||
const hasOpenTab = sessionIDHasOpenTab(tabs.store, input.key, sessionID)
|
||||
if (hasOpenTab && settings.sounds.agentEnabled()) {
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
|
||||
}
|
||||
|
||||
@@ -240,7 +241,7 @@ export function createServerNotificationState(input: {
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
if (settings.notifications.agent()) {
|
||||
if (hasOpenTab && settings.notifications.agent()) {
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
@@ -255,7 +256,8 @@ export function createServerNotificationState(input: {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
|
||||
const hasOpenTab = sessionIDHasOpenTab(tabs.store, input.key, sessionID)
|
||||
if (hasOpenTab && settings.sounds.errorsEnabled()) {
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
|
||||
}
|
||||
|
||||
@@ -270,7 +272,7 @@ export function createServerNotificationState(input: {
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
if (settings.notifications.errors()) {
|
||||
if (hasOpenTab && settings.notifications.errors()) {
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.error.title"), description, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { readLocalImage } from "@/runtime/server/image"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
export function SessionUIProvider(
|
||||
props: ParentProps<{
|
||||
@@ -22,6 +24,7 @@ export function SessionUIProvider(
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const directory = () => props.directory
|
||||
const readImage = createMemo<ReadMarkdownImage>(() => {
|
||||
const dir = directory()
|
||||
@@ -39,6 +42,17 @@ export function SessionUIProvider(
|
||||
await data.session.sync(sessionID).catch(() => undefined)
|
||||
navigate(href(sessionID))
|
||||
}
|
||||
const openReferencedSession = async (sessionID: string) => {
|
||||
// The transcript may mention a session from another server (or one that was deleted).
|
||||
// Resolve it on this server before touching tabs or the current route.
|
||||
const session = await serverSDK.api.session.get({ sessionID }).catch(() => undefined)
|
||||
if (!session || session.time.archived) {
|
||||
showToast({ title: language.t("session.error.notFound") })
|
||||
return
|
||||
}
|
||||
data.session.remember(session)
|
||||
tabs.select(tabs.addSessionTab({ server: props.server, sessionId: session.id }))
|
||||
}
|
||||
const providers = useProviders(directory)
|
||||
const sessionUIData = createMemo(() => ({
|
||||
provider: providers.ready()
|
||||
@@ -66,7 +80,7 @@ export function SessionUIProvider(
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<MarkdownProvider readImage={readImage()}>
|
||||
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession}>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</MarkdownProvider>
|
||||
</DataProvider>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { CommandInfo, ModelInfo, ModelRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import type { ConfigOptionProvider } from "./config-option"
|
||||
|
||||
export type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ModelInfo[]
|
||||
readonly defaultModel: ModelRef
|
||||
readonly modes: Array<{ id: string; name: string; description?: string }>
|
||||
readonly defaultModeID: string
|
||||
readonly commands: CommandInfo[]
|
||||
}
|
||||
|
||||
export type Live = {
|
||||
readonly cwd: string
|
||||
current: Catalog
|
||||
}
|
||||
|
||||
// Provider, integration, and credential changes reach the catalog through model.updated.
|
||||
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
|
||||
|
||||
export function make(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly signal?: AbortSignal
|
||||
readonly changed: (live: Live, previous: Catalog) => Promise<unknown>
|
||||
}) {
|
||||
const entries = new Map<string, Promise<Live>>()
|
||||
const running = new Map<Live, Promise<void>>()
|
||||
const queued = new Set<Live>()
|
||||
let subscribed: Promise<void> | undefined
|
||||
|
||||
const get = (cwd: string) => {
|
||||
const key = FSUtil.resolve(cwd)
|
||||
const cached = entries.get(key)
|
||||
if (cached) return cached
|
||||
// Subscribe before the first read so an update between the read and the subscription is not lost.
|
||||
const loaded = subscribe()
|
||||
.then(() => load(input.client, cwd))
|
||||
.then((current): Live => ({ cwd, current }))
|
||||
.catch((error) => {
|
||||
entries.delete(key)
|
||||
throw error
|
||||
})
|
||||
entries.set(key, loaded)
|
||||
return loaded
|
||||
}
|
||||
|
||||
const reload = (live: Live) => {
|
||||
const current = running.get(live)
|
||||
if (current) {
|
||||
queued.add(live)
|
||||
return current
|
||||
}
|
||||
const run = (async () => {
|
||||
do {
|
||||
queued.delete(live)
|
||||
const next = await load(input.client, live.cwd).catch(() => undefined)
|
||||
if (!next) break
|
||||
const previous = live.current
|
||||
live.current = next
|
||||
await input.changed(live, previous).catch(() => {})
|
||||
} while (queued.has(live))
|
||||
running.delete(live)
|
||||
})()
|
||||
running.set(live, run)
|
||||
return run
|
||||
}
|
||||
|
||||
const subscribe = () =>
|
||||
(subscribed ??= new Promise<void>((ready) => {
|
||||
void (async () => {
|
||||
for await (const event of input.client.event.subscribe({ signal: input.signal })) {
|
||||
if (event.type === "server.connected") ready()
|
||||
if (!reloadOn.has(event.type)) continue
|
||||
const directory = event.location?.directory
|
||||
const targets = directory === undefined ? [...entries.values()] : [entries.get(FSUtil.resolve(directory))]
|
||||
targets.forEach((entry) => void entry?.then(reload, () => {}))
|
||||
}
|
||||
})()
|
||||
.catch(() => {})
|
||||
.finally(ready)
|
||||
}))
|
||||
|
||||
return { get, reload }
|
||||
}
|
||||
|
||||
async function load(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
const location = { directory: cwd }
|
||||
// Some providers discover models in the background after plugin startup begins.
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
|
||||
client.model.list({ location }),
|
||||
client.model.default({ location }),
|
||||
client.agent.list({ location }),
|
||||
client.command.list({ location }),
|
||||
])
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// Parallel reads can straddle initialization; select only from this model list.
|
||||
const defaultModel = preferred
|
||||
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
|
||||
: models[0]
|
||||
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
|
||||
if (defaultModel && defaultAgent) {
|
||||
return {
|
||||
providers: providers(models),
|
||||
models,
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
commands: commandResult.data,
|
||||
}
|
||||
}
|
||||
missing = defaultModel ? "No primary agents are available" : "No models are available"
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error(missing)
|
||||
}
|
||||
|
||||
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
.toSorted()
|
||||
.map((providerID) => ({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
models: models
|
||||
.filter((model) => model.providerID === providerID)
|
||||
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
|
||||
}))
|
||||
}
|
||||
|
||||
export * as ACPCatalog from "./catalog"
|
||||
+107
-138
@@ -1,7 +1,7 @@
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import {
|
||||
isSessionNotFoundError,
|
||||
type CommandInfo,
|
||||
type ModelInfo,
|
||||
type ModelRef,
|
||||
type OpenCodeClient,
|
||||
type SessionInfo,
|
||||
@@ -40,12 +40,8 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import {
|
||||
buildConfigOptions,
|
||||
DEFAULT_VARIANT_VALUE,
|
||||
parseModelSelection,
|
||||
type ConfigOptionProvider,
|
||||
} from "./config-option"
|
||||
import { ACPCatalog, type Catalog } from "./catalog"
|
||||
import { buildConfigOptions, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { promptContentToParts } from "./content"
|
||||
import {
|
||||
@@ -61,22 +57,14 @@ import { ACPError } from "./error"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ModelInfo[]
|
||||
readonly defaultModel: ModelRef
|
||||
readonly modes: Array<{ id: string; name: string; description?: string }>
|
||||
readonly defaultModeID: string
|
||||
readonly commands: CommandInfo[]
|
||||
}
|
||||
|
||||
// Model and mode are unset while the session follows the server defaults.
|
||||
type Attached = {
|
||||
readonly id: string
|
||||
readonly cwd: string
|
||||
readonly abort: AbortController
|
||||
catalog: Catalog
|
||||
model: ModelRef
|
||||
modeID: string
|
||||
readonly catalog: ACPCatalog.Live
|
||||
model?: ModelRef
|
||||
modeID?: string
|
||||
}
|
||||
|
||||
type PreparedPrompt = {
|
||||
@@ -109,21 +97,55 @@ export function make(input: {
|
||||
readonly connection: ACPConnection.Connection
|
||||
}): Interface {
|
||||
const sessions = new Map<string, Attached>()
|
||||
const catalogs = new Map<string, Promise<Catalog>>()
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
|
||||
const capabilities = { writeTextFile: false, childSessionUpdates: false }
|
||||
|
||||
const catalog = (cwd: string) => {
|
||||
const cached = catalogs.get(cwd)
|
||||
if (cached) return cached
|
||||
const loaded = loadCatalog(input.client, cwd).catch((error) => {
|
||||
catalogs.delete(cwd)
|
||||
throw error
|
||||
const catalogs = ACPCatalog.make({
|
||||
client: input.client,
|
||||
signal: input.connection.signal,
|
||||
changed: (live, previous) =>
|
||||
Promise.all(
|
||||
Array.from(sessions.values())
|
||||
.filter((state) => state.catalog === live)
|
||||
.map(async (state) => {
|
||||
const options = configOptions(state)
|
||||
if (!isDeepStrictEqual(options, configOptions(state, previous))) {
|
||||
await input.connection.sessionUpdate({
|
||||
sessionId: state.id,
|
||||
update: { sessionUpdate: "config_option_update", configOptions: options },
|
||||
})
|
||||
}
|
||||
if (!isDeepStrictEqual(live.current.commands, previous.commands)) await sendCommands(state)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const sendCommands = (state: Attached) =>
|
||||
input.connection.sessionUpdate({
|
||||
sessionId: state.id,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: state.catalog.current.commands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description ?? "",
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
const withReload = <A>(state: Attached, select: () => Promise<A>) =>
|
||||
select().catch(async (error: unknown) => {
|
||||
if (
|
||||
!(
|
||||
error instanceof ACPError.InvalidModelError ||
|
||||
error instanceof ACPError.InvalidModeError ||
|
||||
error instanceof ACPError.InvalidEffortError
|
||||
)
|
||||
)
|
||||
throw error
|
||||
await catalogs.reload(state.catalog)
|
||||
return select()
|
||||
})
|
||||
catalogs.set(cwd, loaded)
|
||||
return loaded
|
||||
}
|
||||
|
||||
const requireSession = async (sessionID: string) => {
|
||||
const current = sessions.get(sessionID)
|
||||
@@ -147,27 +169,19 @@ export function make(input: {
|
||||
}
|
||||
|
||||
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
|
||||
const currentCatalog = await catalog(cwd)
|
||||
const catalog = await catalogs.get(cwd)
|
||||
sessions.get(session.id)?.abort.abort()
|
||||
const state: Attached = {
|
||||
id: session.id,
|
||||
cwd,
|
||||
abort: new AbortController(),
|
||||
catalog: currentCatalog,
|
||||
model: session.model ?? currentCatalog.defaultModel,
|
||||
modeID: session.agent ?? currentCatalog.defaultModeID,
|
||||
catalog,
|
||||
model: session.model,
|
||||
modeID: session.agent,
|
||||
}
|
||||
sessions.set(session.id, state)
|
||||
await registerMcpServers(input.client, registeredMcp, state, mcpServers)
|
||||
await input.connection.sessionUpdate({
|
||||
sessionId: state.id,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
...state.catalog.commands.map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
],
|
||||
},
|
||||
})
|
||||
await sendCommands(state)
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -175,14 +189,16 @@ export function make(input: {
|
||||
await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
|
||||
}
|
||||
|
||||
const configOptions = (state: Attached) =>
|
||||
buildConfigOptions({
|
||||
providers: state.catalog.providers,
|
||||
currentModel: { providerID: state.model.providerID, modelID: state.model.id },
|
||||
currentVariant: state.model.variant,
|
||||
modes: state.catalog.modes,
|
||||
currentModeId: state.modeID,
|
||||
const configOptions = (state: Attached, catalog = state.catalog.current) => {
|
||||
const model = currentModel(state, catalog)
|
||||
return buildConfigOptions({
|
||||
providers: catalog.providers,
|
||||
currentModel: { providerID: model.providerID, modelID: model.id },
|
||||
currentVariant: model.variant,
|
||||
modes: catalog.modes,
|
||||
currentModeId: state.modeID ?? catalog.defaultModeID,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: async (params) => {
|
||||
@@ -216,12 +232,10 @@ export function make(input: {
|
||||
return {}
|
||||
},
|
||||
newSession: async (params) => {
|
||||
const currentCatalog = await catalog(params.cwd)
|
||||
const created = await input.client.session.create({
|
||||
location: { directory: params.cwd },
|
||||
agent: currentCatalog.defaultModeID,
|
||||
model: currentCatalog.defaultModel,
|
||||
})
|
||||
// Load before creating so a catalog failure leaves no session behind. Agent and model stay unset
|
||||
// so the server resolves its defaults after plugins activate.
|
||||
await catalogs.get(params.cwd)
|
||||
const created = await input.client.session.create({ location: { directory: params.cwd } })
|
||||
const state = await attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: state.id, configOptions: configOptions(state) }
|
||||
},
|
||||
@@ -279,37 +293,38 @@ export function make(input: {
|
||||
},
|
||||
setSessionConfigOption: async (params) => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
switch (params.configId) {
|
||||
case "model": {
|
||||
const selected = requireModel(state.catalog, params.value, state.model)
|
||||
state.model = selected
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||
break
|
||||
const value = params.value
|
||||
if (typeof value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
await withReload(state, async () => {
|
||||
switch (params.configId) {
|
||||
case "model": {
|
||||
const selected = requireModel(state.catalog.current, value, currentModel(state))
|
||||
state.model = selected
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||
return
|
||||
}
|
||||
case "effort": {
|
||||
const current = currentModel(state)
|
||||
const model = state.catalog.current.models.find(
|
||||
(item) => item.providerID === current.providerID && item.id === current.id,
|
||||
)
|
||||
if (!model || (value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === value)))
|
||||
throw new ACPError.InvalidEffortError({ effort: value })
|
||||
state.model = { ...current, variant: value }
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
|
||||
return
|
||||
}
|
||||
case "mode":
|
||||
return selectMode(input.client, state, value)
|
||||
default:
|
||||
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
case "effort": {
|
||||
const model = state.catalog.models.find(
|
||||
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
|
||||
)
|
||||
if (
|
||||
!model ||
|
||||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
|
||||
)
|
||||
throw new ACPError.InvalidEffortError({ effort: params.value })
|
||||
state.model = { ...state.model, variant: params.value }
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
|
||||
break
|
||||
}
|
||||
case "mode":
|
||||
await selectMode(input.client, state, params.value)
|
||||
break
|
||||
default:
|
||||
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
})
|
||||
return { configOptions: configOptions(state) }
|
||||
},
|
||||
setSessionMode: async (params) => {
|
||||
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
|
||||
const state = await requireSession(params.sessionId)
|
||||
await withReload(state, () => selectMode(input.client, state, params.modeId))
|
||||
return {}
|
||||
},
|
||||
prompt: async (params, signal) => {
|
||||
@@ -321,7 +336,7 @@ export function make(input: {
|
||||
})
|
||||
}
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
||||
const prepared = preparePrompt(state.catalog.current, params.prompt, messageID)
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const extNotification = input.connection.extNotification
|
||||
const childSessionUpdate =
|
||||
@@ -410,58 +425,6 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart
|
||||
return { type: "input", id: messageID }
|
||||
}
|
||||
|
||||
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
const location = { directory: cwd }
|
||||
// Some providers discover models in the background after plugin startup begins.
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
|
||||
client.model.list({ location }),
|
||||
client.model.default({ location }),
|
||||
client.agent.list({ location }),
|
||||
client.command.list({ location }),
|
||||
])
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// Parallel reads can straddle initialization; select only from this model list.
|
||||
const defaultModel = preferred
|
||||
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
|
||||
: models[0]
|
||||
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
|
||||
if (defaultModel && defaultAgent) {
|
||||
return {
|
||||
providers: providers(models),
|
||||
models,
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
commands: commandResult.data,
|
||||
}
|
||||
}
|
||||
missing = defaultModel ? "No primary agents are available" : "No models are available"
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error(missing)
|
||||
}
|
||||
|
||||
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
.toSorted()
|
||||
.map((providerID) => ({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
models: models
|
||||
.filter((model) => model.providerID === providerID)
|
||||
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
|
||||
}))
|
||||
}
|
||||
|
||||
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
@@ -480,8 +443,13 @@ function requireModel(catalog: Catalog, modelID: string, current: ModelRef): Mod
|
||||
return { providerID: model.providerID, id: model.id, variant }
|
||||
}
|
||||
|
||||
function currentModel(state: Attached, catalog = state.catalog.current) {
|
||||
return state.model ?? catalog.defaultModel
|
||||
}
|
||||
|
||||
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
|
||||
if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
|
||||
if (!state.catalog.current.modes.some((mode) => mode.id === modeID))
|
||||
throw new ACPError.InvalidModeError({ mode: modeID })
|
||||
state.modeID = modeID
|
||||
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
|
||||
}
|
||||
@@ -567,8 +535,9 @@ async function sendUsageUpdate(
|
||||
used?: number,
|
||||
) {
|
||||
if (!used) return
|
||||
const model = session.catalog.models.find(
|
||||
(item) => item.providerID === session.model.providerID && item.id === session.model.id,
|
||||
const current = currentModel(session)
|
||||
const model = session.catalog.current.models.find(
|
||||
(item) => item.providerID === current.providerID && item.id === current.id,
|
||||
)
|
||||
if (!model?.limit.context) return
|
||||
const info = await client.session.get({ sessionID: session.id })
|
||||
|
||||
@@ -183,6 +183,26 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("export", {
|
||||
description: "print stored credentials, including secrets, as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name (exports every integration when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("import", {
|
||||
description: "import credentials exported by auth export",
|
||||
params: {
|
||||
...ServerParams,
|
||||
file: Argument.string("file").pipe(
|
||||
Argument.withDescription("JSON file to import (reads stdin when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("switch", {
|
||||
description: "switch the active account for an integration",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { createClient, loadIntegrations, request, resolveIntegration } from "./shared"
|
||||
import { errorMessage } from "../../../util/error"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.export,
|
||||
Effect.fn("cli.auth.export")(
|
||||
function* (input) {
|
||||
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
|
||||
const target = Option.getOrUndefined(input.target)
|
||||
const integrationID = target ? (yield* resolveIntegration(yield* loadIntegrations(client), target)).id : undefined
|
||||
const credentials = (yield* request((signal) => client.credential.list({ signal }))).filter(
|
||||
(credential) => !integrationID || credential.integrationID === integrationID,
|
||||
)
|
||||
if (process.stdout.isTTY)
|
||||
process.stderr.write(
|
||||
"Warning: the output contains secrets; redirect it to a file or pipe it to auth import" + EOL,
|
||||
)
|
||||
process.stdout.write(JSON.stringify(credentials, null, 2) + EOL)
|
||||
},
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import { EOL } from "node:os"
|
||||
import { isConflictError, type CredentialCreateInput } from "@opencode/client"
|
||||
import { Credential } from "@opencode/schema/credential"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { createClient, request } from "./shared"
|
||||
import { errorMessage } from "../../../util/error"
|
||||
import { readStdin } from "../../../util/io"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.import,
|
||||
Effect.fn("cli.auth.import")(
|
||||
function* (input) {
|
||||
const file = Option.getOrUndefined(input.file)
|
||||
if (!file && process.stdin.isTTY)
|
||||
return yield* Effect.fail(new Error("Pipe auth export output into stdin or pass a file to import"))
|
||||
const text = yield* request(() => (file ? Bun.file(file).text() : readStdin()))
|
||||
const credentials = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Credential.Entry)))(text)
|
||||
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
|
||||
const existing = yield* request((signal) => client.credential.list({ signal }))
|
||||
const ids = new Set(existing.map((credential) => credential.id))
|
||||
const integrations = new Set(existing.map((credential) => credential.integrationID))
|
||||
const results = yield* Effect.forEach(credentials, (credential) => {
|
||||
if (ids.has(credential.id)) return Effect.succeed(false)
|
||||
return request((signal) =>
|
||||
client.credential.create(
|
||||
{
|
||||
id: credential.id,
|
||||
integrationID: credential.integrationID,
|
||||
label: credential.label,
|
||||
// Stored metadata is JSON, but the credential schema types it as unknown while the generated client expects JSON.
|
||||
value: credential.value as CredentialCreateInput["value"],
|
||||
// Keep the destination's current selections; only integrations new to it adopt the exported selection.
|
||||
activate: credential.active && !integrations.has(credential.integrationID),
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchIf(isConflictError, () => Effect.succeed(false)),
|
||||
)
|
||||
})
|
||||
const imported = results.filter(Boolean).length
|
||||
process.stderr.write(
|
||||
`Imported ${imported} ${imported === 1 ? "credential" : "credentials"}` +
|
||||
(results.length > imported ? `, skipped ${results.length - imported} already present` : "") +
|
||||
EOL,
|
||||
)
|
||||
},
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -4,12 +4,12 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.agents,
|
||||
Effect.fn("cli.debug.agents")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
|
||||
@@ -4,13 +4,13 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { redactConfig } from "./redact"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.config,
|
||||
Effect.fn("cli.debug.config")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(JSON.stringify(redactConfig(entries), null, 2) + EOL)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type IntegrationInfo, type IntegrationOAuthMethod, type McpSe
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { answerForm } from "../auth/form"
|
||||
@@ -21,7 +21,7 @@ export default Runtime.handler(
|
||||
const authenticate = Effect.fn("cli.mcp.auth.run")(function* (name?: string) {
|
||||
if (!name) yield* requireInteractive("Pass an MCP server name when running without an interactive terminal")
|
||||
intro("Authenticate an MCP server")
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const servers = yield* request((signal) => client.mcp.list({ location }, { signal }))
|
||||
|
||||
@@ -4,12 +4,12 @@ import { OpenCode, type McpServer } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.list,
|
||||
Effect.fn("cli.mcp.list")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
@@ -12,7 +12,7 @@ const location = { directory: process.cwd() }
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.logout,
|
||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
|
||||
@@ -10,6 +10,10 @@ import { ServiceConfig } from "../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.pair,
|
||||
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
|
||||
if ((yield* ServiceConfig.read()).disabled === true)
|
||||
return yield* Effect.fail(
|
||||
new Error("Pairing requires the background service; run `opencode service unset disabled` first"),
|
||||
)
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const urls = Option.isSome(input.url)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Config } from "../../../config"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export interface Item {
|
||||
readonly runtime: "Server" | "TUI"
|
||||
@@ -16,7 +16,7 @@ export interface Item {
|
||||
}
|
||||
|
||||
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const location = { directory: process.cwd() }
|
||||
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -16,7 +16,7 @@ import { discoverPluginTargets, localPluginDirectories, localSource } from "@ope
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
Effect.fn("cli.plugin.list")(function* (input) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
list: () => import("./commands/handlers/auth/list"),
|
||||
login: () => import("./commands/handlers/auth/login"),
|
||||
logout: () => import("./commands/handlers/auth/logout"),
|
||||
export: () => import("./commands/handlers/auth/export"),
|
||||
import: () => import("./commands/handlers/auth/import"),
|
||||
switch: () => import("./commands/handlers/auth/switch"),
|
||||
},
|
||||
debug: {
|
||||
|
||||
@@ -67,6 +67,12 @@ type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||
// attached client must not cancel input that may belong to another session.
|
||||
const GLOBAL_FORM_SESSION_ID = "global"
|
||||
|
||||
const QUESTION_CANCELLED_FEEDBACK =
|
||||
"This non-interactive run cannot ask the user questions, so the question was cancelled. Continue without an answer; make reasonable assumptions and state them."
|
||||
|
||||
const PERMISSION_REJECTED_FEEDBACK =
|
||||
"This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action."
|
||||
|
||||
export async function runNonInteractivePrompt(input: Input) {
|
||||
const controller = new AbortController()
|
||||
const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
@@ -132,10 +138,34 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
}
|
||||
|
||||
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
|
||||
// Subagents run in child sessions; their asks and questions belong to this run too. Other
|
||||
// sessions on a shared server (e.g. the TUI's) must be left alone.
|
||||
const owned = new Map<string, Promise<boolean>>([[input.sessionID, Promise.resolve(true)]])
|
||||
const ownsSession = (sessionID: string): Promise<boolean> => {
|
||||
const known = owned.get(sessionID)
|
||||
if (known) return known
|
||||
const result =
|
||||
sessionID === GLOBAL_FORM_SESSION_ID
|
||||
? Promise.resolve(false)
|
||||
: input.client.session
|
||||
.get({ sessionID })
|
||||
.then((session) => (session.parentID ? ownsSession(session.parentID) : false))
|
||||
.catch(() => false)
|
||||
owned.set(sessionID, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const replyPermission = async (request: {
|
||||
id: string
|
||||
sessionID: string
|
||||
action: string
|
||||
resources: ReadonlyArray<string>
|
||||
}) => {
|
||||
// Nobody can approve here. Outside V1 compatibility, reject with feedback so the tool fails
|
||||
// as ordinary model-visible output and the model continues without the action.
|
||||
const continuing = !input.auto && input.compatibility !== "v1"
|
||||
if (!input.auto) {
|
||||
permissionRejected = true
|
||||
if (input.compatibility !== "v1") process.exitCode = 1
|
||||
if (!continuing) permissionRejected = true
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
@@ -144,25 +174,34 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
await input.client.permission
|
||||
.reply({
|
||||
sessionID: input.sessionID,
|
||||
sessionID: request.sessionID,
|
||||
requestID: request.id,
|
||||
decision: input.auto ? "once" : "reject",
|
||||
...(continuing ? { message: PERMISSION_REJECTED_FEEDBACK } : {}),
|
||||
})
|
||||
.catch(() => {})
|
||||
if (!input.auto) {
|
||||
if (!input.auto && !continuing) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
|
||||
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID" | "metadata">) => {
|
||||
// Nobody can answer a question here. Outside V1 compatibility, cancel it with a message so the
|
||||
// question tool fails as ordinary model-visible output and the model continues without an answer.
|
||||
const continuing = request.metadata?.kind === "question" && input.compatibility !== "v1"
|
||||
try {
|
||||
await input.client.session.form.cancel(
|
||||
{ sessionID: request.sessionID, formID: request.id },
|
||||
{
|
||||
sessionID: request.sessionID,
|
||||
formID: request.id,
|
||||
...(continuing ? { message: QUESTION_CANCELLED_FEEDBACK } : {}),
|
||||
},
|
||||
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
|
||||
)
|
||||
} catch (error) {
|
||||
if (!formAlreadySettled(error)) throw error
|
||||
}
|
||||
if (continuing) return
|
||||
formCancelled = true
|
||||
if (input.compatibility !== "v1") process.exitCode = 1
|
||||
}
|
||||
@@ -179,14 +218,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
const event = next.value
|
||||
|
||||
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
if (event.type === "permission.asked" && submitted && (await ownsSession(event.data.sessionID))) {
|
||||
await replyPermission(event.data)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
event.type === "form.created" &&
|
||||
submitted &&
|
||||
(event.data.form.sessionID === input.sessionID ||
|
||||
((await ownsSession(event.data.form.sessionID)) ||
|
||||
(!input.attached &&
|
||||
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
|
||||
sameLocation(event.location, input.location)))
|
||||
|
||||
@@ -18,7 +18,7 @@ export type Resolved = {
|
||||
readonly service?: ReturnType<typeof managedService>
|
||||
}
|
||||
|
||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args = {}) {
|
||||
if (args.server !== undefined && args.standalone)
|
||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||
if (args.server !== undefined) {
|
||||
@@ -38,7 +38,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
)
|
||||
return { endpoint } satisfies Resolved
|
||||
}
|
||||
if (args.standalone) {
|
||||
if (args.standalone || (yield* ServiceConfig.read()).disabled === true) {
|
||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { selfCommand } from "../util/process"
|
||||
// registration file (by channel), which version, and how to spawn opencode.
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||
password: Schema.optional(Schema.String),
|
||||
@@ -20,7 +21,7 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const keys = ["hostname", "port", "password", "cors", "env"] as const
|
||||
const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const
|
||||
type Key = (typeof keys)[number]
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
@@ -78,7 +79,15 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
|
||||
})
|
||||
|
||||
function configKey(key: string): Key {
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
|
||||
if (
|
||||
key === "disabled" ||
|
||||
key === "hostname" ||
|
||||
key === "port" ||
|
||||
key === "password" ||
|
||||
key === "cors" ||
|
||||
key === "env"
|
||||
)
|
||||
return key
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
}
|
||||
|
||||
@@ -151,6 +160,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
|
||||
switch (selected) {
|
||||
case "disabled": {
|
||||
return String((yield* read()).disabled ?? false)
|
||||
}
|
||||
case "hostname": {
|
||||
return (yield* read()).hostname ?? ""
|
||||
}
|
||||
@@ -177,6 +189,12 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
|
||||
if (selected !== "env" && nestedValue !== undefined)
|
||||
throw new Error(`Usage: opencode service set ${selected} <value>`)
|
||||
switch (selected) {
|
||||
case "disabled": {
|
||||
if (value !== "true" && value !== "false") throw new Error("Disabled must be true or false")
|
||||
if (value === "true") yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), disabled: value === "true" })
|
||||
return
|
||||
}
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), hostname: value })
|
||||
@@ -221,6 +239,11 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
|
||||
switch (selected) {
|
||||
case "disabled": {
|
||||
const { disabled: _disabled, ...next } = yield* read()
|
||||
yield* write(next)
|
||||
return
|
||||
}
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
const { hostname: _hostname, ...next } = yield* read()
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionConfigOption, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { currentValue, requireSelectOption, selectValues } from "./select-options"
|
||||
import {
|
||||
buildAgent,
|
||||
ephemeralEvent,
|
||||
planAgent,
|
||||
reviewCommand,
|
||||
rpcError,
|
||||
secondModel,
|
||||
startSession,
|
||||
startWire,
|
||||
testModel,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
|
||||
describe("acp catalog and config options over the wire", () => {
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const first = await Promise.all([acp.newSession("/workspace"), acp.newSession("/workspace")])
|
||||
const other = await acp.newSession("/other")
|
||||
|
||||
expect(currentValue(first[0], "model")).toBe("test/test-model")
|
||||
expect(currentValue(first[0], "mode")).toBe("build")
|
||||
expect(
|
||||
(["model", "default", "agent", "command"] as const).map((kind) =>
|
||||
acp.server.catalogReads.filter((read) => read.kind === kind).map((read) => read.directory),
|
||||
),
|
||||
).toEqual(Array.from({ length: 4 }, () => ["/workspace", "/other"]))
|
||||
expect(
|
||||
Object.fromEntries([...acp.server.sessions.values()].map((session) => [session.id, session.location.directory])),
|
||||
).toEqual({
|
||||
[first[0].sessionId]: "/workspace",
|
||||
[first[1].sessionId]: "/workspace",
|
||||
[other.sessionId]: "/other",
|
||||
})
|
||||
await acp.until(() => acp.updates.filter((item) => commandNames(item)).length === 3, "commands for each session")
|
||||
expect(acp.updates.map(commandNames)).toEqual([["review"], ["review"], ["review"]])
|
||||
})
|
||||
|
||||
test("follows server defaults and refreshes the catalog when location plugins finish activating", async () => {
|
||||
const configured = { ...buildAgent, id: "copilot-build", name: "copilot-build" }
|
||||
await using acp = await startSession()
|
||||
expect(currentValue(acp.session, "mode")).toBe("build")
|
||||
expect(currentValue(acp.session, "model")).toBe("test/test-model")
|
||||
|
||||
const reads = agentReads(acp)
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/other" }))
|
||||
acp.server.catalog.agents = [configured, buildAgent, planAgent]
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/workspace" }))
|
||||
|
||||
const update = await acp.waitForUpdate((item) => item.update.sessionUpdate === "config_option_update")
|
||||
expect(update.update.sessionUpdate === "config_option_update" && modeOption(update.update.configOptions)).toEqual({
|
||||
currentValue: "copilot-build",
|
||||
options: ["copilot-build", "build", "plan"],
|
||||
})
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
|
||||
expect(commandNames(commands)).toEqual(["review", "ship"])
|
||||
expect(agentReads(acp)).toBe(reads + 1)
|
||||
|
||||
const second = await acp.newSession()
|
||||
expect(currentValue(second, "mode")).toBe("copilot-build")
|
||||
})
|
||||
|
||||
test("pushes config options on model.updated and commands on command.updated", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession()
|
||||
expect(selectValues(session.configOptions, "model")).toEqual(["test/test-model"])
|
||||
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
const options = await acp.waitForUpdate((item) => item.update.sessionUpdate === "config_option_update")
|
||||
expect(
|
||||
options.update.sessionUpdate === "config_option_update" && selectValues(options.update.configOptions, "model"),
|
||||
).toEqual(["test/second-model", "test/test-model"])
|
||||
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
|
||||
expect(commands).toEqual({
|
||||
sessionId: session.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate === "config_option_update")).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reloads the catalog before rejecting a model or mode it has not seen", async () => {
|
||||
const configured = { ...planAgent, id: "copilot-build", name: "copilot-build" }
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession()
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.catalog.agents = [buildAgent, planAgent, configured]
|
||||
|
||||
const model = await acp.request("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
await acp.request("session/set_mode", { sessionId: session.sessionId, modeId: "copilot-build" })
|
||||
const reads = agentReads(acp)
|
||||
const missing = await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId: "mode", value: "missing" }),
|
||||
)
|
||||
|
||||
expect(currentValue(model, "model")).toBe("test/second-model")
|
||||
expect(acp.server.selections).toContainEqual({ sessionID: session.sessionId, agent: "copilot-build" })
|
||||
expect(missing).toMatchObject({ code: -32602, data: { mode: "missing" } })
|
||||
expect(agentReads(acp)).toBeGreaterThan(reads)
|
||||
})
|
||||
|
||||
test.each(["empty", "missing the default"])(
|
||||
"retries when the model list is %s but the default is ready",
|
||||
async (initial) => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = initial === "empty" ? [] : [secondModel]
|
||||
acp.server.catalog.defaultModel = testModel
|
||||
await acp.initialize()
|
||||
|
||||
const created = acp.newSession()
|
||||
await acp.until(() => modelReads(acp) === 1, "first model read")
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
const session = await created
|
||||
|
||||
const choices = selectValues(session.configOptions, "model")
|
||||
expect(choices).toContain("test/second-model")
|
||||
expect(choices).toContain("test/test-model")
|
||||
expect(currentValue(session, "model")).toBe("test/test-model")
|
||||
expect(modelReads(acp)).toBe(2)
|
||||
},
|
||||
)
|
||||
|
||||
test("does not cache a failed catalog load", async () => {
|
||||
const failure = { pending: true }
|
||||
await using acp = await startWire({
|
||||
fetch(request) {
|
||||
if (request.path !== "/api/model" || !failure.pending) return undefined
|
||||
failure.pending = false
|
||||
return Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
|
||||
},
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603 })
|
||||
expect(acp.server.sessions.size).toBe(0)
|
||||
const retried = await acp.newSession()
|
||||
|
||||
expect(acp.server.sessions.has(retried.sessionId)).toBe(true)
|
||||
expect(modelReads(acp)).toBe(1)
|
||||
})
|
||||
|
||||
test("switches model, effort, and mode against the warm catalog", async () => {
|
||||
await using acp = await startSession()
|
||||
const sessionId = acp.sessionId
|
||||
const set = (configId: string, value: string) =>
|
||||
acp.request("session/set_config_option", { sessionId, configId, value })
|
||||
|
||||
const selectedModel = await set("model", "test/second-model")
|
||||
const selectedEffort = await set("effort", "medium")
|
||||
const selectedMode = await set("mode", "plan")
|
||||
await acp.request("session/set_mode", { sessionId, modeId: "build" })
|
||||
|
||||
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
||||
expect(currentValue(selectedModel, "effort")).toBe("default")
|
||||
expect(currentValue(selectedEffort, "effort")).toBe("medium")
|
||||
expect(currentValue(selectedMode, "mode")).toBe("plan")
|
||||
expect(acp.server.selections).toEqual([
|
||||
{ sessionID: sessionId, model: { providerID: "test", id: secondModel.id } },
|
||||
{ sessionID: sessionId, model: { providerID: "test", id: secondModel.id, variant: "medium" } },
|
||||
{ sessionID: sessionId, agent: "plan" },
|
||||
{ sessionID: sessionId, agent: "build" },
|
||||
])
|
||||
expect(modelReads(acp)).toBe(1)
|
||||
|
||||
expect(await rpcError(set("effort", "maximum"))).toMatchObject({ code: -32602, data: { effort: "maximum" } })
|
||||
expect(await rpcError(set("mode", "missing"))).toMatchObject({ code: -32602, data: { mode: "missing" } })
|
||||
expect(await rpcError(set("missing", "value"))).toMatchObject({ code: -32602, data: { configId: "missing" } })
|
||||
expect(await rpcError(set("model", "test/missing-model"))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { modelId: "test/missing-model" },
|
||||
})
|
||||
})
|
||||
|
||||
test.todo(
|
||||
"advertises the built-in compact command (https://github.com/anomalyco/opencode/issues/37229)",
|
||||
async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
|
||||
expect(commandNames(commands)).toContain("compact")
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function commandNames(item: SessionNotification) {
|
||||
if (item.update.sessionUpdate !== "available_commands_update") return undefined
|
||||
return item.update.availableCommands.map((command) => command.name)
|
||||
}
|
||||
|
||||
function agentReads(acp: Wire) {
|
||||
return acp.server.catalogReads.filter((read) => read.kind === "agent").length
|
||||
}
|
||||
|
||||
function modelReads(acp: Wire) {
|
||||
return acp.server.catalogReads.filter((read) => read.kind === "model").length
|
||||
}
|
||||
|
||||
function modeOption(options: SessionConfigOption[]) {
|
||||
const mode = requireSelectOption(options, "mode")
|
||||
return { currentValue: mode.currentValue, options: selectValues(options, "mode") }
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
alternateValue,
|
||||
createAcpFixture,
|
||||
expectOk,
|
||||
flattenSelectOptions,
|
||||
initialize,
|
||||
newSession,
|
||||
requireSelectOption,
|
||||
selectConfigOption,
|
||||
} from "./subprocess"
|
||||
import { alternateValue, flattenSelectOptions, requireSelectOption, selectConfigOption } from "./select-options"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp config option subprocess", () => {
|
||||
test('model option is listed with category "model"', async () => {
|
||||
|
||||
@@ -1,910 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { resolve } from "node:path"
|
||||
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
|
||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
|
||||
type Fixture = ReturnType<typeof createSseFixture>
|
||||
|
||||
describe("acp event behavior", () => {
|
||||
test("subscribes before admission and isolates sessions and input IDs", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_before",
|
||||
ordinal: 0,
|
||||
delta: "before admission",
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id }))
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" }))
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_wrong_input",
|
||||
ordinal: 0,
|
||||
delta: "wrong input",
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id }))
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_b",
|
||||
assistantMessageID: "msg_b",
|
||||
ordinal: 0,
|
||||
delta: "other session",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_a",
|
||||
ordinal: 0,
|
||||
delta: "accepted",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_a",
|
||||
assistantMessageID: "msg_a",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_b" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_a" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_a",
|
||||
inboxID: "input_a",
|
||||
})
|
||||
|
||||
expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
|
||||
"/api/event",
|
||||
"/api/session/ses_a/prompt",
|
||||
])
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
sessionId: "ses_a",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_a",
|
||||
content: { type: "text", text: "accepted" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves reasoning boundaries and update order during streaming and replay", async () => {
|
||||
const firstUpdate = Promise.withResolvers<void>()
|
||||
const releaseUpdate = Promise.withResolvers<void>()
|
||||
const allUpdates = Promise.withResolvers<void>()
|
||||
const releaseSubmit = Promise.withResolvers<void>()
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
async onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: id }))
|
||||
send(
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 0,
|
||||
delta: "think-1",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 0,
|
||||
delta: " continued",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 1,
|
||||
delta: "answer",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.reasoning.delta", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
ordinal: 1,
|
||||
delta: "think-2",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_order",
|
||||
assistantMessageID: "msg_order",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_order" }))
|
||||
await releaseSubmit.promise
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
if (updates.length === 1) {
|
||||
firstUpdate.resolve()
|
||||
await releaseUpdate.promise
|
||||
}
|
||||
if (updates.length === 4) allUpdates.resolve()
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
} satisfies Connection
|
||||
const result = turn({ fixture, connection, sessionID: "ses_order", inboxID: "input_order" })
|
||||
|
||||
try {
|
||||
await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
|
||||
|
||||
releaseUpdate.resolve()
|
||||
await withTimeout(allUpdates.promise, "ordered updates did not finish")
|
||||
expect(await Promise.race([result.then(() => "resolved"), Promise.resolve("pending")])).toBe("pending")
|
||||
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
|
||||
|
||||
releaseSubmit.resolve()
|
||||
const response = await withTimeout(result, "turn did not resolve after admission returned")
|
||||
|
||||
expect(
|
||||
updates.map((item) => {
|
||||
if (
|
||||
item.update.sessionUpdate === "agent_message_chunk" ||
|
||||
item.update.sessionUpdate === "agent_thought_chunk"
|
||||
) {
|
||||
return [
|
||||
item.update.sessionUpdate,
|
||||
item.update.messageId,
|
||||
item.update.content.type === "text" ? item.update.content.text : undefined,
|
||||
]
|
||||
}
|
||||
return [item.update.sessionUpdate, undefined]
|
||||
}),
|
||||
).toEqual([
|
||||
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
|
||||
["agent_message_chunk", "msg_order", "answer"],
|
||||
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
|
||||
])
|
||||
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
|
||||
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
|
||||
|
||||
const replayed: SessionUpdateParams[] = []
|
||||
await replayMessages(recordingConnection(replayed), "ses_order", "/workspace", [
|
||||
{
|
||||
id: "msg_order",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
time: { created: 1 },
|
||||
content: [
|
||||
{ type: "reasoning", text: "think-1 continued" },
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "reasoning", text: "think-2" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(replayed).toEqual([
|
||||
{
|
||||
sessionId: "ses_order",
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "msg_order:reasoning:0",
|
||||
content: { type: "text", text: "think-1 continued" },
|
||||
},
|
||||
},
|
||||
updates[2],
|
||||
updates[3],
|
||||
])
|
||||
} finally {
|
||||
releaseUpdate.resolve()
|
||||
releaseSubmit.resolve()
|
||||
await result.catch(() => undefined)
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("projects foreground child session updates onto the parent turn", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_child",
|
||||
...childSession("ses_child", "ses_parent", "Explore code"),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
input: { path: "/workspace/src/index.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
id: "call_read",
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: "source" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_parent",
|
||||
inboxID: "input_parent",
|
||||
})
|
||||
|
||||
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
|
||||
["ses_parent", "tool_call"],
|
||||
["ses_parent", "tool_call_update"],
|
||||
["ses_parent", "tool_call_update"],
|
||||
])
|
||||
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
|
||||
"ses_child:call_read",
|
||||
"ses_child:call_read",
|
||||
"ses_child:call_read",
|
||||
])
|
||||
expect(updates[0]?.update).toMatchObject({
|
||||
title: "Explore code: read",
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_child",
|
||||
parentID: "ses_parent",
|
||||
depth: 1,
|
||||
title: "Explore code",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("continues child extension updates after the parent turn ends", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const childUpdates: ChildSessionUpdate[] = []
|
||||
const completed = Promise.withResolvers<void>()
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_background",
|
||||
...childSession("ses_background", "ses_parent", "Background research"),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_parent",
|
||||
inboxID: "input_parent",
|
||||
childSessionUpdate: async (update) => {
|
||||
childUpdates.push(update)
|
||||
if (update.type === "status" && update.status === "completed") completed.resolve()
|
||||
},
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
|
||||
fixture.send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_future",
|
||||
...childSession("ses_future", "ses_parent", "Later turn child"),
|
||||
}),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
|
||||
fixture.send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
name: "shell",
|
||||
}),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
input: { command: "pwd" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
id: "call_shell",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "/workspace" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
|
||||
await withTimeout(completed.promise, "background child completion was not delivered")
|
||||
|
||||
expect(updates).toEqual([])
|
||||
expect(
|
||||
childUpdates.map((update) =>
|
||||
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
|
||||
),
|
||||
).toEqual([
|
||||
["status", "created"],
|
||||
["status", "running"],
|
||||
["update", "tool_call"],
|
||||
["update", "tool_call_update"],
|
||||
["update", "tool_call_update"],
|
||||
["status", "completed"],
|
||||
])
|
||||
expect(childUpdates[2]).toMatchObject({
|
||||
rootSessionId: "ses_parent",
|
||||
childSessionId: "ses_background",
|
||||
parentSessionId: "ses_parent",
|
||||
depth: 1,
|
||||
title: "Background research",
|
||||
type: "update",
|
||||
update: { toolCallId: "ses_background:call_shell" },
|
||||
})
|
||||
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("streams tool pending, progress, success, and failure updates", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
name: "shell",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
input: { command: "printf done", workdir: "sub" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
metadata: { phase: 1 },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_ok",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
name: "read",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
metadata: { bytes: 0 },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.failed", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
id: "call_fail",
|
||||
error: { type: "tool.error", message: "not found" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: tokens(),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_tools",
|
||||
inboxID: "input_tools",
|
||||
})
|
||||
|
||||
expect(
|
||||
updates.map((item) => [
|
||||
item.update.sessionUpdate,
|
||||
"status" in item.update ? item.update.status : undefined,
|
||||
"toolCallId" in item.update ? item.update.toolCallId : undefined,
|
||||
]),
|
||||
).toEqual([
|
||||
["tool_call", "pending", "call_ok"],
|
||||
["tool_call_update", "in_progress", "call_ok"],
|
||||
["tool_call_update", "in_progress", "call_ok"],
|
||||
["tool_call_update", "completed", "call_ok"],
|
||||
["tool_call", "pending", "call_fail"],
|
||||
["tool_call_update", "in_progress", "call_fail"],
|
||||
["tool_call_update", "in_progress", "call_fail"],
|
||||
["tool_call_update", "failed", "call_fail"],
|
||||
])
|
||||
expect(updates[1]?.update).toMatchObject({
|
||||
title: "printf done",
|
||||
kind: "execute",
|
||||
locations: [{ path: resolve("/workspace", "sub") }],
|
||||
rawInput: { command: "printf done", workdir: "sub" },
|
||||
})
|
||||
expect(updates[2]?.update).not.toHaveProperty("content")
|
||||
expect(updates[3]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[7]?.update).toMatchObject({
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/missing.ts" }],
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "opening" } },
|
||||
{ type: "content", content: { type: "text", text: "not found" } },
|
||||
],
|
||||
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("replays user, text, reasoning, and tool messages in order", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const messages = replayFixtureMessages()
|
||||
const connection = {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
} satisfies Pick<AgentSideConnection, "sessionUpdate">
|
||||
|
||||
await replayMessages(connection, "ses_replay", "/workspace", messages)
|
||||
|
||||
expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
|
||||
expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
|
||||
"user_message_chunk",
|
||||
"user_message_chunk",
|
||||
"user_message_chunk",
|
||||
"agent_message_chunk",
|
||||
"agent_thought_chunk",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
])
|
||||
expect(updates[1]?.update).toMatchObject({
|
||||
content: {
|
||||
type: "resource_link",
|
||||
uri: "file:///workspace/note.md",
|
||||
name: "note.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
})
|
||||
expect(updates[2]?.update).toMatchObject({
|
||||
content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
|
||||
})
|
||||
expect(updates[6]?.update).toMatchObject({
|
||||
toolCallId: "call_done",
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "done" } },
|
||||
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
|
||||
],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[8]?.update).toMatchObject({
|
||||
toolCallId: "call_running",
|
||||
status: "in_progress",
|
||||
title: "pwd",
|
||||
locations: [{ path: "/workspace" }],
|
||||
})
|
||||
expect(updates[10]?.update).toMatchObject({
|
||||
toolCallId: "call_failed",
|
||||
status: "failed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "partial" } },
|
||||
{ type: "content", content: { type: "text", text: "failed hard" } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("continues replay after a session update callback rejects", async () => {
|
||||
const attempts: Array<[string, string]> = []
|
||||
const connection = {
|
||||
sessionUpdate: async (params) => {
|
||||
if (params.update.sessionUpdate !== "tool_call" && params.update.sessionUpdate !== "tool_call_update") return
|
||||
attempts.push([params.update.toolCallId, params.update.sessionUpdate])
|
||||
if (params.update.toolCallId === "call_first" && params.update.sessionUpdate === "tool_call_update") {
|
||||
throw new Error("replay send failed")
|
||||
}
|
||||
},
|
||||
} satisfies Pick<AgentSideConnection, "sessionUpdate">
|
||||
|
||||
await replayMessages(connection, "ses_replay_failure", "/workspace", [
|
||||
replayToolMessage("call_first"),
|
||||
replayToolMessage("call_after"),
|
||||
])
|
||||
|
||||
expect(attempts).toEqual([
|
||||
["call_first", "tool_call"],
|
||||
["call_first", "tool_call_update"],
|
||||
["call_after", "tool_call"],
|
||||
["call_after", "tool_call_update"],
|
||||
])
|
||||
})
|
||||
|
||||
test("returns cancelled after an admitted turn is interrupted", async () => {
|
||||
const submitted = Promise.withResolvers<void>()
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
|
||||
},
|
||||
onInterrupt({ sessionID, send }) {
|
||||
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
|
||||
return true
|
||||
},
|
||||
})
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection: recordingConnection([]),
|
||||
sessionID: "ses_cancel",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: async (signal) => {
|
||||
await fixture.client.session.prompt(
|
||||
{ sessionID: "ses_cancel", id: "input_cancel", text: "cancel me" },
|
||||
{ signal },
|
||||
)
|
||||
submitted.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
expect(await fixture.client.session.interrupt({ sessionID: "ses_cancel" })).toEqual({ interrupted: true })
|
||||
|
||||
const response = await withTimeout(result, "cancelled turn did not terminate")
|
||||
expect(response).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("returns cancelled when admission is aborted before promotion", async () => {
|
||||
const submitted = Promise.withResolvers<void>()
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ signal }) {
|
||||
submitted.resolve()
|
||||
return new Promise<void>((resolve) => {
|
||||
if (signal.aborted) return resolve()
|
||||
signal.addEventListener("abort", () => resolve(), { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection: recordingConnection([]),
|
||||
sessionID: "ses_cancel_admission",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel_admission" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: (signal) =>
|
||||
fixture.client.session.prompt(
|
||||
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
|
||||
{ signal },
|
||||
),
|
||||
})
|
||||
|
||||
try {
|
||||
await withTimeout(submitted.promise, "cancel test prompt was not submitted")
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
|
||||
const response = await withTimeout(result, "pre-admission cancellation did not terminate")
|
||||
expect(response).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
|
||||
} finally {
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
await result.catch(() => undefined)
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels unsupported session forms so execution can continue", async () => {
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id }))
|
||||
send(
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_question",
|
||||
sessionID: "ses_form",
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "q0", title: "Choice", type: "string" }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
onFormCancel({ sessionID, formID, send }) {
|
||||
send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection([]),
|
||||
sessionID: "ses_form",
|
||||
inboxID: "input_form",
|
||||
})
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(
|
||||
fixture.requests.some(
|
||||
(request) => request.method === "DELETE" && request.path === "/api/session/ses_form/form/frm_question",
|
||||
),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function recordingConnection(updates: SessionUpdateParams[]) {
|
||||
return {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
} satisfies Connection
|
||||
}
|
||||
|
||||
function turn(input: {
|
||||
readonly fixture: Fixture
|
||||
readonly connection: Connection
|
||||
readonly sessionID: string
|
||||
readonly inboxID: string
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
}) {
|
||||
return streamTurn({
|
||||
client: input.fixture.client,
|
||||
connection: input.connection,
|
||||
sessionID: input.sessionID,
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: input.inboxID },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
childSessionUpdate: input.childSessionUpdate,
|
||||
submit: (signal) =>
|
||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
|
||||
})
|
||||
}
|
||||
|
||||
function childSession(id: string, parentID: string, title: string) {
|
||||
return {
|
||||
slug: id,
|
||||
projectID: "project",
|
||||
location: { directory: "/workspace" },
|
||||
parentID,
|
||||
title,
|
||||
version: "test",
|
||||
}
|
||||
}
|
||||
|
||||
function tokens() {
|
||||
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
}
|
||||
|
||||
function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
return [
|
||||
{
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
time: { created: 1 },
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "text/markdown",
|
||||
name: "note.md",
|
||||
source: { type: "uri", uri: "file:///workspace/note.md" },
|
||||
},
|
||||
{
|
||||
data: "aGVsbG8=",
|
||||
mime: "text/plain",
|
||||
name: "inline.txt",
|
||||
source: { type: "inline" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_done",
|
||||
name: "shell",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_running",
|
||||
name: "shell",
|
||||
time: { created: 2, ran: 2 },
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "pwd" },
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_failed",
|
||||
name: "read",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "error",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
error: { type: "tool.error", message: "failed hard" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_streaming",
|
||||
name: "shell",
|
||||
time: { created: 2 },
|
||||
state: { status: "streaming", input: '{"command":' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function replayToolMessage(id: string) {
|
||||
return {
|
||||
id: `msg_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
time: { created: 1, completed: 2 },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id,
|
||||
name: "shell",
|
||||
time: { created: 1, completed: 2 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies SessionMessageInfo
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { streamTurn } from "../../src/acp/event"
|
||||
|
||||
test("acp prompt resolves after ordered turn updates", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
let events: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
events = controller
|
||||
send(controller, { id: "evt_connected", type: "server.connected", data: {} })
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_test/prompt") {
|
||||
const body: unknown = await request.json()
|
||||
if (!body || typeof body !== "object") {
|
||||
return new Response(null, { status: 400 })
|
||||
}
|
||||
const id = "id" in body ? body.id : undefined
|
||||
if (typeof id !== "string") return new Response(null, { status: 400 })
|
||||
queueMicrotask(() => {
|
||||
if (!events) return
|
||||
send(events, {
|
||||
id: "evt_promoted",
|
||||
created: 1,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_test", inboxID: id },
|
||||
})
|
||||
send(events, {
|
||||
id: "evt_text",
|
||||
created: 2,
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hello" },
|
||||
})
|
||||
send(events, {
|
||||
id: "evt_step",
|
||||
created: 3,
|
||||
type: "session.step.ended",
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_assistant",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
})
|
||||
send(events, {
|
||||
id: "evt_done",
|
||||
created: 4,
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_test" },
|
||||
})
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_test/message/msg_assistant") {
|
||||
return Response.json({
|
||||
data: {
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
finish: "stop",
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, completed: 4 },
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
const client = OpenCode.make({ baseUrl: server.url.toString() })
|
||||
|
||||
try {
|
||||
const id = "msg_prompt"
|
||||
const response = await streamTurn({
|
||||
client,
|
||||
connection: {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
sessionID: "ses_test",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
|
||||
})
|
||||
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
sessionId: "ses_test",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_assistant",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
|
||||
} finally {
|
||||
events?.close()
|
||||
await server.stop(true)
|
||||
}
|
||||
|
||||
function send(controller: ReadableStreamDefaultController<Uint8Array>, event: unknown) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
}
|
||||
})
|
||||
|
||||
test("acp action resolves without prompt lifecycle events", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
if (new URL(request.url).pathname !== "/api/event") return new Response(null, { status: 404 })
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "server.connected", data: {} })}\n\n`))
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await streamTurn({
|
||||
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||
connection: {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
sessionID: "ses_test",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "msg_action" },
|
||||
writeTextFile: false,
|
||||
action: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: async () => {},
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
ResumeSessionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption } from "./subprocess"
|
||||
import { selectConfigOption } from "./select-options"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
@@ -98,4 +99,30 @@ describe("acp lifecycle subprocess", () => {
|
||||
|
||||
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
|
||||
}, 60_000)
|
||||
|
||||
// The private server is found with `pgrep`, which Windows lacks.
|
||||
const todoOutsideWindows = process.platform === "win32" ? test.skip : test.todo
|
||||
todoOutsideWindows(
|
||||
"exits when the private server process dies (https://github.com/anomalyco/opencode/issues/51716)",
|
||||
async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
await newSession(acp, fixture.home)
|
||||
const servers = Bun.spawnSync(["pgrep", "-P", String(acp.pid)])
|
||||
.stdout.toString()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
expect(servers).toHaveLength(1)
|
||||
|
||||
process.kill(servers[0], "SIGKILL")
|
||||
|
||||
const timeout = Promise.withResolvers<"running">()
|
||||
const timer = setTimeout(() => timeout.resolve("running"), 10_000)
|
||||
const exited = await Promise.race([acp.exited, timeout.promise]).finally(() => clearTimeout(timer))
|
||||
expect(exited).not.toBe("running")
|
||||
},
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -1,634 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type { ACPConnection } from "../../src/acp/connection"
|
||||
import { streamTurn } from "../../src/acp/event"
|
||||
import { syncEditedFiles } from "../../src/acp/permission"
|
||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||
|
||||
type SessionUpdateParams = SessionNotification
|
||||
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission" | "writeTextFile">
|
||||
type Fixture = ReturnType<typeof createSseFixture>
|
||||
|
||||
describe("acp permission behavior", () => {
|
||||
test("does not sync edits when writeTextFile was not advertised", async () => {
|
||||
const writes: WriteTextFileRequest[] = []
|
||||
|
||||
await syncEditedFiles({
|
||||
connection: {
|
||||
writeTextFile: async (input) => {
|
||||
writes.push(input)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
writeTextFile: false,
|
||||
sessionID: "ses_no_write",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
toolInput: { filePath: "/workspace/file.ts" },
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
test("forwards allow-once and allow-always selections to the generated client", async () => {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_allow", inboxID: id }))
|
||||
send(
|
||||
permissionAsked("ses_allow", "perm_once", {
|
||||
action: "shell",
|
||||
metadata: { command: "printf hello" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
|
||||
}),
|
||||
)
|
||||
send(
|
||||
permissionAsked("ses_allow", "perm_always", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always",
|
||||
},
|
||||
}
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_allow", "input_allow")
|
||||
|
||||
expect(permissionRequests[0]).toMatchObject({
|
||||
sessionId: "ses_allow",
|
||||
toolCall: {
|
||||
toolCallId: "call_once",
|
||||
status: "pending",
|
||||
title: "printf hello",
|
||||
kind: "execute",
|
||||
locations: [{ path: "/workspace" }],
|
||||
rawInput: { command: "printf hello", cwd: "/workspace" },
|
||||
},
|
||||
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(permissionRequests[1]).toMatchObject({
|
||||
sessionId: "ses_allow",
|
||||
toolCall: {
|
||||
toolCallId: "call_always",
|
||||
status: "pending",
|
||||
title: "/workspace/file.ts",
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/file.ts" }],
|
||||
rawInput: { path: "/workspace/file.ts" },
|
||||
},
|
||||
})
|
||||
expect(permissionReplies(fixture)).toEqual([
|
||||
["perm_once", "once"],
|
||||
["perm_always", "always"],
|
||||
])
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves external directory permission context", async () => {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_external", inboxID: id }))
|
||||
send(
|
||||
permissionAsked("ses_external", "perm_external", {
|
||||
action: "external_directory",
|
||||
metadata: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_external" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_external", "input_external")
|
||||
|
||||
expect(permissionRequests[0]?.toolCall).toMatchObject({
|
||||
title: "Create external directory",
|
||||
locations: [{ path: "/tmp/outside" }],
|
||||
rawInput: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_child",
|
||||
slug: "ses_child",
|
||||
projectID: "project",
|
||||
location: { directory: "/workspace" },
|
||||
parentID: "ses_parent",
|
||||
title: "Review code",
|
||||
version: "test",
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||
send(
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/child.ts" },
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_parent", "input_parent")
|
||||
|
||||
expect(permissionRequests).toHaveLength(1)
|
||||
expect(permissionRequests[0]).toMatchObject({
|
||||
sessionId: "ses_parent",
|
||||
toolCall: {
|
||||
toolCallId: "ses_child:call_child",
|
||||
title: "Review code: /workspace/child.ts",
|
||||
},
|
||||
})
|
||||
expect(fixture.requests).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/session/ses_child/permission/perm_child/reply",
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("previews edits during approval and syncs the completed file", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
||||
const file = path.join(cwd, "file.ts")
|
||||
await fs.writeFile(file, "before")
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const writes: WriteTextFileRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
id: "call_edit",
|
||||
name: "edit",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
id: "call_edit",
|
||||
input: { path: "file.ts", oldString: "before", newString: "after" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
permissionAsked("ses_edit", "perm_edit", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
async onPermissionReply({ send }) {
|
||||
await fs.writeFile(file, "after")
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
id: "call_edit",
|
||||
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||
content: [{ type: "text", text: "edited" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_edit" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||
},
|
||||
writeTextFile: async (request) => {
|
||||
writes.push(request)
|
||||
return {}
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_edit", "input_edit", cwd)
|
||||
|
||||
expect(permissionRequests[0]?.toolCall).toMatchObject({
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: "file.ts" }],
|
||||
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
|
||||
})
|
||||
expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
await fs.rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("previews and syncs each file in a patch", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
|
||||
fs.writeFile(path.join(cwd, "second.ts"), "alpha\n"),
|
||||
])
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: first.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** Update File: second.ts",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+beta",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const writes: WriteTextFileRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: id }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
id: "call_patch",
|
||||
name: "patch",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
id: "call_patch",
|
||||
input: { patchText },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
permissionAsked("ses_patch", "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
async onPermissionReply({ send }) {
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(cwd, "first.ts"), "two\n"),
|
||||
fs.writeFile(path.join(cwd, "second.ts"), "beta\n"),
|
||||
])
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_patch",
|
||||
id: "call_patch",
|
||||
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
|
||||
content: [{ type: "text", text: "patched" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_patch" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||
},
|
||||
writeTextFile: async (request) => {
|
||||
writes.push(request)
|
||||
return {}
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_patch", "input_patch", cwd)
|
||||
|
||||
expect(permissionRequests[0]?.toolCall).toMatchObject({
|
||||
title: "2 files",
|
||||
kind: "edit",
|
||||
locations: [{ path: "first.ts" }, { path: "second.ts" }],
|
||||
content: [
|
||||
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
|
||||
],
|
||||
})
|
||||
expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
|
||||
{ sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
|
||||
{ sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
|
||||
])
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
await fs.rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_reject", inboxID: id }))
|
||||
send(permissionAsked("ses_reject", "perm_selected_reject"))
|
||||
send(permissionAsked("ses_reject", "perm_cancelled"))
|
||||
send(permissionAsked("ses_reject", "perm_failed"))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_reject" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request): Promise<RequestPermissionResponse> => {
|
||||
if (request.toolCall.toolCallId === "perm_selected_reject") {
|
||||
return { outcome: { outcome: "selected", optionId: "reject" } }
|
||||
}
|
||||
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
|
||||
throw new Error("client permission UI failed")
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
const response = await startTurn(fixture, connection, "ses_reject", "input_reject")
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(permissionReplies(fixture)).toEqual([
|
||||
["perm_selected_reject", "reject"],
|
||||
["perm_cancelled", "reject"],
|
||||
["perm_failed", "reject"],
|
||||
])
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("serializes permission requests and replies within one session", async () => {
|
||||
const firstRequested = Promise.withResolvers<void>()
|
||||
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_serial", inboxID: id }))
|
||||
send(permissionAsked("ses_serial", "perm_1"))
|
||||
send(permissionAsked("ses_serial", "perm_2"))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
if (request.toolCall.toolCallId === "perm_1") {
|
||||
firstRequested.resolve()
|
||||
return releaseFirst.promise
|
||||
}
|
||||
return { outcome: { outcome: "selected", optionId: "always" } } as const
|
||||
},
|
||||
} satisfies Connection
|
||||
const result = startTurn(fixture, connection, "ses_serial", "input_serial")
|
||||
|
||||
try {
|
||||
await withTimeout(firstRequested.promise, "first permission was not requested")
|
||||
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
|
||||
expect(permissionReplies(fixture)).toEqual([])
|
||||
|
||||
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
await withTimeout(result, "serialized permission turn did not finish")
|
||||
|
||||
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
|
||||
expect(permissionReplies(fixture)).toEqual([
|
||||
["perm_1", "once"],
|
||||
["perm_2", "always"],
|
||||
])
|
||||
} finally {
|
||||
releaseFirst.resolve({ outcome: { outcome: "cancelled" } })
|
||||
await result.catch(() => undefined)
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not let one session's blocked permission stall another session", async () => {
|
||||
const blockedRequested = Promise.withResolvers<void>()
|
||||
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
|
||||
const promptIDs = new Map<string, string>()
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ sessionID, id, send }) {
|
||||
promptIDs.set(sessionID, id)
|
||||
if (promptIDs.size !== 2) return
|
||||
const blockedID = promptIDs.get("ses_blocked")
|
||||
const freeID = promptIDs.get("ses_free")
|
||||
if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_blocked", inboxID: blockedID }))
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_free", inboxID: freeID }))
|
||||
send(permissionAsked("ses_blocked", "perm_blocked"))
|
||||
send(
|
||||
ephemeralEvent("session.text.delta", {
|
||||
sessionID: "ses_free",
|
||||
assistantMessageID: "msg_free",
|
||||
ordinal: 0,
|
||||
delta: "session B continued",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.step.ended", {
|
||||
sessionID: "ses_free",
|
||||
assistantMessageID: "msg_free",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_free" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_blocked" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => {
|
||||
blockedRequested.resolve()
|
||||
return releaseBlocked.promise
|
||||
},
|
||||
} satisfies Connection
|
||||
const blocked = startTurn(fixture, connection, "ses_blocked", "input_blocked")
|
||||
const free = startTurn(fixture, connection, "ses_free", "input_free")
|
||||
|
||||
try {
|
||||
await withTimeout(blockedRequested.promise, "blocked permission was not requested")
|
||||
const response = await withTimeout(free, "free session was stalled by another session's permission")
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(updates).toContainEqual({
|
||||
sessionId: "ses_free",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_free",
|
||||
content: { type: "text", text: "session B continued" },
|
||||
},
|
||||
})
|
||||
expect(permissionReplies(fixture)).toEqual([])
|
||||
|
||||
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
await withTimeout(blocked, "blocked session did not resume after permission selection")
|
||||
expect(permissionReplies(fixture)).toEqual([["perm_blocked", "once"]])
|
||||
} finally {
|
||||
releaseBlocked.resolve({ outcome: { outcome: "cancelled" } })
|
||||
await Promise.all([blocked.catch(() => undefined), free.catch(() => undefined)])
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
|
||||
const requested = Promise.withResolvers<AbortSignal | undefined>()
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
|
||||
send(permissionAsked("ses_cancel", "perm_cancel"))
|
||||
},
|
||||
onPermissionReply({ send }) {
|
||||
send(durableEvent("session.execution.interrupted", { sessionID: "ses_cancel", reason: "user" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
// Behaves like a client answering the agent's `$/cancel_request` with a cancelled outcome.
|
||||
requestPermission: (_request, options) =>
|
||||
new Promise<RequestPermissionResponse>((resolve) => {
|
||||
options?.cancellationSignal?.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), {
|
||||
once: true,
|
||||
})
|
||||
requested.resolve(options?.cancellationSignal)
|
||||
}),
|
||||
} satisfies Connection
|
||||
const control = { cancelled: false, admission: new AbortController() }
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection,
|
||||
sessionID: "ses_cancel",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: (signal) =>
|
||||
fixture.client.session.prompt({ sessionID: "ses_cancel", id: "input_cancel", text: "hello" }, { signal }),
|
||||
})
|
||||
|
||||
try {
|
||||
const signal = await withTimeout(requested.promise, "permission was not requested")
|
||||
expect(signal?.aborted).toBe(false)
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
|
||||
expect(await withTimeout(result, "cancelled turn did not finish")).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(permissionReplies(fixture)).toEqual([["perm_cancel", "reject"]])
|
||||
} finally {
|
||||
await result.catch(() => undefined)
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") {
|
||||
return streamTurn({
|
||||
client: fixture.client,
|
||||
connection,
|
||||
sessionID,
|
||||
cwd,
|
||||
start: { type: "input", id: inboxID },
|
||||
writeTextFile: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
|
||||
})
|
||||
}
|
||||
|
||||
function permissionAsked(
|
||||
sessionID: string,
|
||||
id: string,
|
||||
input: {
|
||||
readonly action?: string
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
} = {},
|
||||
) {
|
||||
return ephemeralEvent("permission.asked", {
|
||||
id,
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
resources: ["*"],
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function permissionReplies(fixture: Fixture) {
|
||||
return fixture.requests.flatMap((request): Array<[string, string]> => {
|
||||
const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
|
||||
if (!match?.[1] || !request.body || typeof request.body !== "object") return []
|
||||
const reply = "decision" in request.body ? request.body.decision : undefined
|
||||
return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
interrupted,
|
||||
permissionAsked,
|
||||
startSession,
|
||||
startWire,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
toolCalled,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
turn,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const allowOnce = () => ({ outcome: { outcome: "selected", optionId: "once" } }) as const
|
||||
|
||||
describe("acp permissions over the wire", () => {
|
||||
test("forwards allow-once and allow-always selections to the server", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_once", {
|
||||
action: "shell",
|
||||
metadata: { command: "printf hello" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_always", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
}),
|
||||
),
|
||||
permission: (request) => ({
|
||||
outcome: { outcome: "selected", optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always" },
|
||||
}),
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "call_once",
|
||||
status: "pending",
|
||||
title: "printf hello",
|
||||
kind: "execute",
|
||||
locations: [{ path: "/workspace" }],
|
||||
rawInput: { command: "printf hello", cwd: "/workspace" },
|
||||
},
|
||||
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(acp.permissions[1]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "call_always",
|
||||
status: "pending",
|
||||
title: "/workspace/file.ts",
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/file.ts" }],
|
||||
rawInput: { path: "/workspace/file.ts" },
|
||||
},
|
||||
})
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_once", "once"],
|
||||
["perm_always", "always"],
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves external directory permission context", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_external", {
|
||||
action: "external_directory",
|
||||
metadata: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "Create external directory",
|
||||
locations: [{ path: "/tmp/outside" }],
|
||||
rawInput: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/child.ts" },
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions).toHaveLength(1)
|
||||
expect(acp.permissions[0]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts" },
|
||||
})
|
||||
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
|
||||
})
|
||||
|
||||
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_selected_reject"),
|
||||
permissionAsked(sessionID, "perm_cancelled"),
|
||||
permissionAsked(sessionID, "perm_failed"),
|
||||
),
|
||||
permission(request) {
|
||||
if (request.toolCall.toolCallId === "perm_selected_reject") {
|
||||
return { outcome: { outcome: "selected", optionId: "reject" } }
|
||||
}
|
||||
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
|
||||
throw new Error("client permission UI failed")
|
||||
},
|
||||
})
|
||||
|
||||
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_selected_reject", "reject"],
|
||||
["perm_cancelled", "reject"],
|
||||
["perm_failed", "reject"],
|
||||
])
|
||||
})
|
||||
|
||||
test("serializes permission requests and replies within one session", async () => {
|
||||
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, permissionAsked(sessionID, "perm_1"), permissionAsked(sessionID, "perm_2")),
|
||||
permission: (request) =>
|
||||
request.toolCall.toolCallId === "perm_1"
|
||||
? releaseFirst.promise
|
||||
: { outcome: { outcome: "selected", optionId: "always" } },
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.permissions.length === 1, "first permission")
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
|
||||
expect(acp.server.replies).toEqual([])
|
||||
|
||||
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
await prompt
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_1", "once"],
|
||||
["perm_2", "always"],
|
||||
])
|
||||
})
|
||||
|
||||
test("does not let one session's blocked permission stall another session", async () => {
|
||||
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startWire({ onPrompt: () => undefined, permission: () => releaseBlocked.promise })
|
||||
await acp.initialize()
|
||||
const blockedSession = await acp.newSession()
|
||||
const freeSession = await acp.newSession()
|
||||
|
||||
const blocked = acp.prompt(blockedSession.sessionId, "hello")
|
||||
const free = acp.prompt(freeSession.sessionId, "hello")
|
||||
const [blockedPrompt, freePrompt] = await acp.until(
|
||||
() => acp.server.prompts.length === 2 && acp.server.prompts,
|
||||
"both prompt submissions",
|
||||
)
|
||||
acp.server.send(
|
||||
delivered(blockedPrompt.sessionID, blockedPrompt.id),
|
||||
delivered(freePrompt.sessionID, freePrompt.id),
|
||||
permissionAsked(blockedPrompt.sessionID, "perm_blocked"),
|
||||
textDelta(freePrompt.sessionID, "msg_free", "session B continued"),
|
||||
stepEnded(freePrompt.sessionID, "msg_free"),
|
||||
succeeded(freePrompt.sessionID),
|
||||
succeeded(blockedPrompt.sessionID),
|
||||
)
|
||||
await acp.until(() => acp.permissions.length === 1, "blocked permission")
|
||||
|
||||
expect(await free).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.updates).toContainEqual({
|
||||
sessionId: freePrompt.sessionID,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_free",
|
||||
content: { type: "text", text: "session B continued" },
|
||||
},
|
||||
})
|
||||
expect(acp.server.replies).toEqual([])
|
||||
|
||||
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
expect(await blocked).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(decisions(acp)).toEqual([["perm_blocked", "once"]])
|
||||
})
|
||||
|
||||
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), permissionAsked(sessionID, "perm_cancel")],
|
||||
onPermissionReply: ({ sessionID }) => [interrupted(sessionID)],
|
||||
permission: (_request, signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
|
||||
}),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.permissions.length === 1, "permission request")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(decisions(acp)).toEqual([["perm_cancel", "reject"]])
|
||||
const asked = acp.received.find(
|
||||
(message): message is AnyRequest =>
|
||||
"method" in message && "id" in message && message.method === "session/request_permission",
|
||||
)
|
||||
expect(acp.received).toContainEqual({
|
||||
jsonrpc: "2.0",
|
||||
method: "$/cancel_request",
|
||||
params: { requestId: asked?.id },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp edit previews and client file sync over the wire", () => {
|
||||
test("previews edits during approval and syncs the completed file", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "before")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "before", newString: "after" }),
|
||||
permissionAsked(sessionID, "perm_edit", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: async ({ sessionID }) => {
|
||||
await fs.writeFile(file, "after")
|
||||
return [
|
||||
toolSucceeded(sessionID, "call_edit", { files: [{ file: "file.ts" }], replacements: 1 }, "edited"),
|
||||
succeeded(sessionID),
|
||||
]
|
||||
},
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: "file.ts" }],
|
||||
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
|
||||
})
|
||||
expect(acp.writes).toEqual([{ sessionId: session.sessionId, path: file, content: "after" }])
|
||||
})
|
||||
|
||||
test("previews and syncs each file in a patch", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(dir.path, "first.ts"), "one\n"),
|
||||
fs.writeFile(path.join(dir.path, "second.ts"), "alpha\n"),
|
||||
])
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: first.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** Update File: second.ts",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+beta",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
permissionAsked(sessionID, "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: async ({ sessionID }) => {
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(dir.path, "first.ts"), "two\n"),
|
||||
fs.writeFile(path.join(dir.path, "second.ts"), "beta\n"),
|
||||
])
|
||||
return [
|
||||
toolSucceeded(sessionID, "call_patch", { files: [{ file: "first.ts" }, { file: "second.ts" }] }, "patched"),
|
||||
succeeded(sessionID),
|
||||
]
|
||||
},
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "2 files",
|
||||
kind: "edit",
|
||||
locations: [{ path: "first.ts" }, { path: "second.ts" }],
|
||||
content: [
|
||||
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
|
||||
],
|
||||
})
|
||||
expect(acp.writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
|
||||
{ sessionId: session.sessionId, path: path.join(dir.path, "first.ts"), content: "two\n" },
|
||||
{ sessionId: session.sessionId, path: path.join(dir.path, "second.ts"), content: "beta\n" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not sync edits when the client did not advertise writeTextFile", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "file.ts"), "after")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { filePath: path.join(dir.path, "file.ts") }),
|
||||
toolSucceeded(sessionID, "call_edit", {}, "edited"),
|
||||
),
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.writes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function decisions(acp: Wire) {
|
||||
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { StopReason } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import {
|
||||
assistantMessage,
|
||||
delivered,
|
||||
durableEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
makeSession,
|
||||
rpcError,
|
||||
startSession,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
tokens,
|
||||
turn,
|
||||
type Wire,
|
||||
type WireOptions,
|
||||
} from "./wire-fixture"
|
||||
|
||||
// A "hold" prompt is admitted and starts streaming, then only finishes when interrupted.
|
||||
const held = {
|
||||
onPrompt: ({ sessionID, id, text }) =>
|
||||
text === "hold" ? [delivered(sessionID, id), textDelta(sessionID, "msg_held", "working")] : turn(sessionID, id),
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
} satisfies WireOptions
|
||||
|
||||
describe("acp prompt turns over the wire", () => {
|
||||
test("streams an admitted turn and resolves with usage after its terminal event", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, textDelta(sessionID, "msg_assistant", "hello"), stepEnded(sessionID, "msg_assistant")),
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hi")
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
_meta: {},
|
||||
})
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate === "agent_message_chunk")).toEqual([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_assistant",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{
|
||||
kind: "prompt",
|
||||
sessionID: acp.sessionId,
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
text: "hi",
|
||||
files: [],
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("routes slash commands and compact through their session endpoints", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
const command = await acp.prompt(acp.sessionId, "/review now")
|
||||
const compact = await acp.prompt(acp.sessionId, "/compact")
|
||||
|
||||
expect([command.stopReason, compact.stopReason]).toEqual(["end_turn", "end_turn"])
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{ kind: "command", sessionID: acp.sessionId, name: "review", text: "now", files: [], delivery: "steer" },
|
||||
{ kind: "compact", sessionID: acp.sessionId, id: expect.stringMatching(/^msg_/) },
|
||||
])
|
||||
})
|
||||
|
||||
test("submits assistant-only context as synthetic input before the visible prompt", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "visible" },
|
||||
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
|
||||
{ type: "resource_link", uri: "file:///workspace/README.md", name: "README.md", mimeType: "text/markdown" },
|
||||
])
|
||||
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{
|
||||
kind: "synthetic",
|
||||
sessionID: acp.sessionId,
|
||||
text: "hidden context",
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
},
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: "visible",
|
||||
files: [{ uri: "file:///workspace/README.md", name: "README.md" }],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
|
||||
const assistantTokens = { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } }
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, stepEnded(sessionID, "msg_assistant", { tokens: assistantTokens })),
|
||||
})
|
||||
await acp.request("session/set_config_option", {
|
||||
sessionId: acp.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
acp.server.sessions.set(acp.sessionId, makeSession(acp.sessionId, { cost: 3.5 }))
|
||||
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant", { tokens: assistantTokens })])
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
thoughtTokens: 7,
|
||||
cachedReadTokens: 11,
|
||||
cachedWriteTokens: 13,
|
||||
totalTokens: 171,
|
||||
},
|
||||
_meta: {},
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "usage_update", used: 171, size: 200_000, cost: { amount: 3.5, currency: "USD" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("completes the prompt when reads after admission fail", async () => {
|
||||
const reads = { failing: false }
|
||||
await using acp = await startSession({
|
||||
fetch: (request) => (reads.failing && request.method === "GET" ? new Response(null, { status: 500 }) : undefined),
|
||||
onPrompt: ({ sessionID, id }) => {
|
||||
reads.failing = true
|
||||
return turn(sessionID, id, stepEnded(sessionID, "msg_assistant"))
|
||||
},
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test.each<{ name: string; stopReason: StopReason; events: (sessionID: string) => OpenCodeEvent[] }>([
|
||||
{ name: "a normal stop", stopReason: "end_turn", events: (id) => [stepEnded(id, "msg"), succeeded(id)] },
|
||||
{
|
||||
name: "a length-limited step",
|
||||
stopReason: "max_tokens",
|
||||
events: (id) => [stepEnded(id, "msg", { finish: "length" }), succeeded(id)],
|
||||
},
|
||||
{
|
||||
name: "a content-filtered step",
|
||||
stopReason: "refusal",
|
||||
events: (id) => [stepEnded(id, "msg", { finish: "content-filter" }), succeeded(id)],
|
||||
},
|
||||
{
|
||||
name: "a content-filter failure",
|
||||
stopReason: "refusal",
|
||||
events: (id) => [failed(id, { type: "provider.content-filter", message: "blocked" })],
|
||||
},
|
||||
{
|
||||
name: "a server-side interruption",
|
||||
stopReason: "cancelled",
|
||||
events: (id) => [durableEvent("session.execution.interrupted", { sessionID: id, reason: "shutdown" })],
|
||||
},
|
||||
])("maps $name to $stopReason", async (input) => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), ...input.events(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe(input.stopReason)
|
||||
})
|
||||
|
||||
test("maps provider auth failures to auth required", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
failed(sessionID, { type: "provider.auth", message: "missing key" }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
|
||||
test("maps an assistant message auth error to auth required", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, textDelta(sessionID, "msg_auth", "partial"), stepEnded(sessionID, "msg_auth")),
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [
|
||||
assistantMessage("msg_auth", { error: { type: "provider.auth", message: "expired" } }),
|
||||
])
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
|
||||
test("surfaces other execution failures as internal errors with the failure message", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
failed(sessionID, { type: "provider.rate-limit", message: "slow down" }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({
|
||||
code: -32603,
|
||||
message: expect.stringContaining("slow down"),
|
||||
})
|
||||
})
|
||||
|
||||
test("reports provider retries while pending and clears them when the next step starts", async () => {
|
||||
const at = Date.UTC(2026, 0, 1)
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
retryScheduled(sessionID, 2, at),
|
||||
durableEvent("session.step.started", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_retry",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
started: at,
|
||||
}),
|
||||
stepEnded(sessionID, "msg_retry"),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate === "session_info_update")).toEqual([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": retryMeta(2, at) } },
|
||||
},
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", _meta: { "opencode/retry": null } },
|
||||
},
|
||||
])
|
||||
expect(response._meta).toEqual({})
|
||||
})
|
||||
|
||||
test("reports the pending retry on a turn cancelled during backoff", async () => {
|
||||
const at = Date.UTC(2026, 0, 1)
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), retryScheduled(sessionID, 1, at)],
|
||||
onInterrupt: held.onInterrupt,
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: { "opencode/retry": retryMeta(1, at) } })
|
||||
})
|
||||
|
||||
test("session/cancel before admission aborts the submission and returns cancelled", async () => {
|
||||
const aborted = Promise.withResolvers<void>()
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ signal }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted.resolve()
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.server.submissions.length === 1, "prompt submission")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
|
||||
await aborted.promise
|
||||
expect(acp.server.interrupts).toContain(acp.sessionId)
|
||||
})
|
||||
|
||||
test("session/cancel mid-turn interrupts the session once, returns cancelled, and keeps it usable", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold")
|
||||
await admitted(acp, acp.sessionId)
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("$/cancel_request on the prompt request cancels the turn like session/cancel", async () => {
|
||||
await using acp = await startSession(held)
|
||||
const controller = new AbortController()
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold", controller.signal)
|
||||
await admitted(acp, acp.sessionId)
|
||||
controller.abort()
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("session/close settles the active turn before responding and detaches only that session", async () => {
|
||||
await using acp = await startSession(held)
|
||||
const other = await acp.newSession()
|
||||
|
||||
const order: string[] = []
|
||||
const prompt = acp.prompt(acp.sessionId, "hold").then((response) => {
|
||||
order.push("prompt")
|
||||
return response
|
||||
})
|
||||
await admitted(acp, acp.sessionId)
|
||||
const close = await acp.request("session/close", { sessionId: acp.sessionId }).then((response) => {
|
||||
order.push("close")
|
||||
return response
|
||||
})
|
||||
|
||||
expect(close).toEqual({})
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(order).toEqual(["prompt", "close"])
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "again"))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: acp.sessionId },
|
||||
})
|
||||
expect((await acp.prompt(other.sessionId, "still here")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("rejects a second prompt while the session already has an active turn", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const first = acp.prompt(acp.sessionId, "hold")
|
||||
await acp.until(() => acp.server.submissions.length === 1, "first prompt")
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "second"))).toMatchObject({ code: -32603 })
|
||||
expect(acp.server.submissions).toHaveLength(1)
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
expect((await first).stopReason).toBe("cancelled")
|
||||
})
|
||||
|
||||
test.todo(
|
||||
"reports usage summed across every step of the turn (https://github.com/anomalyco/opencode/issues/41660)",
|
||||
async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
stepEnded(sessionID, "msg_step_1", { finish: "tool-calls", tokens: { ...tokens(), input: 10, output: 5 } }),
|
||||
stepEnded(sessionID, "msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [
|
||||
assistantMessage("msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
])
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// The server answered admission before streaming the chunk, and this request round-trips through the server after it,
|
||||
// so the agent has observed admission before the test cancels.
|
||||
async function admitted(acp: Wire, sessionId: string) {
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk")
|
||||
await acp.request("session/set_mode", { sessionId, modeId: "build" })
|
||||
}
|
||||
|
||||
function retryScheduled(sessionID: string, attempt: number, at: number) {
|
||||
return durableEvent("session.retry.scheduled", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_retry",
|
||||
attempt,
|
||||
at,
|
||||
error: { type: "provider.rate-limit", message: "rate limited" },
|
||||
})
|
||||
}
|
||||
|
||||
function retryMeta(attempt: number, at: number) {
|
||||
return {
|
||||
attempt,
|
||||
nextRetryAt: new Date(at).toISOString(),
|
||||
error: { type: "provider.rate-limit", message: "rate limited" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { assistantMessage, makeSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp session replay over the wire", () => {
|
||||
test("replays user, text, reasoning, and tool messages in order on session/load", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_replay", makeSession("ses_replay"))
|
||||
acp.server.messages.set("ses_replay", replayFixtureMessages())
|
||||
await acp.initialize()
|
||||
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_replay", mcpServers: [] })
|
||||
|
||||
const updates = acp.updates.filter((item) => item.update.sessionUpdate !== "available_commands_update")
|
||||
expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
|
||||
expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
|
||||
"user_message_chunk",
|
||||
"user_message_chunk",
|
||||
"user_message_chunk",
|
||||
"agent_message_chunk",
|
||||
"agent_thought_chunk",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
])
|
||||
expect(updates[1]?.update).toMatchObject({
|
||||
content: { type: "resource_link", uri: "file:///workspace/note.md", name: "note.md", mimeType: "text/markdown" },
|
||||
})
|
||||
expect(updates[2]?.update).toMatchObject({
|
||||
content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
|
||||
})
|
||||
expect(updates[4]?.update).toMatchObject({ messageId: "msg_assistant:reasoning:0" })
|
||||
expect(updates[6]?.update).toMatchObject({
|
||||
toolCallId: "call_done",
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "done" } },
|
||||
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
|
||||
],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(updates[8]?.update).toMatchObject({
|
||||
toolCallId: "call_running",
|
||||
status: "in_progress",
|
||||
title: "pwd",
|
||||
locations: [{ path: "/workspace" }],
|
||||
})
|
||||
expect(updates[10]?.update).toMatchObject({
|
||||
toolCallId: "call_failed",
|
||||
status: "failed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "partial" } },
|
||||
{ type: "content", content: { type: "text", text: "failed hard" } },
|
||||
],
|
||||
})
|
||||
expect(updates[11]?.update).toMatchObject({ toolCallId: "call_streaming", status: "pending", rawInput: {} })
|
||||
})
|
||||
|
||||
test("continues replay after one message fails to translate", async () => {
|
||||
await using acp = await startWire({
|
||||
fetch(request) {
|
||||
if (request.path !== "/api/session/ses_replay_failure/message") return undefined
|
||||
return Response.json({
|
||||
data: [
|
||||
replayToolMessage("call_first", { status: "error", input: {}, metadata: {} }),
|
||||
replayToolMessage("call_after", {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
}),
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
},
|
||||
})
|
||||
acp.server.sessions.set("ses_replay_failure", makeSession("ses_replay_failure"))
|
||||
await acp.initialize()
|
||||
|
||||
const loaded = await acp.request("session/load", {
|
||||
cwd: "/workspace",
|
||||
sessionId: "ses_replay_failure",
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(loaded.configOptions).toBeDefined()
|
||||
expect(
|
||||
acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
|
||||
? [[item.update.toolCallId, item.update.sessionUpdate]]
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
["call_first", "tool_call"],
|
||||
["call_after", "tool_call"],
|
||||
["call_after", "tool_call_update"],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function replayToolMessage(id: string, state: Record<string, unknown>) {
|
||||
return {
|
||||
...assistantMessage(`msg_${id}`),
|
||||
content: [{ type: "tool", id, name: "shell", time: { created: 1, completed: 2 }, state }],
|
||||
}
|
||||
}
|
||||
|
||||
function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
return [
|
||||
{
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
time: { created: 1 },
|
||||
files: [
|
||||
{ data: "", mime: "text/markdown", name: "note.md", source: { type: "uri", uri: "file:///workspace/note.md" } },
|
||||
{ data: "aGVsbG8=", mime: "text/plain", name: "inline.txt", source: { type: "inline" } },
|
||||
],
|
||||
},
|
||||
assistantMessage("msg_assistant", {
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
{ type: "text", text: "answer" },
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_done",
|
||||
name: "shell",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_running",
|
||||
name: "shell",
|
||||
time: { created: 2, ran: 2 },
|
||||
state: { status: "running", input: { command: "pwd" }, metadata: {} },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_failed",
|
||||
name: "read",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "error",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
error: { type: "tool.error", message: "failed hard" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_streaming",
|
||||
name: "shell",
|
||||
time: { created: 2 },
|
||||
state: { status: "streaming", input: '{"command":' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk"
|
||||
|
||||
type SelectOption = Extract<SessionConfigOption, { type: "select" }>
|
||||
|
||||
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
return options?.find((option): option is SelectOption => option.id === id && option.type === "select")
|
||||
}
|
||||
|
||||
export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
const option = selectConfigOption(options, id)
|
||||
if (!option) throw new Error(`Missing ACP config option: ${id}`)
|
||||
return option
|
||||
}
|
||||
|
||||
export function flattenSelectOptions(option: SelectOption) {
|
||||
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
|
||||
}
|
||||
|
||||
export function selectValues(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
return flattenSelectOptions(requireSelectOption(options, id)).map((option) => option.value)
|
||||
}
|
||||
|
||||
export function alternateValue(option: SelectOption) {
|
||||
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
|
||||
if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
|
||||
return value
|
||||
}
|
||||
|
||||
export function currentValue(
|
||||
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
|
||||
id: string,
|
||||
) {
|
||||
return result?.configOptions?.find((option) => option.id === id)?.currentValue
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
|
||||
import { flattenSelectOptions, requireSelectOption } from "./subprocess"
|
||||
|
||||
describe("acp service directory behavior", () => {
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
let created = 0
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method !== "POST" || request.path !== "/api/session") return undefined
|
||||
created++
|
||||
return Response.json({
|
||||
data: makeSession(`ses_${created}`, { cwd: created <= 2 ? "/workspace" : "/other" }),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const first = await Promise.all([
|
||||
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
|
||||
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
|
||||
])
|
||||
const other = await fixture.service.newSession({ cwd: "/other", mcpServers: [] })
|
||||
|
||||
expect(first.map((session) => session.sessionId).toSorted()).toEqual(["ses_1", "ses_2"])
|
||||
expect(other.sessionId).toBe("ses_3")
|
||||
expect(currentValue(first[0], "model")).toBe("test/test-model")
|
||||
expect(currentValue(first[0], "mode")).toBe("build")
|
||||
expect(
|
||||
[
|
||||
"/api/model",
|
||||
"/api/model/default",
|
||||
"/api/agent",
|
||||
"/api/command",
|
||||
].map((path) =>
|
||||
fixture.requests
|
||||
.filter((request) => request.path === path)
|
||||
.map((request) => request.query["location[directory]"]),
|
||||
),
|
||||
).toEqual([
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.method === "POST" && request.path === "/api/session")
|
||||
.map((request) => request.body),
|
||||
).toEqual([
|
||||
{
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model", variant: "default" },
|
||||
location: { directory: "/workspace" },
|
||||
},
|
||||
{
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model", variant: "default" },
|
||||
location: { directory: "/workspace" },
|
||||
},
|
||||
{
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model", variant: "default" },
|
||||
location: { directory: "/other" },
|
||||
},
|
||||
])
|
||||
expect(
|
||||
fixture.updates.map((item) =>
|
||||
item.update.sessionUpdate === "available_commands_update"
|
||||
? item.update.availableCommands.map((command) => command.name)
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
["review"],
|
||||
["review"],
|
||||
["review"],
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["empty", "missing the default"])(
|
||||
"retries when the model list is %s but the default is ready",
|
||||
async (initial) => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (
|
||||
request.path === "/api/model" &&
|
||||
context.requests.filter((request) => request.path === "/api/model").length === 1
|
||||
) {
|
||||
return Response.json({
|
||||
location: {
|
||||
directory: "/workspace",
|
||||
project: { id: "global", directory: "/workspace", canonical: "/workspace" },
|
||||
},
|
||||
data: initial === "empty" ? [] : [secondModel],
|
||||
})
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_ready") })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
const model = requireSelectOption(session.configOptions, "model")
|
||||
const choices = flattenSelectOptions(model).map((option) => option.value)
|
||||
|
||||
expect(choices).toContain("test/second-model")
|
||||
expect(choices).toContain("test/test-model")
|
||||
expect(model.currentValue).toBe("test/test-model")
|
||||
expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(2)
|
||||
},
|
||||
)
|
||||
|
||||
test("does not cache a failed catalog load", async () => {
|
||||
let modelCalls = 0
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.path === "/api/model") {
|
||||
modelCalls++
|
||||
if (modelCalls === 1) {
|
||||
return Response.json(
|
||||
{ name: "ModelsNotReadyError", data: { message: "catalog is warming" } },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_retry") })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const failure = await fixture.service
|
||||
.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ name: "ModelsNotReadyError" })
|
||||
const retried = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
expect(retried.sessionId).toBe("ses_retry")
|
||||
expect(modelCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("switches model, effort, and mode against the warm catalog", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_config") })
|
||||
}
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
(request.path === "/api/session/ses_config/model" || request.path === "/api/session/ses_config/agent")
|
||||
) {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const selectedModel = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
const selectedEffort = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "medium",
|
||||
})
|
||||
const selectedMode = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
})
|
||||
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
||||
|
||||
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
||||
expect(currentValue(selectedModel, "effort")).toBe("default")
|
||||
expect(currentValue(selectedEffort, "effort")).toBe("medium")
|
||||
expect(currentValue(selectedMode, "mode")).toBe("plan")
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.path === "/api/session/ses_config/model")
|
||||
.map((request) => request.body),
|
||||
).toEqual([
|
||||
{ model: { providerID: "test", id: secondModel.id } },
|
||||
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.path === "/api/session/ses_config/agent")
|
||||
.map((request) => request.body),
|
||||
).toEqual([{ agent: "plan" }, { agent: "build" }])
|
||||
expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(1)
|
||||
|
||||
const invalidEffort = await fixture.service
|
||||
.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "maximum",
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
const invalidMode = await fixture.service
|
||||
.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: "missing",
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
const invalidConfig = await fixture.service
|
||||
.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "missing",
|
||||
value: "value",
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
expect(invalidEffort).toMatchObject({ _tag: "ACPInvalidEffortError" })
|
||||
expect(invalidMode).toMatchObject({ _tag: "ACPInvalidModeError" })
|
||||
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
|
||||
})
|
||||
|
||||
test("converts MCP configs and deduplicates registrations per session and config", async () => {
|
||||
const local: McpServer = {
|
||||
name: "tools",
|
||||
command: "bun",
|
||||
args: ["server.ts"],
|
||||
env: [{ name: "TOKEN", value: "x" }],
|
||||
}
|
||||
const changed: McpServer = { ...local, args: ["changed.ts"] }
|
||||
const remote: McpServer = {
|
||||
type: "http",
|
||||
name: "docs",
|
||||
url: "https://example.com/mcp",
|
||||
headers: [{ name: "Authorization", value: "Bearer x" }],
|
||||
}
|
||||
let created = 0
|
||||
const mcp = "/api/experimental/mcp/"
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
created++
|
||||
return Response.json({ data: makeSession(`ses_${created}`) })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_1") {
|
||||
return Response.json({ data: makeSession("ses_1") })
|
||||
}
|
||||
if (request.method === "PUT" && request.path.startsWith(mcp)) {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, local, remote] })
|
||||
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
|
||||
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
|
||||
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
|
||||
|
||||
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith(mcp))
|
||||
expect(adds).toHaveLength(4)
|
||||
expect(adds.filter((request) => request.path === `${mcp}tools`).map((request) => request.body)).toEqual([
|
||||
{
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["bun", "server.ts"],
|
||||
environment: { TOKEN: "x" },
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["bun", "changed.ts"],
|
||||
environment: { TOKEN: "x" },
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["bun", "server.ts"],
|
||||
environment: { TOKEN: "x" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(adds.find((request) => request.path === `${mcp}docs`)?.body).toEqual({
|
||||
config: {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
headers: { Authorization: "Bearer x" },
|
||||
oauth: false,
|
||||
},
|
||||
})
|
||||
expect(adds.map((request) => request.query)).toEqual([
|
||||
{ "location[directory]": "/workspace" },
|
||||
{ "location[directory]": "/workspace" },
|
||||
{ "location[directory]": "/workspace" },
|
||||
{ "location[directory]": "/workspace" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function currentValue(
|
||||
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
|
||||
id: string,
|
||||
) {
|
||||
return result?.configOptions?.find((option) => option.id === id)?.currentValue
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import {
|
||||
OpenCode,
|
||||
type AgentInfo,
|
||||
type CommandInfo,
|
||||
type ModelInfo,
|
||||
type ModelRef,
|
||||
type SessionInfo,
|
||||
type TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { ACPService } from "../../src/acp/service"
|
||||
|
||||
export type FixtureRequest = {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly query: Record<string, string>
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
export type FixtureContext = {
|
||||
readonly requests: FixtureRequest[]
|
||||
send(event: unknown): void
|
||||
}
|
||||
|
||||
type FixtureHandler = (
|
||||
request: FixtureRequest,
|
||||
context: FixtureContext,
|
||||
) => Response | undefined | Promise<Response | undefined>
|
||||
|
||||
type FixtureOptions = {
|
||||
readonly fetch?: FixtureHandler
|
||||
readonly models?: readonly ModelInfo[]
|
||||
readonly defaultModel?: ModelInfo
|
||||
readonly agents?: readonly AgentInfo[]
|
||||
readonly commands?: readonly CommandInfo[]
|
||||
}
|
||||
|
||||
export const testModel = {
|
||||
id: "test-model",
|
||||
modelID: "test-model",
|
||||
providerID: "test",
|
||||
name: "Test Model",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [{ id: "default" }, { id: "high" }],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 100_000, output: 10_000 },
|
||||
} satisfies ModelInfo
|
||||
|
||||
export const secondModel = {
|
||||
id: "second-model",
|
||||
modelID: "second-model",
|
||||
providerID: "test",
|
||||
name: "Second Model",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [{ id: "low" }, { id: "medium" }],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 200_000, output: 20_000 },
|
||||
} satisfies ModelInfo
|
||||
|
||||
export const buildAgent = {
|
||||
id: "build",
|
||||
name: "Build",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
} satisfies AgentInfo
|
||||
|
||||
export const planAgent = {
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Plan first",
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
} satisfies AgentInfo
|
||||
|
||||
export const reviewCommand = {
|
||||
name: "review",
|
||||
description: "Review changes",
|
||||
} satisfies CommandInfo
|
||||
|
||||
export function makeSession(
|
||||
id: string,
|
||||
input: {
|
||||
readonly cwd?: string
|
||||
readonly agent?: string
|
||||
readonly model?: ModelRef
|
||||
readonly cost?: number
|
||||
readonly tokens?: TokenUsageInfo
|
||||
readonly time?: SessionInfo["time"]
|
||||
readonly title?: string
|
||||
} = {},
|
||||
): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
projectID: "global",
|
||||
agent: input.agent ?? "build",
|
||||
model: input.model ?? { providerID: "test", id: "test-model", variant: "default" },
|
||||
cost: input.cost ?? 0,
|
||||
tokens: input.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: input.time ?? { created: 0, updated: 0 },
|
||||
title: input.title ?? `Session ${id}`,
|
||||
location: { directory: input.cwd ?? "/workspace" },
|
||||
}
|
||||
}
|
||||
|
||||
export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
const requests: FixtureRequest[] = []
|
||||
const updates: SessionNotification[] = []
|
||||
const encoder = new TextEncoder()
|
||||
let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const models = options.models ?? [testModel, secondModel]
|
||||
const context: FixtureContext = {
|
||||
requests,
|
||||
send(event) {
|
||||
if (!eventController) throw new Error("ACP fixture has no active event stream")
|
||||
eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
},
|
||||
}
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(raw) {
|
||||
const url = new URL(raw.url)
|
||||
const request: FixtureRequest = {
|
||||
method: raw.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
body: raw.method === "GET" || raw.method === "HEAD" ? undefined : await raw.json().catch(() => undefined),
|
||||
}
|
||||
requests.push(request)
|
||||
const response = await options.fetch?.(request, context)
|
||||
if (response) return response
|
||||
|
||||
const directory = request.query["location[directory]"] ?? "/workspace"
|
||||
const location = { directory, project: { id: "global", directory } }
|
||||
if (request.path === "/api/event") {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value
|
||||
eventController = value
|
||||
context.send({ id: "evt_connected", type: "server.connected", data: {} })
|
||||
},
|
||||
cancel() {
|
||||
if (eventController === controller) eventController = undefined
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
if (request.path === "/api/model") return Response.json({ location, data: models })
|
||||
if (request.path === "/api/model/default") {
|
||||
return Response.json({ location, data: options.defaultModel ?? models[0] ?? null })
|
||||
}
|
||||
if (request.path === "/api/agent") {
|
||||
return Response.json({ location, data: options.agents ?? [buildAgent, planAgent] })
|
||||
}
|
||||
if (request.path === "/api/command") {
|
||||
return Response.json({ location, data: options.commands ?? [reviewCommand] })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
const service = ACPService.make({
|
||||
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||
connection: {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
service,
|
||||
requests,
|
||||
updates,
|
||||
async [Symbol.asyncDispose]() {
|
||||
eventController?.close()
|
||||
await server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user