mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-04 06:26:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f674c8eff |
No files matched your search
@@ -2,9 +2,9 @@ name: nix-eval
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, v2]
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [dev, v2]
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -122,7 +122,7 @@
|
||||
"@clack/core": "1.0.0-alpha.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/pty": "0.2.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -133,7 +133,6 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
@@ -179,7 +178,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -205,7 +204,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -218,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -254,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -281,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -298,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -322,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -342,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -355,7 +354,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/client": "2.0.0",
|
||||
"@modelcontextprotocol/core": "2.0.0",
|
||||
"@opencode-ai/pty": "0.2.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/codemode": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
@@ -365,7 +364,7 @@
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"bun-pty": "0.4.9",
|
||||
"bun-pty": "0.4.8",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -411,7 +410,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -456,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -493,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -509,7 +508,7 @@
|
||||
},
|
||||
"packages/gui-extensions": {
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -554,7 +553,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -573,7 +572,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -586,7 +585,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -600,7 +599,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -615,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -654,7 +653,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -684,7 +683,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -699,7 +698,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -723,7 +722,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -744,7 +743,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -766,7 +765,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -801,7 +800,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -821,7 +820,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -855,7 +854,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -874,7 +873,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -920,7 +919,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -934,7 +933,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -968,7 +967,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1003,7 +1002,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1041,7 +1040,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1082,7 +1081,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -2167,17 +2166,19 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="],
|
||||
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.2.0", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.2.0", "@opencode-ai/pty-darwin-x64": "0.2.0", "@opencode-ai/pty-linux-arm64-gnu": "0.2.0", "@opencode-ai/pty-linux-arm64-musl": "0.2.0", "@opencode-ai/pty-linux-x64-gnu": "0.2.0", "@opencode-ai/pty-linux-x64-musl": "0.2.0" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-pV86urAwinpwFXX8AJlOf8S9CVJhGsV+11I/J6TcxMW0c7u6LgeODzdj/BVBC6jUhsG2aKDxg8rACMcDCy3HiA=="],
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.13", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.13", "@opencode-ai/pty-darwin-x64": "0.1.13", "@opencode-ai/pty-linux-arm64-gnu": "0.1.13", "@opencode-ai/pty-linux-arm64-musl": "0.1.13", "@opencode-ai/pty-linux-x64-gnu": "0.1.13", "@opencode-ai/pty-linux-x64-musl": "0.1.13" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-WPCN8h8HaZhhUcrMG0zu+4D9vco0EZiEg/gCF1K3JPRN6UsHMiXq1HVIy5IlyfcoyjfViRmQmXYE4AuU3laBjA=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2y6xrktb2J7rRk+mzAJHA8cCbWhC4Lo2zJ66t9ad59qFhL5nzAPfpEOwnyvvFqhxebHNvL08Mp64M9IHnM0aiA=="],
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fVtQZqVLBuJx/aB+5ojfmQifS1KMc9gxlxpFQ6bxEFU8tn8xHQTiFPaNroZgOtaw7I4ceGyx/eXieK1wp68yAA=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XVQwK9+KgVGYunCYPCCBf1Or0z6zSkzjgfdJd7dEe/LOFg5vmMkOfSB9dCXnoRCWGviWYk7xd07iFIFOgyj5Ig=="],
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/tAEm0hCMXraPM9cxR8Rg7X1UBZInRTaxWAS4Ht9eH1nWj1rANOLvHWiWX/vVh5TB0Ubg8bWPu4B0nZkEHROQ=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TPVGCQk5E65IY3ipcpd17rwKehdvXcXEYRhhGzok/6Dske69ei2S+ERz7NXZ8cKFAvbiiT771HHM+XyVYAl+7A=="],
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-I124aSYBBjpGZnYExHfIajkvVK1FiK+//OJBGdqqFp5pas2Oruq4O8tv+pMoxomZIYh2ce/QhOOYLHRwXsthTg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iH6/liY7xN1OXVD9eGzdH11BVGvnpPsH5Z1Unz0Pn9EzkPFf28oDNKNyeXV0xIAP53oiYp8gpRq2PADNwbVsTg=="],
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-feWsfKpaDytGJzutoK43GqQwVghG2vHZt6BE/ydPZNuqIrySQ/6JfliUAMwn5BWs/Ky7ouSwKHCyAVeukusSvg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GhdrmbUzxGHRfWvt1qutgVD4HLb7aSBsgWGjFsc6k8HjO5ZbdaC3ScRyd512YAAG5KAROLKZ6+0SEBsm5xFYlA=="],
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-jliNgsevGuxfIeX7eyzjHhrJkF8uEUPnDLbF2v16uv69FhEHrraf7jyWkxazMP6rNvn2CGtwMMc4BXPS5pzjhg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-rXDpidW66gz2b2M/NbUN8ZKmAxaJcASnuHATeXevlrFdiPUv8uJwvkRd6Pla1fp01Q65MkBmgRa7Q9c+H1PlzA=="],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
|
||||
|
||||
@@ -3547,7 +3548,7 @@
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.9", "", {}, "sha512-IUF/B3FANo8vIQ775Zt7Er7lphMpYMhLkes45am2WE8FaVI7KRYtj1rQwliZ18b6GpNzBNWcl7sz9QT5wDFBeQ=="],
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
|
||||
+2
-22
@@ -13,12 +13,7 @@
|
||||
opencode,
|
||||
}:
|
||||
let
|
||||
electronPin =
|
||||
(lib.pipe ../packages/desktop/package.json [
|
||||
builtins.readFile
|
||||
builtins.fromJSON
|
||||
]).devDependencies.electron;
|
||||
electron = callPackage ./electron.nix { inherit electronPin; };
|
||||
electron = callPackage ./electron.nix { };
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "opencode-desktop";
|
||||
@@ -71,7 +66,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
''
|
||||
# https://github.com/electron/electron/issues/31121
|
||||
# mac builds use a .app bundle which doesnt have this issue
|
||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
+ lib.optionalString stdenv.isLinux ''
|
||||
substituteInPlace \
|
||||
packages/desktop/src/main/windows/appearance.ts \
|
||||
packages/desktop/src/main/service/desktop-cli.ts \
|
||||
@@ -79,7 +74,6 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
'';
|
||||
|
||||
preBuild = ''
|
||||
echo "electron ${electron.version} from nixpkgs ${lib.version}, package.json pins ${electronPin}"
|
||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||
chmod -R u+w $HOME/.electron-dist
|
||||
|
||||
@@ -95,15 +89,8 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
|
||||
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode/", ""))')
|
||||
# copyBuiltCliToResources joins this dist with the npm package name getCurrentCli()
|
||||
# reports, not the Nix build's name. It reads only .version from the manifest and
|
||||
# writes it as opencode-cli.version beside the binary.
|
||||
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
|
||||
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode"
|
||||
# OPENCODE_VERSION is what the bundled CLI prints for --version, so the manifest
|
||||
# and the executable cannot drift.
|
||||
bun -e 'await Bun.write(process.argv[1], JSON.stringify({ version: process.env.OPENCODE_VERSION }) + "\n")' \
|
||||
"$OPENCODE_CLI_DIST/$cli_package/package.json"
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
@@ -151,13 +138,6 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
"libc.musl-x86_64.so.1"
|
||||
];
|
||||
|
||||
passthru = {
|
||||
# electronVersion is what ships; electronPin is what packages/desktop/package.json
|
||||
# asks for. They differ whenever nixpkgs carries no release of the pinned minor.
|
||||
electronVersion = electron.version;
|
||||
inherit electronPin;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "OpenCode Desktop App";
|
||||
mainProgram = "opencode-desktop";
|
||||
|
||||
+10
-8
@@ -1,10 +1,12 @@
|
||||
{ lib, pkgs, electronPin }:
|
||||
{ callPackage, path }:
|
||||
let
|
||||
# Nixpkgs owns the release hashes, so bumping the pin no longer means editing this repo. Only the
|
||||
# major is delegated, so what gets built trails the pin whenever nixpkgs has not shipped it yet.
|
||||
# That is safe: the bundle ships one native addon, node-pty's Node-API prebuild, and
|
||||
# only the win32 WSL runtime loads it, so nothing in the main process binds the
|
||||
# Electron ABI.
|
||||
major = lib.versions.major electronPin;
|
||||
version = (builtins.fromJSON (builtins.readFile ../packages/desktop/package.json)).devDependencies.electron;
|
||||
in
|
||||
pkgs."electron_${major}-bin" or (throw "nixpkgs ${lib.version} carries no prebuilt electron ${major}: run `nix flake update nixpkgs`, or pin a major nixpkgs still carries")
|
||||
(callPackage (path + "/pkgs/development/tools/electron/binary/generic.nix") { }) version {
|
||||
# Electron 42.10.1 SHASUMS256.txt; update with the desktop package version.
|
||||
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
|
||||
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
|
||||
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
|
||||
# fetchzip hashes the unpacked headers, not the release tarball.
|
||||
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-onbxt1PCyU4Nh14S5gIeLH20OtbQ7v2ipRq1zqZkLWs=",
|
||||
"aarch64-linux": "sha256-P7DAE018lTJNGmttg87U9oaTAuw/wHlnMStdnXYKCr0=",
|
||||
"aarch64-darwin": "sha256-N+NfV1ObOTnW+ez7As+CS+6ci/iRGCQ6cslvyQFCp6E="
|
||||
"x86_64-linux": "sha256-g3k0cAFGqzmRYlcIkg1NDvlx1WxHYhnYPL0/a8E+qTg=",
|
||||
"aarch64-linux": "sha256-a+3ymqdxOONGe2Tpq4GUccl1b+Dwzxlb9LFXgE1gZ+0=",
|
||||
"aarch64-darwin": "sha256-h8xIzuMmaWfJqjHCO74xUDCWNKQLFrIGoKYZ+2TauYc="
|
||||
}
|
||||
}
|
||||
+6
-22
@@ -12,7 +12,7 @@
|
||||
installShellFiles,
|
||||
versionCheckHook,
|
||||
writableTmpDirAsHomeHook,
|
||||
node_modules ? callPackage ./node_modules.nix { },
|
||||
node_modules ? callPackage ./node-modules.nix { },
|
||||
}:
|
||||
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
pname = "opencode";
|
||||
@@ -85,30 +85,14 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
'';
|
||||
|
||||
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
||||
# v2 dropped the `completion` subcommand; --completions is the global flag.
|
||||
# --completions also accepts sh, which emits the same script as bash.
|
||||
# staged to files, substitute below rejects anything that is not a regular file
|
||||
$out/bin/opencode --completions bash > opencode.bash
|
||||
$out/bin/opencode --completions zsh > _opencode
|
||||
$out/bin/opencode --completions fish > opencode.fish
|
||||
|
||||
# trick yargs into also generating zsh completions
|
||||
installShellCompletion --cmd opencode \
|
||||
--bash opencode.bash \
|
||||
--fish opencode.fish \
|
||||
--zsh _opencode
|
||||
|
||||
# OPENCODE_CLI_NAME is a build-time define, so the opencode2 copies are
|
||||
# renamed rather than regenerated. --replace-fail is a global literal
|
||||
# substitution, so any lowercase opencode that later appears in a
|
||||
# description or help text ships as opencode2 in the opencode2 copy.
|
||||
substitute opencode.bash opencode2.bash --replace-fail opencode opencode2
|
||||
substitute _opencode _opencode2 --replace-fail opencode opencode2
|
||||
substitute opencode.fish opencode2.fish --replace-fail opencode opencode2
|
||||
--bash <($out/bin/opencode completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
|
||||
|
||||
installShellCompletion --cmd opencode2 \
|
||||
--bash opencode2.bash \
|
||||
--fish opencode2.fish \
|
||||
--zsh _opencode2
|
||||
--bash <($out/bin/opencode2 completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion)
|
||||
'';
|
||||
|
||||
nativeInstallCheckInputs = [
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1223,7 +1223,7 @@ const gateway = CloudflareAIGateway.configure({
|
||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||
```
|
||||
|
||||
Included LLM providers: OpenAI, Anthropic, Google (Gemini), Google Vertex, Amazon Bedrock, Azure OpenAI, Baseten, Cerebras, Cohere, Cloudflare AI Gateway, Cloudflare Workers AI, DeepInfra, DeepSeek, Fireworks, Groq, Mistral, OpenRouter, TogetherAI, and xAI. Z.ai currently exposes image generation. Generic Chat Completions, Responses, and Anthropic Messages-compatible entrypoints support custom endpoints.
|
||||
Included LLM providers: OpenAI, Anthropic, Google (Gemini), Google Vertex, Amazon Bedrock, Azure OpenAI, Baseten, Cerebras, Cloudflare AI Gateway, Cloudflare Workers AI, DeepInfra, DeepSeek, Fireworks, Groq, Mistral, OpenRouter, TogetherAI, and xAI. Z.ai currently exposes image generation. Generic Chat Completions, Responses, and Anthropic Messages-compatible entrypoints support custom endpoints.
|
||||
|
||||
Each named provider owns its module, endpoint, authentication, and route setup. Providers with the same wire format compose the shared protocol directly:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { LLMEvent, Usage, type FinishReasonDetails, type LLMRequest } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "cohere-chat"
|
||||
export const DEFAULT_BASE_URL = "https://api.cohere.com/v2"
|
||||
|
||||
const Options = Schema.Struct({
|
||||
thinking: Schema.optional(
|
||||
Schema.Struct({
|
||||
type: Schema.optional(Schema.Literals(["enabled", "disabled"])),
|
||||
tokenBudget: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type ProviderOptionsInput = Schema.Schema.Type<typeof Options>
|
||||
|
||||
const Content = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("thinking"), thinking: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("image_url"), image_url: Schema.Struct({ url: Schema.String }) }),
|
||||
])
|
||||
const ToolCall = Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal("function"),
|
||||
function: Schema.Struct({ name: Schema.String, arguments: Schema.String }),
|
||||
})
|
||||
const Message = Schema.Struct({
|
||||
role: Schema.Literals(["system", "user", "assistant", "tool"]),
|
||||
content: Schema.optional(Schema.Union([Schema.String, Schema.Array(Content)])),
|
||||
tool_calls: Schema.optional(Schema.Array(ToolCall)),
|
||||
tool_call_id: Schema.optional(Schema.String),
|
||||
tool_plan: Schema.optional(Schema.String),
|
||||
})
|
||||
const Body = Schema.Struct({
|
||||
model: Schema.String,
|
||||
messages: Schema.Array(Message),
|
||||
stream: Schema.Literal(true),
|
||||
tools: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("function"),
|
||||
function: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
parameters: Schema.Unknown,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
tool_choice: Schema.optional(Schema.Literals(["NONE", "REQUIRED"])),
|
||||
thinking: Schema.optional(Schema.Struct({ type: Schema.String, token_budget: Schema.optional(Schema.Number) })),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
p: Schema.optional(Schema.Number),
|
||||
k: Schema.optional(Schema.Number),
|
||||
seed: Schema.optional(Schema.Number),
|
||||
stop_sequences: Schema.optional(Schema.Array(Schema.String)),
|
||||
frequency_penalty: Schema.optional(Schema.Number),
|
||||
presence_penalty: Schema.optional(Schema.Number),
|
||||
})
|
||||
const TokenCounts = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
reasoning_tokens: Schema.optional(Schema.Number),
|
||||
})
|
||||
const NativeUsage = Schema.Struct({
|
||||
tokens: Schema.optional(TokenCounts),
|
||||
billed_units: Schema.optional(TokenCounts),
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
})
|
||||
const Event = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("message-start") }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["content-start", "content-delta"]),
|
||||
index: Schema.Number,
|
||||
delta: Schema.Struct({
|
||||
message: Schema.Struct({
|
||||
content: Schema.Struct({ text: Schema.optional(Schema.String), thinking: Schema.optional(Schema.String) }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("content-end"), index: Schema.Number }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("tool-plan-delta"),
|
||||
delta: Schema.Struct({ message: Schema.Struct({ tool_plan: Schema.String }) }),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["tool-call-start", "tool-call-delta"]),
|
||||
index: Schema.Number,
|
||||
delta: Schema.Struct({
|
||||
message: Schema.Struct({
|
||||
tool_calls: Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
function: Schema.Struct({ name: Schema.optional(Schema.String), arguments: Schema.optional(Schema.String) }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("tool-call-end"), index: Schema.Number }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("message-end"),
|
||||
delta: Schema.Struct({ finish_reason: Schema.String, usage: Schema.optional(NativeUsage) }),
|
||||
}),
|
||||
// Citation output is outside this basic chat surface.
|
||||
Schema.Struct({ type: Schema.Literals(["citation-start", "citation-end"]) }),
|
||||
])
|
||||
type Event = typeof Event.Type
|
||||
type State = {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly finished: boolean
|
||||
}
|
||||
|
||||
const TOOL_CHOICE = { auto: undefined, none: "NONE", required: "REQUIRED", tool: "REQUIRED" } as const
|
||||
|
||||
const fromRequest = Effect.fn("CohereChat.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const messages: (typeof Message.Type)[] = request.system.length
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
request.system.length === 1
|
||||
? request.system[0].text
|
||||
: request.system.map((part) => ({ type: "text", text: part.text })),
|
||||
},
|
||||
]
|
||||
: []
|
||||
for (const message of flattened.request.messages) {
|
||||
if (message.role === "system") {
|
||||
messages.push({ role: "user", content: (yield* ProviderShared.wrappedSystemUpdate("Cohere Chat", message)).text })
|
||||
continue
|
||||
}
|
||||
if (message.role === "tool") {
|
||||
for (const part of message.content) {
|
||||
if (part.type !== "tool-result")
|
||||
return yield* ProviderShared.unsupportedContent("Cohere Chat", "tool", ["tool-result"])
|
||||
if (part.result.type === "content" && part.result.value.some((item) => item.type === "file"))
|
||||
return yield* ProviderShared.invalidRequest("Cohere Chat does not support file content in tool results")
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const content: (typeof Content.Type)[] = []
|
||||
const calls: (typeof ToolCall.Type)[] = []
|
||||
const plans: string[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text })
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant" && part.type === "reasoning") {
|
||||
if (part.providerMetadata?.cohere?.toolPlan === true) plans.push(part.text)
|
||||
else content.push({ type: "thinking", thinking: part.text })
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant" && part.type === "tool-call") {
|
||||
const args = ProviderShared.encodeJson(part.input)
|
||||
calls.push({ id: part.id, type: "function", function: { name: part.name, arguments: args } })
|
||||
continue
|
||||
}
|
||||
if (message.role === "user" && part.type === "media" && part.media.mediaType.startsWith("image/")) {
|
||||
const url =
|
||||
ProviderShared.mediaUrl(part.media) ??
|
||||
(yield* ProviderShared.requireInlineMedia("Cohere Chat", part.media)).dataUrl
|
||||
content.push({ type: "image_url", image_url: { url } })
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(
|
||||
"Cohere Chat",
|
||||
message.role,
|
||||
message.role === "user" ? ["text", "media"] : ["text", "reasoning", "tool-call"],
|
||||
)
|
||||
}
|
||||
messages.push({
|
||||
role: message.role,
|
||||
content: content.length ? content : undefined,
|
||||
tool_calls: calls.length ? calls : undefined,
|
||||
tool_plan: plans.length ? plans.join("") : undefined,
|
||||
})
|
||||
}
|
||||
const selected = request.toolChoice?.type === "tool" ? request.toolChoice.name : undefined
|
||||
const tools = selected === undefined ? flattened.tools : flattened.tools.filter((tool) => tool.name === selected)
|
||||
if (selected !== undefined && tools.length === 0)
|
||||
return yield* ProviderShared.invalidRequest("Cohere Chat tool choice must name an available tool")
|
||||
if (tools.some((tool) => tool.native !== undefined))
|
||||
return yield* ProviderShared.invalidRequest("Cohere Chat does not support provider-defined tools")
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages,
|
||||
stream: true as const,
|
||||
tools: tools.length
|
||||
? tools.map((tool) => ({
|
||||
type: "function" as const,
|
||||
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema },
|
||||
}))
|
||||
: undefined,
|
||||
tool_choice: TOOL_CHOICE[request.toolChoice?.type ?? "auto"],
|
||||
thinking: options.thinking && {
|
||||
type: options.thinking.type ?? "enabled",
|
||||
// Cohere rejects budgets above max_tokens; fitting also leaves room for the answer.
|
||||
token_budget:
|
||||
options.thinking.tokenBudget === undefined
|
||||
? undefined
|
||||
: ProviderShared.fitThinkingBudget(options.thinking.tokenBudget, request.generation?.maxTokens),
|
||||
},
|
||||
max_tokens: request.generation?.maxTokens,
|
||||
temperature: request.generation?.temperature,
|
||||
p: request.generation?.topP,
|
||||
k: request.generation?.topK,
|
||||
seed: request.generation?.seed,
|
||||
stop_sequences: request.generation?.stop,
|
||||
frequency_penalty: request.generation?.frequencyPenalty,
|
||||
presence_penalty: request.generation?.presencePenalty,
|
||||
}
|
||||
})
|
||||
|
||||
const finishReason = (raw: string): FinishReasonDetails => {
|
||||
switch (raw) {
|
||||
case "COMPLETE":
|
||||
case "STOP_SEQUENCE":
|
||||
return { normalized: "stop", raw }
|
||||
case "MAX_TOKENS":
|
||||
return { normalized: "length", raw }
|
||||
case "TOOL_CALL":
|
||||
return { normalized: "tool-calls", raw }
|
||||
case "ERROR":
|
||||
case "TIMEOUT":
|
||||
return { normalized: "error", raw }
|
||||
default:
|
||||
return { normalized: "unknown", raw }
|
||||
}
|
||||
}
|
||||
|
||||
const mapUsage = (usage: typeof NativeUsage.Type) =>
|
||||
new Usage({
|
||||
inputTokens: usage.tokens?.input_tokens,
|
||||
outputTokens: usage.tokens?.output_tokens,
|
||||
nonCachedInputTokens: ProviderShared.subtractTokens(usage.tokens?.input_tokens, usage.cached_tokens),
|
||||
cacheReadInputTokens: usage.cached_tokens,
|
||||
reasoningTokens: usage.tokens?.reasoning_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(usage.tokens?.input_tokens, usage.tokens?.output_tokens, undefined),
|
||||
providerMetadata: { cohere: usage },
|
||||
})
|
||||
|
||||
// Lifecycle deltas open blocks on demand and ends are no-ops for closed blocks, so content-start needs no handling.
|
||||
const step = Effect.fn("CohereChat.step")(function* (state: State, event: Event) {
|
||||
const events: LLMEvent[] = []
|
||||
switch (event.type) {
|
||||
case "message-start":
|
||||
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, events] as const
|
||||
case "content-delta": {
|
||||
const id = String(event.index)
|
||||
const content = event.delta.message.content
|
||||
const lifecycle =
|
||||
content.thinking !== undefined
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, id, content.thinking)
|
||||
: Lifecycle.textDelta(state.lifecycle, events, id, content.text ?? "")
|
||||
return [{ ...state, lifecycle }, events] as const
|
||||
}
|
||||
case "content-end": {
|
||||
const id = String(event.index)
|
||||
const lifecycle = Lifecycle.textEnd(Lifecycle.reasoningEnd(state.lifecycle, events, id), events, id)
|
||||
return [{ ...state, lifecycle }, events] as const
|
||||
}
|
||||
case "tool-plan-delta": {
|
||||
const plan = event.delta.message.tool_plan
|
||||
const lifecycle = Lifecycle.reasoningDelta(state.lifecycle, events, "tool-plan", plan, {
|
||||
cohere: { toolPlan: true },
|
||||
})
|
||||
return [{ ...state, lifecycle }, events] as const
|
||||
}
|
||||
case "tool-call-start":
|
||||
case "tool-call-delta": {
|
||||
const call = event.delta.message.tool_calls
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
state.tools,
|
||||
event.index,
|
||||
{ id: call.id, name: call.function.name, text: call.function.arguments ?? "" },
|
||||
"Cohere tool call is missing id or name",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
return [{ ...state, tools: result.tools }, result.events] as const
|
||||
}
|
||||
case "tool-call-end": {
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
||||
return [{ ...state, tools: result.tools }, result.events ?? []] as const
|
||||
}
|
||||
case "message-end": {
|
||||
const pending = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||
events.push(...pending.events)
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: finishReason(event.delta.finish_reason),
|
||||
usage: event.delta.usage && mapUsage(event.delta.usage),
|
||||
})
|
||||
return [{ tools: pending.tools, lifecycle, finished: true }, events] as const
|
||||
}
|
||||
default:
|
||||
return [state, events] as const
|
||||
}
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(Event),
|
||||
initial: (): State => ({ lifecycle: Lifecycle.initial(), tools: ToolStream.empty(), finished: false }),
|
||||
step,
|
||||
terminal: (event) => event.type === "message-end",
|
||||
onHalt: (state) =>
|
||||
state.finished
|
||||
? Effect.succeed([])
|
||||
: Effect.fail(ProviderShared.eventError(ADAPTER, "Cohere stream ended without message-end")),
|
||||
},
|
||||
})
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "cohere",
|
||||
providerMetadataKey: "cohere",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat", { baseURL: DEFAULT_BASE_URL }),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
export * as CohereChat from "./cohere-chat.js"
|
||||
@@ -475,7 +475,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
safetySettings: options.safetySettings,
|
||||
serviceTier: options.serviceTier,
|
||||
systemInstruction:
|
||||
request.system.length === 0 ? undefined : { parts: request.system.map((part) => ({ text: part.text })) },
|
||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||
tools: hasTools
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * as AnthropicMessages from "./anthropic-messages.js"
|
||||
export * as BedrockConverse from "./bedrock-converse.js"
|
||||
export * as CohereChat from "./cohere-chat.js"
|
||||
export * as Gemini from "./gemini.js"
|
||||
export * as MistralChat from "./mistral-chat.js"
|
||||
export * as OpenAIChat from "./openai-chat.js"
|
||||
|
||||
@@ -68,10 +68,7 @@ const MistralAssistantToolCall = Schema.Struct({
|
||||
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
|
||||
|
||||
const MistralMessage = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("system"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(MistralTextContent)]),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
|
||||
@@ -338,17 +335,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const normalizeID = MistralToolID.normalizer(request)
|
||||
const messages: MistralMessage[] =
|
||||
request.system.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
request.system.length === 1
|
||||
? request.system[0].text
|
||||
: request.system.map((part) => ({ type: "text", text: part.text })),
|
||||
},
|
||||
]
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ProviderInternalError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type CacheHint,
|
||||
type LLMRequest,
|
||||
@@ -45,6 +46,12 @@ const OpenAIChatCacheControl = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatCacheControl = Schema.Schema.Type<typeof OpenAIChatCacheControl>
|
||||
|
||||
const OpenAIChatFunction = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
parameters: JsonObject,
|
||||
})
|
||||
|
||||
const OpenAIChatTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
function: Schema.Struct({
|
||||
|
||||
@@ -55,13 +55,7 @@ const patterns = [
|
||||
|
||||
const payloadPatterns = [/request entity too large/i, /payload too large/i, /request too large/i]
|
||||
|
||||
const exclusions = [
|
||||
/^(throttling error|service unavailable):/i,
|
||||
/rate limit/i,
|
||||
/too many requests/i,
|
||||
// Cohere reports an output limit above the model maximum as "too many tokens"; compaction cannot fix it.
|
||||
/max[_ ]tokens must be less than/i,
|
||||
]
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { CohereChat } from "../protocols/cohere-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { ProviderID, type ModelID, type OpenString } from "../schema/index.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("cohere")
|
||||
const COMPATIBILITY_BASE_URL = "https://api.cohere.ai/compatibility/v1"
|
||||
export type ChatOptionsInput = { readonly reasoningEffort?: OpenString<"none" | "high"> }
|
||||
export type ProviderOptions = CohereChat.ProviderOptionsInput & ChatOptionsInput
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
export type Settings<Options = CohereChat.ProviderOptionsInput> = ProviderPackage.Settings &
|
||||
Options & { readonly apiKey?: string; readonly baseURL?: string }
|
||||
|
||||
export const route = CohereChat.route
|
||||
export const chatRoute = Route.make({
|
||||
id: "cohere-chat-completions",
|
||||
provider: id,
|
||||
providerMetadataKey: "cohere",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: COMPATIBILITY_BASE_URL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
export const routes = [route, chatRoute]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const auth = AuthOptions.bearer(input, "COHERE_API_KEY")
|
||||
const native = route.with({ ...defaults, auth, endpoint: { baseURL: baseURL ?? CohereChat.DEFAULT_BASE_URL } })
|
||||
const chat = chatRoute.with({ ...defaults, auth, endpoint: { baseURL: baseURL ?? COMPATIBILITY_BASE_URL } })
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => native.model<CohereChat.ProviderOptionsInput>({ id: modelID }),
|
||||
chat: (modelID: string | ModelID) =>
|
||||
chat.model<ChatOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: {
|
||||
maxTokensField: "max_tokens",
|
||||
supportsStore: false,
|
||||
supportsUsageInStreaming: true,
|
||||
reasoningField: "reasoning_content",
|
||||
supportsStrictMode: false,
|
||||
},
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, CohereChat.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
export * as Cohere from "./cohere.js"
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ProviderPackage } from "../../provider-package.js"
|
||||
import { Cohere } from "../cohere.js"
|
||||
|
||||
export type Settings = Cohere.Settings<Cohere.ChatOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, Cohere.ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
Cohere.configure({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).chat(modelID)
|
||||
@@ -9,7 +9,6 @@ export * as Baseten from "./baseten.js"
|
||||
export * as BlackForestLabs from "./black-forest-labs.js"
|
||||
export * as Cartesia from "./cartesia.js"
|
||||
export * as Cerebras from "./cerebras.js"
|
||||
export * as Cohere from "./cohere.js"
|
||||
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
|
||||
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
|
||||
export * as DeepInfra from "./deepinfra.js"
|
||||
|
||||
@@ -159,15 +159,6 @@ const nativeTransportFailure = (error: unknown) => {
|
||||
return failure
|
||||
}
|
||||
|
||||
// HTTP hooks re-wrap response bodies, so a read failure can arrive as an HttpClientError caused by
|
||||
// another HttpClientError. The innermost cause carries the native failure.
|
||||
const rootCause = (error: unknown): unknown =>
|
||||
HttpClientError.isHttpClientError(error) && "cause" in error.reason && error.reason.cause !== undefined
|
||||
? rootCause(error.reason.cause)
|
||||
: error
|
||||
|
||||
const CONNECTION_LOST = "Connection lost while reading the response"
|
||||
|
||||
const httpError = (input: {
|
||||
readonly error: unknown
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
@@ -188,21 +179,20 @@ const httpError = (input: {
|
||||
}),
|
||||
})
|
||||
|
||||
const source = rootCause(input.error)
|
||||
const source =
|
||||
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
|
||||
? (input.error.reason.cause ?? input.error)
|
||||
: input.error
|
||||
const native = nativeTransportFailure(source)
|
||||
const code = native?.code
|
||||
const detail =
|
||||
code && native?.message && !native.message.includes(code) ? `${code}: ${native.message}` : native?.message
|
||||
const message = detail ?? (input.error instanceof Error ? input.error.message : undefined)
|
||||
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
|
||||
const detail = raw
|
||||
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
|
||||
|
||||
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
|
||||
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
|
||||
if (!HttpClientError.isHttpClientError(input.error))
|
||||
return transportError({ message: message ?? "HTTP transport failed", code })
|
||||
// Effect reports every response body read failure as a DecodeError, but the raw byte stream decodes
|
||||
// nothing: provider output parsing happens later and fails as InvalidProviderOutput.
|
||||
if (input.operation === "read" && input.error.reason._tag === "DecodeError")
|
||||
return transportError({ message: detail ? `${CONNECTION_LOST}: ${detail}` : CONNECTION_LOST, code })
|
||||
if (input.error.reason._tag === "TransportError") {
|
||||
return transportError({
|
||||
message: message ?? input.error.reason.description ?? "HTTP transport failed",
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as GoogleVertexChat from "../src/providers/google-vertex-chat.js"
|
||||
import * as GoogleVertexMessages from "../src/providers/google-vertex-messages.js"
|
||||
import * as GoogleVertexResponses from "../src/providers/google-vertex-responses.js"
|
||||
import * as OpenAI from "../src/providers/openai.js"
|
||||
import * as OpenAICompatible from "../src/providers/openai-compatible.js"
|
||||
import * as OpenRouter from "../src/providers/openrouter.js"
|
||||
import * as XAI from "../src/providers/xai.js"
|
||||
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError, isRetryable } from "../src/index.js"
|
||||
import {
|
||||
LLMClient,
|
||||
RequestExecutor,
|
||||
WebSocketTransport,
|
||||
type HttpMiddleware,
|
||||
type WebSocketChannelExecutor,
|
||||
} from "../src/route.js"
|
||||
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
|
||||
import { route } from "../src/protocols/openai-chat.js"
|
||||
import { configure } from "../src/providers/openai.js"
|
||||
import { dynamicResponse, fixedResponse, handlerLayer, systemError, truncatedStream } from "./lib/http.js"
|
||||
import { dynamicResponse, fixedResponse, handlerLayer, systemError } from "./lib/http.js"
|
||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||
import { sseEvents, sseRaw } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
@@ -24,8 +18,6 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
|
||||
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
|
||||
)
|
||||
|
||||
const sseRequest = HttpClientRequest.post("https://provider.test/v1/messages")
|
||||
|
||||
const expectAIError = (error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
if (!(error instanceof AIError)) throw new Error("expected AIError")
|
||||
@@ -101,9 +93,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe(
|
||||
"Connection lost while reading the response: ECONNRESET: disconnected query-secret-123 header-secret-456",
|
||||
)
|
||||
expect(error.message).toBe("ECONNRESET: disconnected query-secret-123 header-secret-456")
|
||||
expect(error.reason.http).toMatchObject({ status: 200, url: secretRequest.url })
|
||||
expect(error.reason.cause).toMatchObject({ code: "ECONNRESET" })
|
||||
expect(error.reason).toMatchObject({
|
||||
@@ -133,7 +123,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("Connection lost while reading the response: ECONNRESET: socket closed")
|
||||
expect(error.message).toBe("ECONNRESET: socket closed")
|
||||
expect(error.reason.cause).toBeInstanceOf(TypeError)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
@@ -154,54 +144,6 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("reports a connection lost mid-stream through middleware that re-wraps the body", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const chunks: Array<Uint8Array> = []
|
||||
// Session HTTP hooks hand plugins a web Response, so the body is re-wrapped around the original stream.
|
||||
const rewrap: HttpMiddleware = (input, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* handler(input)
|
||||
const body = yield* Stream.toReadableStreamEffect(response.stream)
|
||||
return HttpClientResponse.fromWeb(
|
||||
input,
|
||||
new Response(body, { status: response.status, headers: response.headers }),
|
||||
)
|
||||
})
|
||||
const error = yield* RequestExecutor.stream(executor, sseRequest, rewrap).pipe(
|
||||
Stream.runForEach((chunk) => Effect.sync(() => chunks.push(chunk))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expectAIError(error)
|
||||
expect(new TextDecoder().decode(chunks[0])).toBe('data: {"type":"ping"}\n\n')
|
||||
expect(error.message).toBe("Connection lost while reading the response: ECONNRESET: other side closed")
|
||||
expect(error.reason.cause).toBeInstanceOf(TypeError)
|
||||
expect(error.reason.http).toMatchObject({ status: 200 })
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "read", code: "ECONNRESET" })
|
||||
expect(isRetryable(error)).toBeTrue()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
truncatedStream(
|
||||
['data: {"type":"ping"}\n\n'],
|
||||
new TypeError("terminated", { cause: systemError("ECONNRESET", "other side closed") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not report a body read failure without a native cause as a decode error", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* RequestExecutor.stream(executor, sseRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("Connection lost while reading the response")
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "read", code: undefined })
|
||||
expect(isRetryable(error)).toBeTrue()
|
||||
}).pipe(Effect.provide(truncatedStream(['data: {"type":"ping"}\n\n'], new DOMException("aborted", "AbortError")))),
|
||||
)
|
||||
|
||||
it.effect("preserves middleware error messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:cohere",
|
||||
"provider:cohere"
|
||||
],
|
||||
"name": "cohere/continues-a-native-tool-call",
|
||||
"recordedAt": "2026-10-03T04:09:51.600Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.cohere.com/v2/chat",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message-start\ndata: {\"id\":\"d6c0d412-926c-42ed-b152-043404f8c33c\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" asking\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" have\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" available\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" takes\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" parameter\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" specifically\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" mentioned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" \\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" as\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: conteLine truncated
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.cohere.com/v2/chat",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking for the weather in Paris. I have a get_weather function available that takes a city parameter. The user specifically mentioned \\\"Paris\\\" as the city. I need to use the get_weather tool exactly once as per the policies, and I have all the required parameters (city = \\\"Paris\\\").\\n\\nLet me call the function with the city parameter set to \\\"Paris\\\".\"}],\"tool_calls\":[{\"id\":\"get_weather_vfp5ryredwrg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"tool_call_id\":\"get_weather_vfp5ryredwrg\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message-start\ndata: {\"id\":\"0c6cba0e-442d-4e28-96b2-c3cd6ff3b22d\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" returned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" data\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" shows\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Temperature\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 22\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" (\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"presumably\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" degrees\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Celsius\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\")\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Condition\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" sunny\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n\\nI\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" should\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" present\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" this\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" information\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\Line truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:cohere",
|
||||
"provider:cohere"
|
||||
],
|
||||
"name": "cohere/streams-compatible-chat-reasoning",
|
||||
"recordedAt": "2026-10-02T02:39:47.406Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.cohere.ai/compatibility/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"command-a-reasoning-08-2025\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 17 times 23? Answer briefly.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"high\",\"max_tokens\":2048}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\"\",\"role\":\"assistant\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\"Okay\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\",\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" so\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" I\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" need\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" to\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" figure\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" out\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" what\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" 1\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\"7\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" multiplied\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" by\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" 2\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\"3\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"index\":0,\"finish_reason\":null,\"delta\":{\"reasoning_content\":\" is\"},\"logprobs\":null}],\"created\":1790908779,\"model\":\"command-a-reasoning-08-2025\",\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"id\":\"52b1dec0-5396-46a4-9897-9e0adf56038b\",\"choices\":[{\"indLine truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:cohere",
|
||||
"provider:cohere"
|
||||
],
|
||||
"name": "cohere/streams-native-text-and-usage",
|
||||
"recordedAt": "2026-10-02T02:36:12.967Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.cohere.com/v2/chat",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"command-a-03-2025\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly: OK\"}]}],\"stream\":true,\"max_tokens\":64}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message-start\ndata: {\"id\":\"a171b3a5-db11-4445-a645-5221fb466ab8\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"text\",\"text\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"text\":\"OK\"}}}}\n\nevent: content-end\ndata: {\"type\":\"content-end\",\"index\":0}\n\nevent: message-end\ndata: {\"type\":\"message-end\",\"delta\":{\"finish_reason\":\"COMPLETE\",\"usage\":{\"billed_units\":{\"input_tokens\":4,\"output_tokens\":1},\"tokens\":{\"input_tokens\":499,\"output_tokens\":4},\"cached_tokens\":464}}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:cohere",
|
||||
"provider:cohere"
|
||||
],
|
||||
"name": "cohere/streams-native-thinking-with-a-budget",
|
||||
"recordedAt": "2026-10-02T02:36:14.669Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.cohere.com/v2/chat",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"command-a-reasoning-08-2025\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 17 times 23? Answer briefly.\"}]}],\"stream\":true,\"thinking\":{\"type\":\"enabled\",\"token_budget\":128},\"max_tokens\":2048}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message-start\ndata: {\"id\":\"305edbbe-9f86-4bc0-9490-7766b168a453\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"Okay\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" let\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"'s\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" see\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" need\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" figure\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" out\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" what\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 1\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"7\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" multiplied\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" by\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 2\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"3\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" H\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"mm\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" how\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" do\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" do\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" this\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"?\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" remember\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" some\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" multiplication\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" methods\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",Line truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,16 +2,9 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "mistral-small-latest",
|
||||
"tags": [
|
||||
"prefix:mistral-chat",
|
||||
"provider:mistral",
|
||||
"protocol:mistral-chat",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"usage"
|
||||
],
|
||||
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"],
|
||||
"name": "mistral-chat/drives-a-tool-loop",
|
||||
"recordedAt": "2026-10-03T04:09:49.878Z"
|
||||
"recordedAt": "2026-08-30T17:18:49.552Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +15,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":120,\"total_tokens\":132,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghij\"}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -40,14 +33,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"EwgHkPRLW\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with a\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature of 1\"},\"finish_reason\":null}],\"p\":\"abcdefghi\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"8°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":67,\"total_tokens\":84,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -462,27 +462,6 @@ describe("provider error rawBody classification", () => {
|
||||
expect(reason._tag === "InvalidRequest" ? reason.classification : reason._tag).toBe("context-overflow")
|
||||
})
|
||||
|
||||
test("separates Cohere prompt overflow from output limit rejections", () => {
|
||||
const classify = (message: string) => {
|
||||
const reason = classifyProviderFailure({
|
||||
message,
|
||||
status: 400,
|
||||
rawBody: JSON.stringify({ error_type: "TOO_MANY_TOKENS", message }),
|
||||
})
|
||||
return reason._tag === "InvalidRequest" ? reason.classification : reason._tag
|
||||
}
|
||||
expect(
|
||||
classify(
|
||||
"too many tokens: size limit exceeded by 168512 tokens. Try using shorter or fewer inputs. The limit for this model is 132000 tokens.",
|
||||
),
|
||||
).toBe("context-overflow")
|
||||
expect(
|
||||
classify(
|
||||
"too many tokens: max tokens must be less than or equal to 4096, the maximum output length for this model - received 1000000.",
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("classifies invalid API keys reported as HTTP 400 as authentication failures", () => {
|
||||
const rawBody = JSON.stringify({
|
||||
error: {
|
||||
|
||||
@@ -121,25 +121,6 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Cohere entrypoints onto native and compatibility routes", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("@opencode/ai/providers/cohere"),
|
||||
import("@opencode/ai/providers/cohere/chat"),
|
||||
])
|
||||
const settings = { apiKey: "fixture", headers: { "x-test": "fixture" }, body: { future_option: true } }
|
||||
const routes = [
|
||||
["cohere-chat", "https://api.cohere.com/v2"],
|
||||
["cohere-chat-completions", "https://api.cohere.ai/compatibility/v1"],
|
||||
]
|
||||
modules.forEach((module, index) => {
|
||||
const selected = module.model("command-a-03-2025", settings)
|
||||
expect(selected.provider).toBe("cohere")
|
||||
expect([selected.route.id, selected.route.endpoint.baseURL]).toEqual(routes[index])
|
||||
expect(selected.route.defaults.headers).toEqual(settings.headers)
|
||||
expect(selected.route.defaults.http?.body).toEqual(settings.body)
|
||||
})
|
||||
})
|
||||
|
||||
test("maps MiniMax API entrypoints onto provider-owned routes", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("@opencode/ai/providers/minimax"),
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, SystemPart } from "../../src/index.js"
|
||||
import { Cohere } from "../../src/providers/cohere.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const recorded = recordedTests({ prefix: "cohere", provider: "cohere", requires: ["COHERE_API_KEY"] })
|
||||
const cohere = Cohere.configure({ apiKey: process.env.COHERE_API_KEY ?? "fixture" })
|
||||
|
||||
recorded.effect(
|
||||
"streams native text and usage",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: cohere.model("command-a-03-2025"),
|
||||
prompt: "Reply exactly: OK",
|
||||
generation: { maxTokens: 64 },
|
||||
}),
|
||||
)
|
||||
expect(response.text.trim()).toMatch(/^OK\.?$/)
|
||||
expect(response.usage.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage.outputTokens).toBeGreaterThan(0)
|
||||
expect(response.events.find(LLMEvent.is.finish)?.reason).toEqual({ normalized: "stop", raw: "COMPLETE" })
|
||||
expect(response.usage.providerMetadata?.cohere?.billed_units).toBeDefined()
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
recorded.effect(
|
||||
"streams native thinking with a budget",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: cohere.model("command-a-reasoning-08-2025"),
|
||||
prompt: "What is 17 times 23? Answer briefly.",
|
||||
providerOptions: { thinking: { type: "enabled", tokenBudget: 128 } },
|
||||
generation: { maxTokens: 2048 },
|
||||
}),
|
||||
)
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.text).toContain("391")
|
||||
expect(response.usage.reasoningTokens).toBeGreaterThan(0)
|
||||
expect(response.usage.reasoningTokens).toBeLessThanOrEqual(128)
|
||||
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
recorded.effect(
|
||||
"continues a native tool call",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* runWeatherToolLoop(
|
||||
LLMRequest.update(
|
||||
goldenWeatherToolLoopRequest({
|
||||
id: "cohere-tool-loop",
|
||||
model: cohere.model("command-a-plus-05-2026"),
|
||||
maxTokens: 2048,
|
||||
temperature: false,
|
||||
}),
|
||||
{
|
||||
system: [
|
||||
SystemPart.make("Use the get_weather tool exactly once."),
|
||||
SystemPart.make("After the tool result, reply exactly: Paris is sunny."),
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
expectWeatherToolLoop(events)
|
||||
expect(events.some(LLMEvent.is.toolInputDelta)).toBe(true)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
recorded.effect(
|
||||
"streams compatible chat reasoning",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: cohere.chat("command-a-reasoning-08-2025"),
|
||||
prompt: "What is 17 times 23? Answer briefly.",
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
generation: { maxTokens: 2048 },
|
||||
}),
|
||||
)
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.text).toContain("391")
|
||||
expect(response.events.find(LLMEvent.is.finish)?.reason.normalized).toBe("stop")
|
||||
expect(response.usage.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage.reasoningTokens).toBeGreaterThan(0)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
@@ -1,272 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMClient, LLMEvent, Media, Message, SystemPart, isRetryable } from "../../src/index.js"
|
||||
import { Cohere } from "../../src/providers/cohere.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const cohere = Cohere.configure({ apiKey: "fixture" })
|
||||
|
||||
test("Cohere exposes native and compatible endpoints without Core remapping", () => {
|
||||
expect(cohere.model("command-a-03-2025").route.endpoint.baseURL).toBe("https://api.cohere.com/v2")
|
||||
expect(cohere.chat("command-a-03-2025").route.endpoint.baseURL).toBe("https://api.cohere.ai/compatibility/v1")
|
||||
expect(
|
||||
Cohere.model("command-a-03-2025", { apiKey: "fixture", headers: { "X-Test": "yes" }, body: { temperature: 0 } })
|
||||
.route.defaults?.http,
|
||||
).toMatchObject({ body: { temperature: 0 } })
|
||||
})
|
||||
|
||||
it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: cohere.model("command-a-reasoning-08-2025"),
|
||||
system: [SystemPart.make("Be concise.\nKeep this newline."), SystemPart.make("Second instructions.")],
|
||||
messages: [
|
||||
Message.user("Lookup Paris"),
|
||||
Message.assistant([{ type: "tool-call", id: "lookup-1", name: "lookup", input: { city: "Paris" } }]),
|
||||
Message.tool({ id: "lookup-1", name: "lookup", result: { sunny: true } }),
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Look up a city", inputSchema: { type: "object", properties: {} } }],
|
||||
toolChoice: "required",
|
||||
providerOptions: { thinking: { tokenBudget: 128 } },
|
||||
generation: { maxTokens: 2048, topP: 0.9, topK: 10 },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "command-a-reasoning-08-2025",
|
||||
stream: true,
|
||||
p: 0.9,
|
||||
k: 10,
|
||||
max_tokens: 2048,
|
||||
thinking: { type: "enabled", token_budget: 128 },
|
||||
tool_choice: "REQUIRED",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ type: "text", text: "Be concise.\nKeep this newline." },
|
||||
{ type: "text", text: "Second instructions." },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Lookup Paris" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
tool_calls: [{ id: "lookup-1", function: { name: "lookup", arguments: '{"city":"Paris"}' } }],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "lookup-1", content: '{"sunny":true}' },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Cohere compatibility omits unsupported OpenAI fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: cohere.chat("command-a-reasoning-08-2025"),
|
||||
prompt: "Hello",
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
generation: { maxTokens: 64 },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body).toMatchObject({ reasoning_effort: "high", max_tokens: 64, stream: true })
|
||||
expect(prepared.body.stream_options).toEqual({ include_usage: true })
|
||||
for (const key of ["store", "max_completion_tokens", "parallel_tool_calls", "prompt_cache_key"])
|
||||
expect(prepared.body[key]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Cohere maps inclusive usage while retaining distinct billed units", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message-start" },
|
||||
{ type: "content-start", index: 0, delta: { message: { content: { type: "thinking", thinking: "" } } } },
|
||||
{ type: "content-delta", index: 0, delta: { message: { content: { thinking: "Think" } } } },
|
||||
{ type: "content-end", index: 0 },
|
||||
{ type: "content-start", index: 1, delta: { message: { content: { type: "text", text: "" } } } },
|
||||
{ type: "content-delta", index: 1, delta: { message: { content: { text: "OK" } } } },
|
||||
{ type: "content-end", index: 1 },
|
||||
{
|
||||
type: "message-end",
|
||||
delta: {
|
||||
finish_reason: "COMPLETE",
|
||||
usage: {
|
||||
tokens: { input_tokens: 100, output_tokens: 20, reasoning_tokens: 10 },
|
||||
billed_units: { input_tokens: 30, output_tokens: 15 },
|
||||
cached_tokens: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.text).toBe("OK")
|
||||
expect(response.reasoning).toBe("Think")
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 100,
|
||||
nonCachedInputTokens: 40,
|
||||
cacheReadInputTokens: 60,
|
||||
outputTokens: 20,
|
||||
reasoningTokens: 10,
|
||||
totalTokens: 120,
|
||||
providerMetadata: { cohere: { billed_units: { input_tokens: 30, output_tokens: 15 } } },
|
||||
})
|
||||
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Cohere rejects incomplete streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }),
|
||||
).pipe(Effect.provide(fixedResponse(sseEvents({ type: "message-start" }))), Effect.flip)
|
||||
expect(error.message).toContain("without message-end")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Cohere preserves native tool plans in continued history", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message-start" },
|
||||
{ type: "tool-plan-delta", delta: { message: { tool_plan: "Look up the weather." } } },
|
||||
{
|
||||
type: "tool-call-start",
|
||||
index: 0,
|
||||
delta: { message: { tool_calls: { id: "lookup-1", function: { name: "lookup", arguments: "" } } } },
|
||||
},
|
||||
{
|
||||
type: "tool-call-delta",
|
||||
index: 0,
|
||||
delta: { message: { tool_calls: { function: { arguments: '{"city":"Paris"}' } } } },
|
||||
},
|
||||
{ type: "tool-call-end", index: 0 },
|
||||
{ type: "message-end", delta: { finish_reason: "TOOL_CALL" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.toolCalls[0]?.input).toEqual({ city: "Paris" })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: cohere.model("command-a-03-2025"),
|
||||
messages: [response.message, Message.tool({ id: "lookup-1", name: "lookup", result: { sunny: true } })],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "assistant", tool_plan: "Look up the weather.", tool_calls: [{ id: "lookup-1" }] },
|
||||
{ role: "tool", tool_call_id: "lookup-1" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Cohere rejects unsupported media instead of silently dropping it", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: cohere.model("command-a-03-2025"),
|
||||
messages: [Message.user([{ type: "media", media: Media.base64("Zm9v", "audio/wav") }])],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Cohere thinking budgets must be positive integers", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: cohere.model("command-a-reasoning-08-2025"),
|
||||
providerOptions: { thinking: { tokenBudget: 0 } },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Cohere fits thinking budgets under the output limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: cohere.model("command-a-reasoning-08-2025"),
|
||||
prompt: "Hi",
|
||||
providerOptions: { thinking: { tokenBudget: 31_999 } },
|
||||
generation: { maxTokens: 4096 },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.thinking).toEqual({ type: "enabled", token_budget: 2048 })
|
||||
}),
|
||||
)
|
||||
|
||||
// Bodies captured live on 2026-10-02, except 402 and 429, which are Cohere's documented messages.
|
||||
const errors = [
|
||||
{ status: 401, message: "Incorrect API key provided: ***-123.", tag: "Authentication", retry: false },
|
||||
{ status: 404, message: "model 'no-such-model-xyz' not found", tag: "InvalidRequest", retry: false },
|
||||
{
|
||||
status: 400,
|
||||
message: "invalid request: temperature must be between 0 and 2.0 inclusive.",
|
||||
tag: "InvalidRequest",
|
||||
retry: false,
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
error_type: "TOO_MANY_TOKENS",
|
||||
message: "too many tokens: size limit exceeded by 168512 tokens. The limit for this model is 132000 tokens.",
|
||||
tag: "InvalidRequest",
|
||||
classification: "context-overflow",
|
||||
retry: false,
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
error_type: "TOO_MANY_TOKENS",
|
||||
message:
|
||||
"too many tokens: max tokens must be less than or equal to 4096, the maximum output length for this model - received 1000000.",
|
||||
tag: "InvalidRequest",
|
||||
retry: false,
|
||||
},
|
||||
{ status: 402, message: "Please add or update your payment method to continue", tag: "QuotaExceeded", retry: false },
|
||||
{
|
||||
status: 429,
|
||||
message: "You are using a Trial key, which is limited to 40 API calls / minute.",
|
||||
tag: "RateLimit",
|
||||
retry: true,
|
||||
},
|
||||
{ status: 500, message: "internal server error", tag: "ProviderInternal", retry: true },
|
||||
]
|
||||
|
||||
it.effect("Cohere HTTP errors map to AI error reasons", () =>
|
||||
Effect.forEach(errors, (item) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: cohere.model("command-a-03-2025"), prompt: "Hi" }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(JSON.stringify({ id: "fixture", error_type: item.error_type, message: item.message }), {
|
||||
status: item.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect({
|
||||
message: error.message,
|
||||
tag: error.reason._tag,
|
||||
classification: error.reason._tag === "InvalidRequest" ? error.reason.classification : undefined,
|
||||
retry: isRetryable(error),
|
||||
}).toEqual({ message: item.message, tag: item.tag, classification: item.classification, retry: item.retry })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
|
||||
import { LLM, LLMEvent, Message, ToolDefinition, Media } from "../../src/index.js"
|
||||
import { Mistral } from "../../src/providers/index.js"
|
||||
import { MistralChat } from "../../src/protocols/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
@@ -41,7 +41,7 @@ describe("Mistral Chat", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [SystemPart.make("Initial\nKeep this newline."), SystemPart.make("Second instructions.")],
|
||||
system: "Initial",
|
||||
messages: [
|
||||
Message.system("Updated"),
|
||||
Message.user([
|
||||
@@ -105,13 +105,7 @@ describe("Mistral Chat", () => {
|
||||
reasoning_effort: "high",
|
||||
})
|
||||
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ type: "text", text: "Initial\nKeep this newline." },
|
||||
{ type: "text", text: "Second instructions." },
|
||||
],
|
||||
},
|
||||
{ role: "system", content: "Initial" },
|
||||
{ role: "user", content: "<system-update>\nUpdated\n</system-update>" },
|
||||
{
|
||||
role: "user",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { configure } from "@opencode/ai/providers/mistral"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, Message, SystemPart, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
@@ -97,10 +97,7 @@ describe("Mistral recorded", () => {
|
||||
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
|
||||
const firstRequest = LLM.request({
|
||||
model,
|
||||
system: [
|
||||
SystemPart.make("Call lookup_weather exactly once with Paris."),
|
||||
SystemPart.make("After the tool result, describe the weather briefly."),
|
||||
],
|
||||
system: "Call lookup_weather exactly once with Paris.",
|
||||
prompt: "What is the weather?",
|
||||
tools: [weather],
|
||||
toolChoice: weather,
|
||||
|
||||
@@ -2205,7 +2205,7 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
|
||||
expect(error.message).toBe("Connection lost while reading the response: ECONNRESET: socket closed unexpectedly")
|
||||
expect(error.message).toBe("ECONNRESET: socket closed unexpectedly")
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
transport: "http",
|
||||
@@ -2223,7 +2223,7 @@ describe("OpenAI Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toBe("Connection lost while reading the response: ECONNRESET: socket closed before output")
|
||||
expect(error.message).toBe("ECONNRESET: socket closed before output")
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
transport: "http",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
mapVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
|
||||
@@ -242,7 +242,7 @@ test("keeps an older answer selected while scrolling within the interaction buff
|
||||
|
||||
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_descendant_keyboard_01_shell"
|
||||
await setupTimeline(page, {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import type { CDPSession, Page } from "@playwright/test"
|
||||
import path from "node:path"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
|
||||
|
||||
@@ -8,29 +8,6 @@ import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
for (const view of ["desktop", "mobile"] as const) {
|
||||
test(`offers Git initialization for a project without VCS (${view})`, async ({ page }) => {
|
||||
if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 })
|
||||
const requests: { directory: string; provider?: string }[] = []
|
||||
const workspace = await openSession(page, {
|
||||
name: "ReviewWithoutGit",
|
||||
project: { vcs: undefined },
|
||||
onVcsInit: (input) => requests.push(input),
|
||||
})
|
||||
if (view === "desktop") await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
else await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
|
||||
|
||||
const panel = view === "desktop" ? page.locator("#review-panel") : page.locator('[data-component="session-review"]')
|
||||
await expect(panel.getByText("Track, review, and undo changes in this project")).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
|
||||
const init = panel.getByRole("button", { name: "Create Git repository" })
|
||||
await init.click()
|
||||
await expect.poll(() => requests).toEqual([{ directory: workspace.directory, provider: "git" }])
|
||||
await expect(panel.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expect(init).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("open file tab browses, searches, and tracks missing files", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||
|
||||
@@ -200,11 +200,9 @@ test("terminal stacks under review by default and spans the bottom when configur
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector("#terminal-panel")?.getBoundingClientRect()
|
||||
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')
|
||||
// The side region appears before its lazy review content after reload.
|
||||
if (!review || !terminal || !sidebar) return false
|
||||
const review = document.querySelector("#review-panel")!.getBoundingClientRect()
|
||||
const terminal = document.querySelector("#terminal-panel")!.getBoundingClientRect()
|
||||
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')!
|
||||
const gap = terminal.top - review.bottom
|
||||
return (
|
||||
terminal.left <= 9 &&
|
||||
|
||||
@@ -543,26 +543,6 @@ test.describe("Working", () => {
|
||||
await expect(working).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("keeps Working instead of Used 1 Thought while reasoning is the only activity", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([reasoningPart("prt_only_thought", "Still considering the answer.")], { completed: false }),
|
||||
],
|
||||
settings: { timelineDetail: detailed },
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(working).toBeVisible()
|
||||
await expect(group).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Used 1 Thought" })).toHaveCount(0)
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_after_thought", "read", "running", { filePath: "package.json" })))
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${assistantID}:reasoning:0,prt_after_thought`)
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
|
||||
for (const name of ["shell", "patch", "subagent"] as const) {
|
||||
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
|
||||
@@ -169,7 +169,6 @@ const Group = HttpApiGroup.make("mock")
|
||||
)
|
||||
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
|
||||
.add(HttpApiEndpoint.post("vcsInit", "/api/vcs/init", { success: NoContent, error: Unsupported }))
|
||||
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { query: Query, success: Json }))
|
||||
|
||||
@@ -43,8 +43,6 @@ export interface MockServerConfig {
|
||||
cursor?: string
|
||||
}
|
||||
vcs?: { current: string; default: string }
|
||||
// Initializes the mock project's VCS; without a handler this mutation answers 501.
|
||||
onVcsInit?: (input: { directory: string; provider?: string }) => void
|
||||
vcsDiff?: unknown[] | ((input: { mode?: string }) => unknown[])
|
||||
// Benchmark latency only. Tests hold message pages with `beforeMessagesResponse`.
|
||||
messageDelay?: number
|
||||
@@ -819,24 +817,6 @@ function mockHandlers(
|
||||
location: location(config),
|
||||
data: { branch: config.vcs ?? { current: "main", default: "main" } },
|
||||
}),
|
||||
vcsInit: (ctx) => {
|
||||
if (!config.onVcsInit) return unsupported("initialize VCS", "onVcsInit")
|
||||
return Effect.sync(() => {
|
||||
const url = new URL(ctx.request.url, "http://localhost")
|
||||
const provider = url.searchParams.get("provider") ?? undefined
|
||||
config.onVcsInit?.({ directory: requestDirectory(config, ctx.request), provider })
|
||||
const project = config.project as { id: string; vcs?: string }
|
||||
project.vcs = provider ?? "git"
|
||||
state.emit([
|
||||
{
|
||||
id: "evt_vcs_initialized",
|
||||
type: "worktree.updated",
|
||||
created: Date.now(),
|
||||
data: { projectID: project.id },
|
||||
},
|
||||
])
|
||||
}).pipe(Effect.andThen(noContent))
|
||||
},
|
||||
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
vcsBranches: () => Effect.succeed({ location: location(config), data: ["main"] }),
|
||||
vcsDiff: (ctx) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -30,7 +30,14 @@ import type { Upload } from "../attachments/uploads"
|
||||
import { CommentCard } from "@opencode/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode/session-ui/message-file"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ComposerAttachment, ComposerComment, ComposerOption, ComposerPrompt, ComposerSuggestion } from "../types"
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
ComposerComment,
|
||||
ComposerOption,
|
||||
ComposerPersistedState,
|
||||
ComposerPrompt,
|
||||
ComposerSuggestion,
|
||||
} from "../types"
|
||||
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
|
||||
import { isAttachment } from "../prompt-parts"
|
||||
import "../attachments/attachments.css"
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { CustomProviderForm } from "@/providers/credentials/dialog"
|
||||
import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, Show, createMemo, createResource } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { TimelineDetailControl } from "@/settings/timeline-detail"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
@@ -13,12 +14,42 @@ import {
|
||||
useSettings,
|
||||
} from "@/settings/model"
|
||||
import { formatKeybind } from "@/shell/commands/command"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import { createShellOptions, type ShellSettingsController } from "./controllers"
|
||||
import {
|
||||
createAppearanceSettingsController,
|
||||
createShellOptions,
|
||||
type AppearanceSettingsController,
|
||||
type ShellSettingsController,
|
||||
} from "./controllers"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
|
||||
const fontSettings = {
|
||||
ui: {
|
||||
action: "settings-ui-font",
|
||||
title: "settings.general.row.uiFont.title",
|
||||
description: "settings.general.row.uiFont.description",
|
||||
font: "ui",
|
||||
input: "setUI",
|
||||
},
|
||||
code: {
|
||||
action: "settings-code-font",
|
||||
title: "settings.general.row.font.title",
|
||||
description: "settings.general.row.font.description",
|
||||
font: "code",
|
||||
input: "setCode",
|
||||
},
|
||||
terminal: {
|
||||
action: "settings-terminal-font",
|
||||
title: "settings.general.row.terminalFont.title",
|
||||
description: "settings.general.row.terminalFont.description",
|
||||
font: "terminal",
|
||||
input: "setTerminal",
|
||||
},
|
||||
} as const
|
||||
const AutoApprovePermissionsSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -152,6 +183,90 @@ const FollowUpBehaviorSetting: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.appearance")}</h3>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-color-scheme"
|
||||
options={schemeOptions}
|
||||
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) => {
|
||||
if (option === "system") return language.t("theme.scheme.system")
|
||||
if (option === "light") return language.t("theme.scheme.light")
|
||||
return language.t("theme.scheme.dark")
|
||||
}}
|
||||
onSelect={(option) => option && props.controller.scheme.select(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<ExternalLink class="settings-link" href="https://opencode.ai/docs/themes/">
|
||||
{language.t("common.learnMore")}
|
||||
</ExternalLink>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-theme"
|
||||
options={props.controller.theme.options()}
|
||||
current={props.controller.theme.current()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.name}
|
||||
onSelect={props.controller.theme.select}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<FontSetting kind="ui" fonts={props.controller.fonts} />
|
||||
<FontSetting kind="code" fonts={props.controller.fonts} />
|
||||
<FontSetting kind="terminal" fonts={props.controller.fonts} />
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FontSetting: Component<{
|
||||
kind: "ui" | "code" | "terminal"
|
||||
fonts: AppearanceSettingsController["fonts"]
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const config = () => fontSettings[props.kind]
|
||||
return (
|
||||
<SettingsRow title={language.t(config().title)} description={language.t(config().description)}>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInput
|
||||
data-action={config().action}
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={props.fonts[config().font]().value}
|
||||
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
||||
placeholder={props.fonts[config().font]().placeholder}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t(config().title)}
|
||||
style={{ "font-family": props.fonts[config().font]().family }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
const LanguageSetting = () => {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() =>
|
||||
@@ -294,6 +409,50 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { displayName } from "@opencode/ui/project-avatar"
|
||||
import { resolveProjectForSession } from "@/shell/layout/helpers"
|
||||
import { useExtensionHost } from "@/runtime/extension/host"
|
||||
import { useExtensionAttachment } from "@/runtime/extension/services"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { looksLikeSessionID } from "@/session/search"
|
||||
|
||||
export type CommandPaletteEntry = {
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { batch, createEffect, onCleanup } from "solid-js"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { Data } from "@opencode/client/solid"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { playSoundById } from "@/shell/notifications/sound"
|
||||
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { requireServerKey, sessionHref } from "@/shell/routes/session"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
const NotificationBase = {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/runti
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useExtensionServers } from "@/runtime/extension/servers"
|
||||
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import { SessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
|
||||
@@ -80,6 +80,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), TabStorage.Closed, [])
|
||||
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const memory = createTabMemory(getOwner())
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createMutation } from "@tanstack/solid-query"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { displayName } from "@opencode/ui/project-avatar"
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "node_modules/.ts-dist",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.2.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/server": "workspace:*",
|
||||
"@opencode/tui": "workspace:*",
|
||||
@@ -39,7 +39,6 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
|
||||
@@ -9,13 +9,12 @@ import {
|
||||
type JsonRpcId,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import type { OpenCodeClient } from "@opencode/client/promise"
|
||||
import { Cause, Deferred, Effect, Ref, type Scope } from "effect"
|
||||
import { ACPCapabilities } from "./capabilities"
|
||||
import { ACPCatalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { ACPService } from "./service"
|
||||
import { ACPSessions } from "./sessions"
|
||||
import { ACPTurn } from "./turn"
|
||||
@@ -24,20 +23,19 @@ import { ACPTurn } from "./turn"
|
||||
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
|
||||
const run = Effect.runPromiseWith(yield* Effect.context<Scope.Scope>())
|
||||
const catalog = yield* ACPCatalog.make(client)
|
||||
// Requests can dispatch before the service below is built.
|
||||
// Requests can dispatch once the stream's read loop yields, which may be before the service below is built.
|
||||
const ready = yield* Deferred.make<ACPService.Interface>()
|
||||
const handle =
|
||||
<Params, A>(
|
||||
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Failure>,
|
||||
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPService.Failure>,
|
||||
) =>
|
||||
(name: string) => {
|
||||
const handler = Effect.fn(name)(
|
||||
(ctx: AgentHandlerContext<Params>) =>
|
||||
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
|
||||
Effect.catchTags({
|
||||
ACPCatalogLoadError: (error) => ACPClient.classify(error.cause),
|
||||
ACPCatalogNotReadyError: (error) =>
|
||||
Effect.fail(new ACPError.ServiceFailureError({ safeMessage: error.message, errorName: "CatalogNotReady" })),
|
||||
ACPCatalogLoadError: (error) => ACPPromise.classify(error.cause),
|
||||
ACPCatalogNotReadyError: (error) => Effect.die(error),
|
||||
}),
|
||||
Effect.mapError((error) => (error instanceof RequestError ? error : ACPError.toRequestError(error))),
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
|
||||
@@ -104,6 +102,8 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
"session/set_mode",
|
||||
handle((service, ctx) => service.setSessionMode(ctx.params)),
|
||||
)
|
||||
// The SDK signal is passed through rather than interrupting the fiber: a cancelled turn still resolves with
|
||||
// `stopReason: "cancelled"`.
|
||||
request(
|
||||
"session/prompt",
|
||||
handle((service, ctx) => service.prompt(ctx.params, ctx.signal)),
|
||||
@@ -115,7 +115,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
const acp = ACPConnection.make(app, stream)
|
||||
const connection = acp.connection
|
||||
const sessions = yield* ACPSessions.make({ client, connection, catalog })
|
||||
const capabilities = yield* Ref.make(ACPCapabilities.parse(undefined))
|
||||
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false })
|
||||
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
|
||||
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
|
||||
return acp.agent
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk"
|
||||
|
||||
export const ChildSessionUpdates = "opencode/child-session-updates"
|
||||
|
||||
export type Capabilities = ReturnType<typeof parse>
|
||||
|
||||
export function parse(client: ClientCapabilities | undefined) {
|
||||
const elicitation = client?.elicitation
|
||||
const compaction = client?.session?.compaction
|
||||
return {
|
||||
childSessionUpdates: client?._meta?.[ChildSessionUpdates] === true,
|
||||
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
|
||||
compaction: compaction !== undefined && compaction !== null,
|
||||
}
|
||||
}
|
||||
|
||||
export * as ACPCapabilities from "./capabilities"
|
||||
@@ -1,25 +1,24 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { CommandInfo, ModelInfo, ModelRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Cause, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import { Context, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import type { ConfigOptionProvider } from "./config-option"
|
||||
|
||||
// ACP runs these itself; they take precedence over server commands with the same name.
|
||||
export const builtinCommands = new Map([
|
||||
["compact", { description: "Compact the session", start: "compaction" as const }],
|
||||
])
|
||||
|
||||
export type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ReadonlyArray<Model.Info>
|
||||
readonly defaultModel: Model.Ref
|
||||
readonly modes: ReadonlyArray<{ id: Agent.ID; name: string; description?: string }>
|
||||
readonly defaultModeID: Agent.ID
|
||||
readonly commands: ReadonlyArray<Command.Info>
|
||||
readonly models: ModelInfo[]
|
||||
readonly defaultModel: ModelRef
|
||||
readonly modes: Array<{ id: string; name: string; description?: string }>
|
||||
readonly defaultModeID: string
|
||||
/** Server commands, without those shadowed by a built-in. */
|
||||
readonly commands: CommandInfo[]
|
||||
}
|
||||
|
||||
class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
|
||||
export class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
|
||||
reason: Schema.Literals(["models", "agents"]),
|
||||
}) {
|
||||
override get message() {
|
||||
@@ -27,26 +26,32 @@ class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotRe
|
||||
}
|
||||
}
|
||||
|
||||
class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
|
||||
export class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error = NotReadyError | LoadError
|
||||
|
||||
export interface Interface {
|
||||
/** Loads a directory's catalog once. Concurrent callers share the load, and a failed load is not cached. */
|
||||
readonly get: (cwd: string) => Effect.Effect<Catalog, Error>
|
||||
/** Resolves after a reload that started after the call. A failed reload keeps the previous catalog. */
|
||||
readonly reload: (cwd: string) => Effect.Effect<void, Error>
|
||||
/** Emits the current catalog, then each reloaded one. */
|
||||
readonly changes: (cwd: string) => Stream.Stream<Catalog, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Catalog") {}
|
||||
|
||||
type Entry = {
|
||||
readonly cwd: string
|
||||
readonly catalog: SubscriptionRef.SubscriptionRef<Catalog>
|
||||
readonly lock: Semaphore.Semaphore
|
||||
requestedGeneration: number
|
||||
loadedGeneration: number
|
||||
requested: number
|
||||
loaded: number
|
||||
}
|
||||
|
||||
// Provider, integration, and credential changes reach the catalog through model.updated.
|
||||
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
|
||||
|
||||
export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
@@ -54,20 +59,21 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
const entries = new Map<string, Deferred.Deferred<Entry, Error>>()
|
||||
const connected = yield* Deferred.make<void>()
|
||||
|
||||
// Requests queued behind a running load share the next one.
|
||||
// A reload covers every request made before it starts, so requests queued behind a running reload share
|
||||
// one more load. Typed load failures keep the previous catalog and still settle the requests they covered.
|
||||
const reload = (entry: Entry) =>
|
||||
Effect.suspend(() => {
|
||||
const target = ++entry.requestedGeneration
|
||||
const target = ++entry.requested
|
||||
return entry.lock.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (entry.loadedGeneration >= target) return Effect.void
|
||||
const generation = entry.requestedGeneration
|
||||
if (entry.loaded >= target) return Effect.void
|
||||
const generation = entry.requested
|
||||
return load(client, entry.cwd).pipe(
|
||||
Effect.flatMap((next) => SubscriptionRef.set(entry.catalog, next)),
|
||||
Effect.ignore,
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
entry.loadedGeneration = generation
|
||||
entry.loaded = generation
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -76,7 +82,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
})
|
||||
|
||||
// Subscribe before the first read so an update between the read and the subscription is not lost.
|
||||
yield* client.event.subscribe().pipe(
|
||||
yield* Stream.fromAsyncIterable(client.event.subscribe(), (cause) => cause).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (event.type === "server.connected") return Deferred.succeed(connected, undefined)
|
||||
if (!reloadOn.has(event.type)) return Effect.void
|
||||
@@ -88,9 +94,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP catalog event stream failed", cause),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.ensuring(Deferred.succeed(connected, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
@@ -101,8 +105,8 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
cwd,
|
||||
catalog: yield* SubscriptionRef.make<Catalog>(yield* load(client, cwd)),
|
||||
lock: Semaphore.makeUnsafe(1),
|
||||
requestedGeneration: 0,
|
||||
loadedGeneration: 0,
|
||||
requested: 0,
|
||||
loaded: 0,
|
||||
} satisfies Entry
|
||||
})
|
||||
|
||||
@@ -123,8 +127,8 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
get: Effect.fnUntraced(function* (cwd) {
|
||||
return Service.of({
|
||||
get: Effect.fn("cli.acp.catalog.get")(function* (cwd) {
|
||||
const loaded = yield* entry(cwd)
|
||||
return yield* SubscriptionRef.get(loaded.catalog)
|
||||
}),
|
||||
@@ -132,12 +136,12 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
yield* reload(yield* entry(cwd))
|
||||
}),
|
||||
changes: (cwd) => Stream.unwrap(entry(cwd).pipe(Effect.map((loaded) => SubscriptionRef.changes(loaded.catalog)))),
|
||||
} satisfies Interface
|
||||
})
|
||||
})
|
||||
|
||||
const load = (client: OpenCodeClient, cwd: string) =>
|
||||
read(client, cwd).pipe(
|
||||
// Providers may still be discovering models after startup.
|
||||
// Some providers discover models in the background after plugin startup begins.
|
||||
Effect.retry({
|
||||
while: (error) => error._tag === "ACPCatalogNotReadyError",
|
||||
schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })),
|
||||
@@ -147,18 +151,19 @@ const load = (client: OpenCodeClient, cwd: string) =>
|
||||
|
||||
const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
const location = { directory: cwd }
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = yield* Effect.all(
|
||||
[
|
||||
client.model.list({ location }),
|
||||
client.model.default({ location }),
|
||||
client.agent.list({ location }),
|
||||
client.command.list({ location }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.mapError((cause) => new LoadError({ cause })))
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
Promise.all([
|
||||
client.model.list({ location }, { signal }),
|
||||
client.model.default({ location }, { signal }),
|
||||
client.agent.list({ location }, { signal }),
|
||||
client.command.list({ location }, { signal }),
|
||||
]),
|
||||
catch: (cause) => new LoadError({ cause }),
|
||||
})
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// The parallel default read can name a model missing from this list.
|
||||
// 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]
|
||||
@@ -181,7 +186,7 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
} satisfies Catalog
|
||||
})
|
||||
|
||||
function providers(models: ReadonlyArray<Model.Info>) {
|
||||
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
.toSorted()
|
||||
.map((providerID) => ({
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
|
||||
export const UpdateMethod = "opencode/session/child_update"
|
||||
|
||||
export type Session = {
|
||||
readonly id: string
|
||||
readonly parentID: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||
| {
|
||||
readonly type: "status"
|
||||
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||
readonly error?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
export type Update = {
|
||||
readonly rootSessionId: string
|
||||
readonly childSessionId: string
|
||||
readonly parentSessionId: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
} & Event
|
||||
|
||||
export function update(rootSessionID: string, child: Session, event: Event): Update {
|
||||
return {
|
||||
rootSessionId: rootSessionID,
|
||||
childSessionId: child.id,
|
||||
parentSessionId: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...event,
|
||||
}
|
||||
}
|
||||
|
||||
export function project(sessionUpdate: SessionUpdate, child: Session) {
|
||||
const projected = { ...sessionUpdate }
|
||||
projected._meta = { ...projected._meta, ...meta(child) }
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = toolCallID(child, projected.toolCallId)
|
||||
if (projected.title) projected.title = prefixTitle(child, projected.title)
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
export function meta(child: Session) {
|
||||
return {
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function toolCallID(child: Session | undefined, id: string) {
|
||||
return child ? `${child.id}:${id}` : id
|
||||
}
|
||||
|
||||
export function prefixTitle(child: Session | undefined, value: string) {
|
||||
return child?.title ? `${child.title}: ${value}` : value
|
||||
}
|
||||
|
||||
export * as ACPChild from "./child"
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ClientError } from "@opencode/client/effect"
|
||||
import { InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
export function classify(error: unknown): Effect.Effect<never, ACPError.Error> {
|
||||
if (
|
||||
error instanceof ClientError &&
|
||||
HttpClientError.isHttpClientError(error.cause) &&
|
||||
// The client reports a failed body read as a `DecodeError` with a cause; its other `DecodeError`s have none.
|
||||
(error.cause.reason._tag === "TransportError" ||
|
||||
(error.cause.reason._tag === "DecodeError" && error.cause.reason.cause !== undefined))
|
||||
)
|
||||
return Effect.fail(new ACPError.ServerUnavailableError())
|
||||
if (error instanceof SessionNotFoundError)
|
||||
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: error.sessionID }))
|
||||
if (error instanceof InvalidRequestError)
|
||||
return Effect.fail(new ACPError.InvalidRequestError({ message: error.message, field: error.field }))
|
||||
if (error instanceof InvalidCursorError)
|
||||
return Effect.fail(new ACPError.InvalidRequestError({ message: error.message, field: "cursor" }))
|
||||
return Effect.die(error)
|
||||
}
|
||||
|
||||
export function decodeSessionID(value: string) {
|
||||
return Schema.decodeUnknownEffect(Session.ID)(value).pipe(
|
||||
Effect.mapError(() => new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
|
||||
)
|
||||
}
|
||||
|
||||
export * as ACPClient from "./client"
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { SessionError } from "@opencode/schema/session-error"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
|
||||
const MarkerMeta = "opencode/compaction"
|
||||
|
||||
type Started = { readonly status: "started"; readonly messageId: string; readonly reason: "auto" | "manual" }
|
||||
|
||||
type Compaction =
|
||||
| Started
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly summary: string
|
||||
}
|
||||
| {
|
||||
readonly status: "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: SessionError.Error
|
||||
}
|
||||
|
||||
export type Tracked = ReadonlyMap<string, Started>
|
||||
|
||||
type LifecycleEvent = Extract<
|
||||
OpenCodeEvent,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
type OpeningEvent = Extract<
|
||||
LifecycleEvent,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
const Cancelled = new Set(["aborted", "compaction.interrupted"])
|
||||
|
||||
// Without child updates, a child's standard update would read as the parent's own.
|
||||
export function usesStandardUpdates(
|
||||
ctx: { readonly compaction: boolean; readonly childUpdates: boolean },
|
||||
child: boolean,
|
||||
) {
|
||||
return ctx.compaction && (!child || ctx.childUpdates)
|
||||
}
|
||||
|
||||
export function apply(event: LifecycleEvent, tracked: Tracked, standardUpdates: boolean) {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const started = open(event)
|
||||
return { tracked: new Map(tracked).set(sessionID, started), updates: [update(started, standardUpdates)] }
|
||||
}
|
||||
const current = tracked.get(sessionID)
|
||||
const remaining = new Map(tracked)
|
||||
remaining.delete(sessionID)
|
||||
if (event.type === "session.compaction.ended") {
|
||||
if (!current) return { tracked: remaining, updates: [] }
|
||||
const completed: Compaction = {
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
}
|
||||
return { tracked: remaining, updates: [update(completed, standardUpdates)] }
|
||||
}
|
||||
// Auto compaction can fail without a started event; open it first for standard clients.
|
||||
const started = current ?? open(event)
|
||||
const failed: Compaction = { ...started, status: "failed", reason: event.data.reason, error: event.data.error }
|
||||
return {
|
||||
tracked: remaining,
|
||||
updates: [
|
||||
...(current || !standardUpdates ? [] : [update(started, standardUpdates)]),
|
||||
update(failed, standardUpdates),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function chunk(started: Started | undefined, text: string, standardUpdates: boolean): SessionUpdate | undefined {
|
||||
if (!started || !standardUpdates) return undefined
|
||||
return { sessionUpdate: "compaction_summary_chunk", compactionId: started.messageId, content: { type: "text", text } }
|
||||
}
|
||||
|
||||
export function abandon(started: Started, standardUpdates: boolean) {
|
||||
return update(
|
||||
{ ...started, status: "failed", error: { type: "aborted", message: "Compaction cancelled" } },
|
||||
standardUpdates,
|
||||
)
|
||||
}
|
||||
|
||||
// Nothing on this connection would settle a replayed running compaction.
|
||||
export function replay(message: Extract<SessionMessage.Info, { type: "compaction" }>, standardUpdates: boolean) {
|
||||
if (message.status === "running") return undefined
|
||||
const base = { messageId: message.id, reason: message.reason }
|
||||
return update(
|
||||
message.status === "failed"
|
||||
? { ...base, status: "failed", error: message.error }
|
||||
: { ...base, status: "completed", summary: message.summary },
|
||||
standardUpdates,
|
||||
)
|
||||
}
|
||||
|
||||
// Matches core's compaction message ID, so live and replayed compactions line up.
|
||||
function open(event: OpeningEvent): Started {
|
||||
return {
|
||||
status: "started",
|
||||
messageId: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
|
||||
reason: event.data.reason,
|
||||
}
|
||||
}
|
||||
|
||||
function update(compaction: Compaction, standardUpdates: boolean): SessionUpdate {
|
||||
if (!standardUpdates) {
|
||||
const marker = {
|
||||
status: compaction.status,
|
||||
messageId: compaction.messageId,
|
||||
reason: compaction.reason,
|
||||
...(compaction.status === "failed" ? { error: compaction.error } : {}),
|
||||
}
|
||||
return { sessionUpdate: "session_info_update", _meta: { [MarkerMeta]: marker } }
|
||||
}
|
||||
const compactionId = compaction.messageId
|
||||
if (compaction.status === "started")
|
||||
return { sessionUpdate: "compaction_update", compactionId, status: "in_progress" }
|
||||
if (compaction.status === "completed") {
|
||||
const summary = compaction.summary ? [{ type: "text" as const, text: compaction.summary }] : null
|
||||
return { sessionUpdate: "compaction_update", compactionId, status: "completed", summary }
|
||||
}
|
||||
if (Cancelled.has(compaction.error.type))
|
||||
return { sessionUpdate: "compaction_update", compactionId, status: "cancelled", summary: null }
|
||||
return {
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
error: displayError(compaction.error),
|
||||
}
|
||||
}
|
||||
|
||||
// Core reports a defect as `compaction.failed` with a multi-line pretty-printed cause.
|
||||
function displayError(error: SessionError.Error) {
|
||||
if (!error.message || (error.type === "compaction.failed" && error.message.includes("\n"))) return "Compaction failed"
|
||||
return error.message
|
||||
}
|
||||
|
||||
export * as ACPCompaction from "./compaction"
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { ModelRef } from "@opencode/client/promise"
|
||||
import { builtinCommands, type Catalog } from "./catalog"
|
||||
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
type ConfigOptionModel = {
|
||||
export type ConfigOptionModel = {
|
||||
id: string
|
||||
name: string
|
||||
variants?: ReadonlyArray<string>
|
||||
@@ -17,7 +16,7 @@ export type ConfigOptionProvider = {
|
||||
models: ReadonlyArray<ConfigOptionModel>
|
||||
}
|
||||
|
||||
type ConfigOptionMode = {
|
||||
export type ConfigOptionMode = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
@@ -28,9 +27,10 @@ export type ModelSelection = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
/** A session's model and mode. Unset fields follow the catalog defaults. */
|
||||
export type Selection = {
|
||||
readonly model?: Model.Ref
|
||||
readonly modeID?: Agent.ID
|
||||
readonly model?: ModelRef
|
||||
readonly modeID?: string
|
||||
}
|
||||
|
||||
export function currentModel(catalog: Catalog, selection: Selection) {
|
||||
@@ -163,3 +163,5 @@ function selectVariant(variant: string | undefined, variants: readonly string[])
|
||||
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
|
||||
return variants[0] ?? DEFAULT_VARIANT_VALUE
|
||||
}
|
||||
|
||||
export * as ACPConfigOption from "./config-option"
|
||||
@@ -5,31 +5,46 @@ import {
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type JsonRpcId,
|
||||
RequestError,
|
||||
type RequestError,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Context, Deferred, Effect } from "effect"
|
||||
import type { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
/**
|
||||
* Completes once the response to the request being handled is written, so messages sent afterwards follow it.
|
||||
* Interrupts when the request fails. Outside a request it completes immediately.
|
||||
*/
|
||||
export const Responded = Context.Reference<Effect.Effect<void>>("@opencode/cli/acp/Connection/Responded", {
|
||||
defaultValue: () => Effect.void,
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, RequestError>
|
||||
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
/** Interruption cancels the client's request. */
|
||||
readonly requestPermission: (
|
||||
params: RequestPermissionRequest,
|
||||
) => Effect.Effect<RequestPermissionResponse, RequestError>
|
||||
readonly extNotification: (method: string, params: Record<string, unknown>) => Effect.Effect<void, RequestError>
|
||||
) => Effect.Effect<RequestPermissionResponse, ACPError.Error | RequestError>
|
||||
readonly extNotification: (
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
/** Interruption cancels the client's request. */
|
||||
readonly createElicitation: (
|
||||
params: CreateElicitationRequest,
|
||||
) => Effect.Effect<CreateElicitationResponse, RequestError>
|
||||
) => Effect.Effect<CreateElicitationResponse, ACPError.Error | RequestError>
|
||||
/** Tracks an incoming request from now on and returns its `Responded`. */
|
||||
readonly responded: (requestId: JsonRpcId) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Connection") {}
|
||||
|
||||
export function make(app: AgentApp, stream: Stream) {
|
||||
// Settled as each response is written to the stream, which serializes every outgoing message.
|
||||
const responses = new Map<JsonRpcId, Deferred.Deferred<void>>()
|
||||
const writer = stream.writable.getWriter()
|
||||
const agent = app.connect({
|
||||
@@ -49,15 +64,15 @@ export function make(app: AgentApp, stream: Stream) {
|
||||
})
|
||||
return {
|
||||
agent,
|
||||
connection: {
|
||||
sessionUpdate: (params) => promise(() => agent.client.notify(methods.client.session.update, params)),
|
||||
connection: Service.of({
|
||||
sessionUpdate: (params) => ACPPromise.promise(() => agent.client.notify(methods.client.session.update, params)),
|
||||
requestPermission: (params) =>
|
||||
promise((signal) =>
|
||||
ACPPromise.promise((signal) =>
|
||||
agent.client.request(methods.client.session.requestPermission, params, { cancellationSignal: signal }),
|
||||
),
|
||||
extNotification: (method, params) => promise(() => agent.client.notify(method, params)),
|
||||
extNotification: (method, params) => ACPPromise.promise(() => agent.client.notify(method, params)),
|
||||
createElicitation: (params) =>
|
||||
promise((signal) =>
|
||||
ACPPromise.promise((signal) =>
|
||||
agent.client.request(methods.client.elicitation.create, params, { cancellationSignal: signal }),
|
||||
),
|
||||
responded: (requestId) => {
|
||||
@@ -65,14 +80,8 @@ export function make(app: AgentApp, stream: Stream) {
|
||||
responses.set(requestId, responded)
|
||||
return Deferred.await(responded)
|
||||
},
|
||||
} satisfies Interface,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function promise<A>(evaluate: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(
|
||||
Effect.catch((cause) => (cause instanceof RequestError ? Effect.fail(cause) : Effect.die(cause))),
|
||||
)
|
||||
}
|
||||
|
||||
export * as ACPConnection from "./connection"
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { ContentBlock, ContentChunk, ResourceLink } from "@agentclientprotocol/sdk"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Result } from "effect"
|
||||
|
||||
export type PromptPart =
|
||||
| { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean }
|
||||
| { readonly type: "file"; readonly url: string; readonly filename?: string; readonly mime: string }
|
||||
|
||||
type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
|
||||
export type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
|
||||
|
||||
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
|
||||
return content.flatMap(contentBlockToParts)
|
||||
}
|
||||
|
||||
function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
switch (block.type) {
|
||||
case "text": {
|
||||
const audience = block.annotations?.audience
|
||||
@@ -25,30 +24,48 @@ function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
}
|
||||
return [{ type: "text", text: block.text }]
|
||||
}
|
||||
case "image": {
|
||||
const url = block.data ? `data:${block.mimeType};base64,${block.data}` : block.uri
|
||||
if (!url) return []
|
||||
const filename = filenameFromUri(block.uri ?? undefined) ?? "image"
|
||||
if (url.startsWith("data:")) return [{ type: "file", url, filename, mime: block.mimeType }]
|
||||
return [resourceLinkToPart({ uri: url, name: filename, mimeType: block.mimeType })]
|
||||
}
|
||||
case "image":
|
||||
if (block.data) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: `data:${block.mimeType};base64,${block.data}`,
|
||||
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (block.uri?.startsWith("data:") || block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.uri,
|
||||
filename: filenameFromUri(block.uri) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
case "resource_link":
|
||||
return [resourceLinkToPart(block)]
|
||||
case "resource":
|
||||
if ("text" in block.resource) {
|
||||
const parsed = URL.canParse(block.resource.uri) ? new URL(block.resource.uri) : undefined
|
||||
const decoded =
|
||||
parsed?.protocol === "file:"
|
||||
? Result.try(() => fileURLToPath(parsed)).pipe(
|
||||
Result.orElse(() => Result.try(() => decodeURIComponent(parsed.pathname))),
|
||||
Result.getOrUndefined,
|
||||
)
|
||||
: undefined
|
||||
if (!parsed || decoded === undefined)
|
||||
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
|
||||
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
|
||||
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
|
||||
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
|
||||
try {
|
||||
const parsed = new URL(block.resource.uri)
|
||||
if (parsed.protocol === "file:") {
|
||||
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
|
||||
const decoded = (() => {
|
||||
try {
|
||||
return fileURLToPath(parsed)
|
||||
} catch {
|
||||
return decodeURIComponent(parsed.pathname)
|
||||
}
|
||||
})()
|
||||
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
|
||||
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
|
||||
}
|
||||
} catch {}
|
||||
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
|
||||
}
|
||||
if (!block.resource.mimeType) return []
|
||||
return [
|
||||
@@ -163,9 +180,8 @@ export function linkReference(name: string | undefined, uri: string): PromptPart
|
||||
|
||||
function filenameFromUri(uri: string | undefined): string | undefined {
|
||||
if (!uri || uri.startsWith("data:")) return undefined
|
||||
if (URL.canParse(uri)) {
|
||||
const url = new URL(uri)
|
||||
return path.basename((url.protocol === "zed:" && url.searchParams.get("path")) || url.pathname) || undefined
|
||||
}
|
||||
if (URL.canParse(uri)) return path.basename(new URL(uri).pathname) || undefined
|
||||
return path.basename(uri) || undefined
|
||||
}
|
||||
|
||||
export * as ACPContent from "./content"
|
||||
@@ -1,17 +1,20 @@
|
||||
import { isAbsolute, join, resolve } from "node:path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { OpenCodeClient, PermissionRule, SessionInfo, SessionMetadata } from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { ACPClient } from "./client"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
const key = "opencode.acp.additionalDirectories"
|
||||
const decodeStored = Schema.decodeUnknownOption(Schema.Array(Schema.String))
|
||||
|
||||
// Permission resources treat `*` and `?` as wildcards.
|
||||
/**
|
||||
* Normalizes additional workspace roots without following symlinks, keeping the spelling tools will see, and
|
||||
* drops duplicates and roots that resolve to cwd. Glob characters are rejected because permission resources
|
||||
* would treat them as wildcards. The ACP SDK has already dropped entries that are not strings and replaced a
|
||||
* value that is not an array with `[]`.
|
||||
*/
|
||||
export const parse = Effect.fnUntraced(function* (cwd: string, directories: readonly string[] = []) {
|
||||
const invalid = directories.find((directory) => !isAbsolute(directory) || /[*?]/.test(directory))
|
||||
if (invalid !== undefined) return yield* new ACPError.InvalidAdditionalDirectoryError({ directory: invalid })
|
||||
@@ -21,18 +24,27 @@ export const parse = Effect.fnUntraced(function* (cwd: string, directories: read
|
||||
)
|
||||
})
|
||||
|
||||
/** Session create fields that grant these directories. */
|
||||
export function grant(directories: readonly string[]) {
|
||||
if (directories.length === 0) return {}
|
||||
return { permissions: rules(directories), metadata: { [key]: [...directories] } }
|
||||
}
|
||||
|
||||
export function list(session: Pick<Session.Info, "metadata">) {
|
||||
/** The additional directories ACP last activated for the session, in request order. */
|
||||
export function list(session: Pick<SessionInfo, "metadata">) {
|
||||
return [...Option.getOrElse(decodeStored(session.metadata?.[key]), () => [])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the rules granted for the previously activated directories with grants for these directories.
|
||||
* Grants persist on the server session, so they also apply when it is used from other clients, and child
|
||||
* sessions copy them when they are created; a child created earlier keeps roots its parent later dropped.
|
||||
* Session rules are evaluated after agent and config rules, so a grant overrides config `external_directory`
|
||||
* rules inside the root, while read, edit, and shell rules still apply.
|
||||
*/
|
||||
export const activate = Effect.fnUntraced(function* (
|
||||
client: OpenCodeClient,
|
||||
session: Session.Info,
|
||||
session: SessionInfo,
|
||||
directories: readonly string[],
|
||||
) {
|
||||
const previous = list(session)
|
||||
@@ -43,24 +55,24 @@ export const activate = Effect.fnUntraced(function* (
|
||||
...rules(directories),
|
||||
...current.filter((rule) => !owned.some((item) => isDeepStrictEqual(item, rule))),
|
||||
]
|
||||
const metadata: Session.Metadata = {
|
||||
const metadata: SessionMetadata = {
|
||||
...Object.fromEntries(Object.entries(session.metadata ?? {}).filter(([name]) => name !== key)),
|
||||
...(directories.length > 0 ? { [key]: [...directories] } : {}),
|
||||
}
|
||||
const permissionsChanged = !isDeepStrictEqual(permissions, current)
|
||||
const metadataChanged = !isDeepStrictEqual(previous, directories)
|
||||
if (!permissionsChanged && !metadataChanged) return
|
||||
yield* client.session
|
||||
.update({
|
||||
yield* ACPPromise.promise(() =>
|
||||
client.session.update({
|
||||
sessionID: session.id,
|
||||
...(permissionsChanged ? { permissions } : {}),
|
||||
...(metadataChanged ? { metadata } : {}),
|
||||
})
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Tools compare the written path without following symlinks, so both spellings of a root are granted.
|
||||
function rules(directories: readonly string[]): Permission.Rule[] {
|
||||
function rules(directories: readonly string[]): PermissionRule[] {
|
||||
return [...new Set(directories.flatMap((directory) => [directory, FSUtil.resolve(directory)]))].map((directory) => ({
|
||||
action: "external_directory",
|
||||
resource: join(directory, "*"),
|
||||
|
||||
@@ -4,37 +4,42 @@ import type {
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { isFormAlreadySettledError, isFormNotFoundError, type OpenCodeClient } from "@opencode/client/promise"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import type { ACPService } from "./service"
|
||||
|
||||
/** A form as the event stream carries it, with an unbranded ID. */
|
||||
export type AskedForm = Omit<Form.Info, "id"> & { readonly id: string }
|
||||
type InputField = Exclude<Form.Field, Form.ExternalField>
|
||||
type SelectField = Form.StringField | Form.MultiselectField
|
||||
|
||||
const QuestionKind = "question"
|
||||
// Form mode must not collect secrets; elicit only flows known to be credential-free.
|
||||
const ElicitedKind = Schema.Struct({ kind: Schema.Literals([QuestionKind, "websearch.provider"]) })
|
||||
const Credential = /password|passphrase|secret|token|api[_ -]?key|credential|private[_ -]?key/i
|
||||
// Form mode must not collect secrets, so only forms from flows known not to ask for credentials are elicited.
|
||||
const ElicitedKind = Schema.Struct({ kind: Schema.Literals(["question", "websearch.provider"]) })
|
||||
const Credential = /password|passphrase|secret|token|api[_-]?key|credential|private[_-]?key/i
|
||||
const ToolSource = Schema.Struct({ tool: Schema.Struct({ id: Schema.String }) })
|
||||
|
||||
type Input = {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly form: Form.Info
|
||||
readonly form: AskedForm
|
||||
readonly requestedSchema: ElicitationSchema
|
||||
readonly clientSessionID: string
|
||||
readonly child?: ACPChild.Session
|
||||
readonly child?: { readonly id: string; readonly title?: string }
|
||||
/** Whether the asking tool call reached the client as a `session/update` tool call. */
|
||||
readonly toolCallSent: boolean
|
||||
/** Completes once the form is answered or cancelled elsewhere. */
|
||||
readonly settled: Effect.Effect<void>
|
||||
}
|
||||
|
||||
type Outcome = Form.Answer | "cancel" | "settled"
|
||||
|
||||
/**
|
||||
* Asks the client, then resolves the form on the server. Once `cancelled` completes, the client's request is
|
||||
* cancelled or never sent, and the form is cancelled. Once `settled` completes, the client's request is cancelled and
|
||||
* the server is left alone. Resolving on the server is uninterruptible.
|
||||
*/
|
||||
export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
@@ -52,25 +57,26 @@ export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: In
|
||||
)
|
||||
})
|
||||
|
||||
export const UnshownQuestionMessage =
|
||||
"The question couldn't be shown to the user in this client. Continue without an answer: make reasonable assumptions and state them, or ask the user in your reply if you can't proceed."
|
||||
|
||||
function cancel(client: OpenCodeClient, form: Form.Info, message?: string) {
|
||||
return client.session.form.cancel({ sessionID: form.sessionID, formID: form.id, message }).pipe(
|
||||
Effect.catchTag(["FormAlreadySettledError", "FormNotFoundError"], () => Effect.void),
|
||||
/** Cancels a form, interrupting its session when the server can't cancel it. */
|
||||
export function cancel(client: OpenCodeClient, form: AskedForm) {
|
||||
return settle(() => client.session.form.cancel({ sessionID: form.sessionID, formID: form.id })).pipe(
|
||||
Effect.catch(() =>
|
||||
ACPClient.decodeSessionID(form.sessionID).pipe(
|
||||
Effect.flatMap((sessionID) => client.session.interrupt({ sessionID })),
|
||||
Effect.ignore,
|
||||
),
|
||||
Effect.tryPromise(() => client.session.interrupt({ sessionID: form.sessionID })).pipe(Effect.ignore),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function requestedSchema(form: AskedForm, capabilities: Capabilities): ElicitationSchema | undefined {
|
||||
/**
|
||||
* The form-mode schema for a form, or undefined when the form is cancelled instead: the client lacks form
|
||||
* elicitation, the form is not from an allowed flow or has a field that looks like a credential, or ACP can't
|
||||
* represent it faithfully. Unrepresentable forms have `external` fields, `when` conditions, a hidden required field
|
||||
* without a default, a default outside a field's options, or a free-text answer alongside options that must also
|
||||
* satisfy `required` or item bounds across both inputs. Hidden fields are not asked and answer with their default.
|
||||
*/
|
||||
export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabilities): ElicitationSchema | undefined {
|
||||
if (!capabilities.formElicitation) return undefined
|
||||
if (Option.isNone(Schema.decodeUnknownOption(ElicitedKind)(form.metadata))) return undefined
|
||||
if (form.fields.some(credentialLike)) return undefined
|
||||
if (form.fields.some((field) => Credential.test(field.key) || Credential.test(field.title ?? ""))) return undefined
|
||||
const fields = form.fields.filter((field): field is InputField => field.type !== "external")
|
||||
if (fields.length !== form.fields.length || fields.some((field) => field.when?.length)) return undefined
|
||||
if (fields.some((field) => field.hidden && field.required && field.default === undefined)) return undefined
|
||||
@@ -84,14 +90,8 @@ export function requestedSchema(form: AskedForm, capabilities: Capabilities): El
|
||||
}
|
||||
}
|
||||
|
||||
// The question tool returns the message to the model, so the turn continues instead of ending as interrupted.
|
||||
export function cancelUnshown(client: OpenCodeClient, form: Form.Info) {
|
||||
return cancel(client, form, form.metadata?.kind === QuestionKind ? UnshownQuestionMessage : undefined).pipe(
|
||||
Effect.uninterruptible,
|
||||
)
|
||||
}
|
||||
|
||||
function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
|
||||
/** The answer for an accepted response, or undefined when the user declined, cancelled, or sent invalid content. */
|
||||
export function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
|
||||
if (response.action !== "accept") return undefined
|
||||
const content = Schema.decodeUnknownOption(Form.Answer)(response.content ?? {})
|
||||
if (Option.isNone(content)) return undefined
|
||||
@@ -109,8 +109,8 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const response = yield* input.connection.createElicitation({
|
||||
mode: "form",
|
||||
sessionId: input.clientSessionID,
|
||||
...(toolCallID ? { toolCallId: ACPChild.toolCallID(input.child, toolCallID) } : {}),
|
||||
message: ACPChild.prefixTitle(input.child, input.form.title),
|
||||
...(toolCallID ? { toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID } : {}),
|
||||
message: input.child?.title ? `${input.child.title}: ${input.form.title}` : input.form.title,
|
||||
requestedSchema: input.requestedSchema,
|
||||
})
|
||||
return answer(input.form, response) ?? "cancel"
|
||||
@@ -119,21 +119,21 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
function respond(input: Input, outcome: Outcome) {
|
||||
if (outcome === "settled") return Effect.void
|
||||
if (outcome === "cancel") return cancel(input.client, input.form)
|
||||
return input.client.session.form
|
||||
.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome })
|
||||
.pipe(
|
||||
Effect.catchTag(["FormAlreadySettledError", "FormNotFoundError"], () => Effect.void),
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("ACP form reply failed", cause).pipe(Effect.andThen(cancel(input.client, input.form))),
|
||||
),
|
||||
)
|
||||
return settle(() =>
|
||||
input.client.session.form.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome }),
|
||||
).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("ACP form reply failed", cause).pipe(Effect.andThen(cancel(input.client, input.form))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function credentialLike(field: Form.Field) {
|
||||
const labels =
|
||||
field.type === "string" || field.type === "multiselect" ? (field.options ?? []).map((option) => option.label) : []
|
||||
return [field.key, field.title, field.description, ...labels].some(
|
||||
(text) => text !== undefined && Credential.test(text),
|
||||
// A form already answered or cancelled elsewhere needs nothing more.
|
||||
function settle(evaluate: () => Promise<void>) {
|
||||
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(
|
||||
Effect.catch((cause) =>
|
||||
isFormAlreadySettledError(cause) || isFormNotFoundError(cause) ? Effect.void : Effect.fail(cause),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,8 +153,7 @@ function properties(field: InputField): Array<[string, ElicitationPropertySchema
|
||||
const base = { title: field.title, description: field.description }
|
||||
switch (field.type) {
|
||||
case "string": {
|
||||
if (!hasOptions(field))
|
||||
return [[field.key, { type: "string", ...base, ...stringConstraints(field), default: field.default }]]
|
||||
if (!hasOptions(field)) return [[field.key, { type: "string", ...base, ...text(field), default: field.default }]]
|
||||
const select: ElicitationPropertySchema = {
|
||||
type: "string",
|
||||
...base,
|
||||
@@ -187,6 +186,7 @@ function properties(field: InputField): Array<[string, ElicitationPropertySchema
|
||||
}
|
||||
}
|
||||
|
||||
// A free-text answer next to a field's options is a separate optional property that wins over the selection.
|
||||
function other(field: SelectField, description: string): [string, ElicitationPropertySchema] {
|
||||
return [
|
||||
customKey(field),
|
||||
@@ -194,13 +194,13 @@ function other(field: SelectField, description: string): [string, ElicitationPro
|
||||
type: "string",
|
||||
title: `${field.title ?? field.key} (other)`,
|
||||
description,
|
||||
...(field.type === "string" ? stringConstraints(field) : {}),
|
||||
...(field.type === "string" ? text(field) : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Core rejects an empty string for a required field.
|
||||
function stringConstraints(field: Form.StringField) {
|
||||
// Core rejects an empty string for a required field, so the client is told it needs at least one character.
|
||||
function text(field: Form.StringField) {
|
||||
return {
|
||||
format: field.format,
|
||||
minLength: field.required ? Math.max(field.minLength ?? 0, 1) : field.minLength,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
import type { ACPCatalog } from "./catalog"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("ACPSessionNotFoundError", {
|
||||
sessionId: Schema.String,
|
||||
@@ -56,21 +55,24 @@ export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailable
|
||||
{},
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| SessionDirectoryMismatchError
|
||||
| InvalidConfigOptionError
|
||||
| InvalidModelError
|
||||
| InvalidEffortError
|
||||
| InvalidModeError
|
||||
| InvalidAdditionalDirectoryError
|
||||
| AuthRequiredError
|
||||
| UnknownAuthMethodError
|
||||
| InvalidRequestError
|
||||
| ServiceFailureError
|
||||
| ServerUnavailableError
|
||||
const Errors = Schema.Union([
|
||||
SessionNotFoundError,
|
||||
SessionDirectoryMismatchError,
|
||||
InvalidConfigOptionError,
|
||||
InvalidModelError,
|
||||
InvalidEffortError,
|
||||
InvalidModeError,
|
||||
InvalidAdditionalDirectoryError,
|
||||
AuthRequiredError,
|
||||
UnknownAuthMethodError,
|
||||
InvalidRequestError,
|
||||
ServiceFailureError,
|
||||
ServerUnavailableError,
|
||||
])
|
||||
|
||||
export type Failure = Error | RequestError | ACPCatalog.Error
|
||||
export type Error = typeof Errors.Type
|
||||
|
||||
export const is = Schema.is(Errors)
|
||||
|
||||
export function toRequestError(error: Error): RequestError {
|
||||
switch (error._tag) {
|
||||
@@ -118,9 +120,9 @@ export function toRequestError(error: Error): RequestError {
|
||||
return exhaustive
|
||||
}
|
||||
|
||||
export function fromUnknown(error: unknown) {
|
||||
export function fromUnknown(error: unknown, service?: string) {
|
||||
const errorName = error instanceof Error ? error.name : undefined
|
||||
return new ServiceFailureError({ safeMessage: "Internal service failure", errorName })
|
||||
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
|
||||
}
|
||||
|
||||
export * as ACPError from "./error"
|
||||
@@ -1,30 +1,24 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||
import type { EventSubscribeOutput, OpenCodeClient, PermissionReplyInput } from "@opencode/client/promise"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { applyPatch } from "diff"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import { Cause, Effect } from "effect"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
type Tool = { readonly id: string; readonly name: string; readonly input: ToolInput }
|
||||
type Preview = ReturnType<typeof diff>
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type Tool = { readonly name: string; readonly input: ToolInput }
|
||||
|
||||
type Input = {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly event: PermissionEvent
|
||||
readonly sessionID: Session.ID
|
||||
readonly sessionID: string
|
||||
readonly clientSessionID: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly child?: ACPChild.Session
|
||||
readonly settled: Effect.Effect<void>
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
}
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
@@ -33,18 +27,15 @@ const options: PermissionOption[] = [
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
|
||||
|
||||
/**
|
||||
* Asks the client, then replies to the server. Once `cancelled` completes, the client's request is cancelled or never
|
||||
* sent, and the server gets `reject`. The server reply is uninterruptible, so a server that is alive but stuck can
|
||||
* hold a cancel past `CancelDrainTimeout`; a dead server fails fast.
|
||||
*/
|
||||
export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(
|
||||
cancelled.pipe(
|
||||
Effect.as("reject" as const),
|
||||
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
|
||||
Effect.raceFirst(ask(input)),
|
||||
),
|
||||
).pipe(
|
||||
restore(cancelled.pipe(Effect.as("reject" as const), Effect.raceFirst(ask(input)))).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP permission ask failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed("reject" as const)),
|
||||
Effect.flatMap((decision) => respond(input, decision)),
|
||||
@@ -54,25 +45,23 @@ export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Inp
|
||||
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = input.tool?.input ?? input.event.data.metadata ?? {}
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
|
||||
Effect.orElseSucceed((): Preview[] => []),
|
||||
)
|
||||
const title = permissionTitle(toolName, toolInput, previews)
|
||||
const toolCall = pendingToolCall({
|
||||
toolCallId: ACPChild.toolCallID(input.child, input.tool?.id ?? input.event.data.id),
|
||||
toolName,
|
||||
state: { input: toolInput, title: title ? ACPChild.prefixTitle(input.child, title) : input.child?.title },
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||
const result = yield* input.connection.requestPermission({
|
||||
sessionId: input.clientSessionID,
|
||||
toolCall: {
|
||||
...toolCall,
|
||||
rawInput: input.tool ? toolCall.rawInput : undefined,
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data, input.cwd),
|
||||
...pendingToolCall({
|
||||
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.titlePrefix, permissionTitle(toolName, toolInput, previews)),
|
||||
},
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
...(input.child ? { _meta: ACPChild.meta(input.child) } : {}),
|
||||
},
|
||||
options,
|
||||
})
|
||||
@@ -80,38 +69,41 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
return selected === "once" || selected === "always" ? selected : "reject"
|
||||
})
|
||||
|
||||
function respond(input: Input, decision: Permission.Reply | "settled") {
|
||||
if (decision === "settled") return Effect.void
|
||||
return input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }).pipe(
|
||||
Effect.catchTag("PermissionNotFoundError", () => Effect.void),
|
||||
Effect.catch(ACPClient.classify),
|
||||
function respond(input: Input, decision: PermissionReplyInput["decision"]) {
|
||||
return ACPPromise.promise(() =>
|
||||
input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }),
|
||||
)
|
||||
}
|
||||
|
||||
// Core trims the patch tool's diffs for display, which breaks `applyPatch`, so its previews come from its own hunks.
|
||||
const permissionPreviews = Effect.fnUntraced(function* (
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
metadata: ToolInput | undefined,
|
||||
cwd: string,
|
||||
) {
|
||||
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
if (!prefix) return title
|
||||
if (!title) return prefix
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
|
||||
const files = Option.getOrElse(decodeFiles(metadata?.files), () => [])
|
||||
const previews = yield* Effect.forEach(
|
||||
files,
|
||||
(file) =>
|
||||
Effect.gen(function* () {
|
||||
const path = absolutePath(file.file, cwd)
|
||||
const oldText = file.status === "added" ? null : yield* Effect.tryPromise(() => Bun.file(path).text())
|
||||
const newText = yield* Effect.try(() => applyPatch(oldText ?? "", file.patch))
|
||||
return newText === false ? [] : [diff(path, oldText, newText)]
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return previews.flat()
|
||||
const file = filePath(input)
|
||||
if (!file) return []
|
||||
const path = absolutePath(file, cwd)
|
||||
if (tool === "write") {
|
||||
const content = stringValue(input.content)
|
||||
if (content === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
return [diff(path, oldText, content)]
|
||||
}
|
||||
if (tool !== "edit") return []
|
||||
const oldString = stringValue(input.oldString)
|
||||
const newString = stringValue(input.newString)
|
||||
if (oldString === undefined || newString === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
const newText =
|
||||
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
|
||||
return [diff(path, oldText, newText)]
|
||||
})
|
||||
|
||||
// Patch.derive throws when a hunk does not match the current file; the patch then gets no previews.
|
||||
function patchPreviews(input: ToolInput, cwd: string) {
|
||||
return Effect.forEach(
|
||||
patchHunks(input),
|
||||
@@ -120,22 +112,22 @@ function patchPreviews(input: ToolInput, cwd: string) {
|
||||
const path = absolutePath(hunk.path, cwd)
|
||||
if (hunk.type === "add") {
|
||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
return diff(path, null, newText)
|
||||
return diff(path, "", newText)
|
||||
}
|
||||
const oldText = yield* Effect.tryPromise(() => Bun.file(path).text())
|
||||
const oldText = yield* readText(path)
|
||||
if (hunk.type === "delete") return diff(path, oldText, "")
|
||||
const derived = yield* Effect.try(() => Patch.derive(hunk.path, hunk.chunks, oldText))
|
||||
return diff(hunk.movePath ? absolutePath(hunk.movePath, cwd) : path, oldText, derived.content)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
|
||||
}
|
||||
|
||||
function diff(path: string, oldText: string | null, newText: string) {
|
||||
return { type: "diff" as const, path, oldText, newText }
|
||||
function diff(path: string, oldText: string, newText: string): ToolCallContent {
|
||||
return { type: "diff", path, oldText, newText }
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<Preview>) {
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
|
||||
if (previews.length > 1) return `${previews.length} files`
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "external_directory":
|
||||
@@ -152,22 +144,26 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
|
||||
case "write":
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return filePath(input) ?? previews[0]?.path
|
||||
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function permissionLocations(toolName: string, input: ToolInput, ask: PermissionEvent["data"], cwd: string) {
|
||||
function permissionLocations(
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
resources: ReadonlyArray<string>,
|
||||
cwd: string,
|
||||
): ToolCallLocation[] {
|
||||
const locations = toLocations(toolName, input, cwd)
|
||||
if (locations.length > 0 || !PathActions.has(ask.action)) return locations
|
||||
const paths = ask.resources.flatMap((resource) => {
|
||||
const path = resource.endsWith("/*") ? resource.slice(0, -2) : resource
|
||||
return path && !/[*?]/.test(path) ? [absolutePath(path, cwd)] : []
|
||||
})
|
||||
return Array.from(new Set(paths), (path) => ({ path }))
|
||||
if (locations.length > 0) return locations
|
||||
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
|
||||
}
|
||||
|
||||
const PathActions = new Set(["read", "edit", "external_directory"])
|
||||
// A missing file previews as empty.
|
||||
function readText(path: string) {
|
||||
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
|
||||
}
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { ClientError, isInvalidRequestError, isSessionNotFoundError } from "@opencode/client/promise"
|
||||
import { Effect } from "effect"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
/** Runs a promise, keeping ACP failures typed. Any other rejection is a defect. */
|
||||
export const promise = <A>(evaluate: (signal: AbortSignal) => Promise<A>) =>
|
||||
Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(Effect.catch(classify))
|
||||
|
||||
export function classify(cause: unknown): Effect.Effect<never, ACPError.Error | RequestError> {
|
||||
if (cause instanceof RequestError || ACPError.is(cause)) return Effect.fail(cause)
|
||||
if (cause instanceof ClientError && cause.reason === "Transport")
|
||||
return Effect.fail(new ACPError.ServerUnavailableError())
|
||||
if (isSessionNotFoundError(cause))
|
||||
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: cause.sessionID }))
|
||||
if (isInvalidRequestError(cause))
|
||||
return Effect.fail(new ACPError.InvalidRequestError({ message: cause.message, field: cause.field }))
|
||||
return Effect.die(cause)
|
||||
}
|
||||
|
||||
export * as ACPPromise from "./promise"
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Effect, Option, Stream } from "effect"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import { ACPClient } from "./client"
|
||||
import { ACPCompaction } from "./compaction"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { partsToContentChunks } from "./content"
|
||||
import type { Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate } from "./tool"
|
||||
|
||||
export function history(
|
||||
client: OpenCodeClient,
|
||||
connection: ACPConnection.Interface,
|
||||
attached: Attached,
|
||||
capabilities: Capabilities,
|
||||
) {
|
||||
return Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
(cursor
|
||||
? client.message.list({ sessionID: attached.id, limit: 200, cursor })
|
||||
: client.message.list({ sessionID: attached.id, limit: 200, order: "asc" })
|
||||
).pipe(
|
||||
Effect.catch(ACPClient.classify),
|
||||
Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const),
|
||||
),
|
||||
).pipe(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.forEach(
|
||||
updates(message, attached.cwd, capabilities),
|
||||
(update) => connection.sessionUpdate({ sessionId: attached.id, update }),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function updates(message: SessionMessage.Info, cwd: string, capabilities: Capabilities): SessionUpdate[] {
|
||||
if (message.type === "user")
|
||||
return [
|
||||
{ sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } },
|
||||
...partsToContentChunks(
|
||||
(message.files ?? []).map((file) => ({
|
||||
type: "file",
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
})),
|
||||
).map((chunk) => ({ sessionUpdate: "user_message_chunk" as const, messageId: message.id, ...chunk })),
|
||||
]
|
||||
if (message.type === "compaction") {
|
||||
const update = ACPCompaction.replay(message, capabilities.compaction)
|
||||
return update ? [update] : []
|
||||
}
|
||||
if (message.type !== "assistant") return []
|
||||
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
|
||||
const reasoning = message.content.filter((part) => part.type === "reasoning")
|
||||
return message.content.flatMap((part): SessionUpdate[] => {
|
||||
if (part.type === "text")
|
||||
return [
|
||||
{ sessionUpdate: "agent_message_chunk", messageId: message.id, content: { type: "text", text: part.text } },
|
||||
]
|
||||
if (part.type === "reasoning")
|
||||
return [
|
||||
{
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: ACPTranslate.reasoningMessageID(message.id, reasoning.indexOf(part)),
|
||||
content: { type: "text", text: part.text },
|
||||
},
|
||||
]
|
||||
const call: SessionUpdate = {
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.status === "streaming" ? {} : part.state.input },
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
switch (part.state.status) {
|
||||
case "completed":
|
||||
return [
|
||||
call,
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
]
|
||||
case "running":
|
||||
return [
|
||||
call,
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({ toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, cwd }),
|
||||
},
|
||||
]
|
||||
case "error":
|
||||
return [
|
||||
call,
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
error: part.state.error.message,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
]
|
||||
case "streaming":
|
||||
return [call]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export * as ACPReplay from "./replay"
|
||||
+111
-98
@@ -1,9 +1,12 @@
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { SessionsCursor } from "@opencode/protocol/groups/session"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import {
|
||||
isInvalidRequestError,
|
||||
isSessionNotFoundError,
|
||||
type ModelRef,
|
||||
type OpenCodeClient,
|
||||
type SessionMessageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { DateTime, Effect, Ref, Schema } from "effect"
|
||||
import { Effect, Option, Ref, Result, Stream } from "effect"
|
||||
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
|
||||
import type {
|
||||
AuthenticateRequest,
|
||||
@@ -22,11 +25,11 @@ import type {
|
||||
ListSessionsResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestError,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
@@ -35,34 +38,38 @@ import type {
|
||||
SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { ACPCapabilities, type Capabilities } from "./capabilities"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPDirectories } from "./directories"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPReplay } from "./replay"
|
||||
import type { ACPSessions, Attached, SupportedMcpServer } from "./sessions"
|
||||
import { ACPPromise } from "./promise"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import type { ACPTurn } from "./turn"
|
||||
|
||||
const AuthMethodID = "opencode-login"
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
/** What the client advertised in `initialize`. */
|
||||
export type Capabilities = { readonly childSessionUpdates: boolean; readonly formElicitation: boolean }
|
||||
|
||||
export interface Interface {
|
||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse>
|
||||
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, ACPError.Failure>
|
||||
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, ACPError.Failure>
|
||||
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, ACPError.Failure>
|
||||
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, ACPError.Failure>
|
||||
readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect<DeleteSessionResponse, ACPError.Failure>
|
||||
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, ACPError.Failure>
|
||||
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, ACPError.Failure>
|
||||
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, ACPError.Failure>
|
||||
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Failure>
|
||||
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Failure>
|
||||
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Failure>
|
||||
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Failure>
|
||||
readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect<DeleteSessionResponse, Failure>
|
||||
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Failure>
|
||||
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Failure>
|
||||
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Failure>
|
||||
readonly setSessionConfigOption: (
|
||||
input: SetSessionConfigOptionRequest,
|
||||
) => Effect.Effect<SetSessionConfigOptionResponse, ACPError.Failure>
|
||||
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, ACPError.Failure>
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, ACPError.Failure>
|
||||
) => Effect.Effect<SetSessionConfigOptionResponse, Failure>
|
||||
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Failure>
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
|
||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -78,7 +85,8 @@ export function make(input: {
|
||||
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
|
||||
})
|
||||
|
||||
const withReload = <A>(attached: Attached, select: Effect.Effect<A, ACPError.Failure>) => {
|
||||
// A selection the catalog has not seen may be new on the server, so reload once before rejecting it.
|
||||
const withReload = <A>(attached: Attached, select: Effect.Effect<A, Failure>) => {
|
||||
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(select))
|
||||
return select.pipe(
|
||||
Effect.catchTags({ ACPInvalidModelError: retry, ACPInvalidModeError: retry, ACPInvalidEffortError: retry }),
|
||||
@@ -100,33 +108,54 @@ export function make(input: {
|
||||
}
|
||||
})
|
||||
|
||||
// Update selection before switching so the echoed event is a no-op.
|
||||
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: Model.Ref) {
|
||||
// Both selectors update the selection before switching on the server, so the echoed event diffs to no change.
|
||||
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: ModelRef) {
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
|
||||
yield* input.client.session.switchModel({ sessionID: attached.id, model }).pipe(Effect.catch(ACPClient.classify))
|
||||
yield* ACPPromise.promise(() => input.client.session.switchModel({ sessionID: attached.id, model }))
|
||||
})
|
||||
|
||||
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const mode = catalog.modes.find((item) => item.id === modeID)
|
||||
if (!mode) return yield* new ACPError.InvalidModeError({ mode: modeID })
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID: mode.id }))
|
||||
yield* input.client.session
|
||||
.switchAgent({ sessionID: attached.id, agent: mode.id })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
if (!catalog.modes.some((mode) => mode.id === modeID)) return yield* new ACPError.InvalidModeError({ mode: modeID })
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID }))
|
||||
yield* ACPPromise.promise(() => input.client.session.switchAgent({ sessionID: attached.id, agent: modeID }))
|
||||
})
|
||||
|
||||
const getSession = Effect.fnUntraced(function* (sessionId: string, cwd: string) {
|
||||
const sessionID = yield* ACPClient.decodeSessionID(sessionId)
|
||||
const session = yield* input.client.session.get({ sessionID }).pipe(Effect.catch(ACPClient.classify))
|
||||
const getSession = Effect.fnUntraced(function* (sessionID: string, cwd: string) {
|
||||
const session = yield* ACPPromise.promise(() => input.client.session.get({ sessionID }))
|
||||
if (FSUtil.resolve(cwd) !== FSUtil.resolve(session.location.directory))
|
||||
return yield* new ACPError.SessionDirectoryMismatchError({ sessionId, cwd })
|
||||
return yield* new ACPError.SessionDirectoryMismatchError({ sessionId: sessionID, cwd })
|
||||
return session
|
||||
})
|
||||
|
||||
const replay = (attached: Attached) =>
|
||||
Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
ACPPromise.promise(() =>
|
||||
cursor
|
||||
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
|
||||
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" }),
|
||||
).pipe(Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const)),
|
||||
).pipe(Stream.runForEach((message) => replayMessage(attached, message)))
|
||||
|
||||
// A message that fails to translate keeps the updates before the failure and does not stop the replay.
|
||||
const replayMessage = Effect.fnUntraced(function* (attached: Attached, message: SessionMessageInfo) {
|
||||
const updates = ACPTranslate.replayMessage(message, attached.cwd)
|
||||
while (true) {
|
||||
const next = yield* Effect.result(Effect.try(() => updates.next()))
|
||||
if (Result.isFailure(next))
|
||||
return yield* Effect.logWarning("ACP replay skipped the rest of a message", message.id, next.failure.cause)
|
||||
if (next.success.done) return
|
||||
yield* input.connection.sessionUpdate({ sessionId: attached.id, update: next.success.value })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
initialize: Effect.fnUntraced(function* (params) {
|
||||
yield* Ref.set(input.capabilities, ACPCapabilities.parse(params.clientCapabilities))
|
||||
const elicitation = params.clientCapabilities?.elicitation
|
||||
yield* Ref.set(input.capabilities, {
|
||||
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
|
||||
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
|
||||
})
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -144,7 +173,7 @@ export function make(input: {
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { additionalDirectories: {}, close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { [ACPCapabilities.ChildSessionUpdates]: true },
|
||||
_meta: { [ACPTranslate.ChildSessionUpdatesCapability]: true },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||
@@ -157,41 +186,38 @@ export function make(input: {
|
||||
}),
|
||||
newSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const mcpServers = yield* supportedMcpServers(params.mcpServers)
|
||||
// Load first so a catalog failure leaves no session.
|
||||
// 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.
|
||||
yield* input.catalog.get(params.cwd)
|
||||
const created = yield* input.client.session
|
||||
.create({ location: { directory: AbsolutePath.make(params.cwd) }, ...ACPDirectories.grant(directories) })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
const attachment = yield* input.sessions.attach(created, params.cwd, mcpServers)
|
||||
return { sessionId: attachment.attached.id, configOptions: attachment.configOptions }
|
||||
const created = yield* ACPPromise.promise(() =>
|
||||
input.client.session.create({
|
||||
location: { directory: params.cwd },
|
||||
...ACPDirectories.grant(directories),
|
||||
}),
|
||||
)
|
||||
const attached = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: attached.id, configOptions: yield* currentOptions(attached) }
|
||||
}),
|
||||
loadSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const mcpServers = yield* supportedMcpServers(params.mcpServers)
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
yield* ACPDirectories.activate(input.client, session, directories)
|
||||
const attachment = yield* input.sessions.attach(session, session.location.directory, mcpServers)
|
||||
return yield* ACPReplay.history(
|
||||
input.client,
|
||||
input.connection,
|
||||
attachment.attached,
|
||||
yield* Ref.get(input.capabilities),
|
||||
).pipe(
|
||||
Effect.andThen(currentOptions(attachment.attached)),
|
||||
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers)
|
||||
return yield* replay(attached).pipe(
|
||||
Effect.andThen(currentOptions(attached)),
|
||||
Effect.map((configOptions) => ({ configOptions })),
|
||||
Effect.onError(() => input.sessions.release(attachment.attached)),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
)
|
||||
}),
|
||||
listSessions: Effect.fnUntraced(function* (params) {
|
||||
const page = yield* input.client.session
|
||||
.list({
|
||||
...(params.cwd ? { directory: AbsolutePath.make(params.cwd) } : {}),
|
||||
const page = yield* ACPPromise.promise(() =>
|
||||
input.client.session.list({
|
||||
...(params.cwd ? { directory: params.cwd } : {}),
|
||||
order: "desc",
|
||||
limit: 100,
|
||||
...(params.cursor ? { cursor: Schema.decodeSync(SessionsCursor)(params.cursor) } : {}),
|
||||
})
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||||
}),
|
||||
)
|
||||
return {
|
||||
sessions: page.data.map((session) => {
|
||||
const additionalDirectories = ACPDirectories.list(session)
|
||||
@@ -199,32 +225,30 @@ export function make(input: {
|
||||
sessionId: session.id,
|
||||
cwd: session.location.directory,
|
||||
...(additionalDirectories.length > 0 ? { additionalDirectories } : {}),
|
||||
title: withTimestampedFallback({
|
||||
...session,
|
||||
time: { created: DateTime.toEpochMillis(session.time.created) },
|
||||
}),
|
||||
updatedAt: DateTime.formatIso(session.time.updated),
|
||||
title: withTimestampedFallback(session),
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
}
|
||||
}),
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
}
|
||||
}),
|
||||
deleteSession: Effect.fnUntraced(function* (params) {
|
||||
yield* ACPClient.decodeSessionID(params.sessionId).pipe(
|
||||
Effect.flatMap((sessionID) => input.client.session.remove({ sessionID })),
|
||||
Effect.catchTag(["ACPInvalidRequestError", "SessionNotFoundError"], () => Effect.void),
|
||||
Effect.catch(ACPClient.classify),
|
||||
// A malformed ID fails the server's path decode, and the session ID is the only path param.
|
||||
yield* ACPPromise.promise(() =>
|
||||
input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (isSessionNotFoundError(error) || (isInvalidRequestError(error) && error.kind === "Params")) return
|
||||
throw error
|
||||
}),
|
||||
)
|
||||
yield* input.sessions.detach(params.sessionId)
|
||||
return {}
|
||||
}),
|
||||
resumeSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const mcpServers = yield* supportedMcpServers(params.mcpServers)
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
yield* ACPDirectories.activate(input.client, session, directories)
|
||||
const attachment = yield* input.sessions.attach(session, session.location.directory, mcpServers)
|
||||
return { configOptions: attachment.configOptions }
|
||||
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
return { configOptions: yield* currentOptions(attached) }
|
||||
}),
|
||||
closeSession: Effect.fnUntraced(function* (params) {
|
||||
yield* input.turn.close(params.sessionId)
|
||||
@@ -233,13 +257,14 @@ export function make(input: {
|
||||
}),
|
||||
forkSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const mcpServers = yield* supportedMcpServers(params.mcpServers)
|
||||
const sessionID = yield* ACPClient.decodeSessionID(params.sessionId)
|
||||
const forked = yield* input.client.session.fork({ sessionID }).pipe(Effect.catch(ACPClient.classify))
|
||||
// Forks inherit the source's grants; replace them with this request's.
|
||||
const forked = yield* ACPPromise.promise(() => input.client.session.fork({ sessionID: params.sessionId }))
|
||||
// Forks copy the source session's rules, so the request list replaces any inherited grants.
|
||||
yield* ACPDirectories.activate(input.client, forked, directories)
|
||||
const attachment = yield* input.sessions.attach(forked, forked.location.directory, mcpServers)
|
||||
return { sessionId: attachment.attached.id, configOptions: attachment.configOptions }
|
||||
const attached = yield* input.sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
return yield* currentOptions(attached).pipe(
|
||||
Effect.map((configOptions) => ({ sessionId: attached.id, configOptions })),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
)
|
||||
}),
|
||||
setSessionConfigOption: Effect.fnUntraced(function* (params) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
@@ -258,41 +283,29 @@ export function make(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const supportedMcpServers = Effect.fnUntraced(function* (servers: readonly McpServer[] = []) {
|
||||
const supported = servers.filter(
|
||||
(server): server is SupportedMcpServer => !("type" in server) || server.type === "http",
|
||||
)
|
||||
if (supported.length < servers.length)
|
||||
return yield* new ACPError.InvalidRequestError({
|
||||
message: "Only stdio and HTTP MCP servers are supported",
|
||||
field: "mcpServers",
|
||||
})
|
||||
return supported
|
||||
})
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: Model.Ref) {
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: ModelRef) {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
|
||||
)
|
||||
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
|
||||
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
|
||||
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
|
||||
return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
const variant =
|
||||
selectedVariant?.id ??
|
||||
selected.variant ??
|
||||
(current.providerID === model.providerID &&
|
||||
current.id === model.id &&
|
||||
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
|
||||
? current.variant
|
||||
: undefined)
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies ModelRef
|
||||
})
|
||||
|
||||
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: Model.Ref) {
|
||||
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: ModelRef) {
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model || (effort !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === effort)))
|
||||
return yield* new ACPError.InvalidEffortError({ effort })
|
||||
return { ...current, variant: Model.VariantID.make(effort) } satisfies Model.Ref
|
||||
return { ...current, variant: effort } satisfies ModelRef
|
||||
})
|
||||
|
||||
export * as ACPService from "./service"
|
||||
@@ -1,38 +1,43 @@
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import { Mcp } from "@opencode/schema/mcp"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
|
||||
import type { McpServer, RequestError } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent, SessionInfo } from "@opencode/client/promise"
|
||||
import { Context, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { availableCommands, configOptions, type Selection } from "./config-option"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
export type SupportedMcpServer = Exclude<McpServer, { readonly type: "acp" | "sse" }>
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
export type Attached = {
|
||||
readonly id: Session.ID
|
||||
readonly id: string
|
||||
readonly cwd: string
|
||||
readonly selection: Ref.Ref<Selection>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Attaches a session in its own scope, closing any previous attachment of the same ID. Once the attaching request
|
||||
* has responded, the scope follows the cwd's catalog and pushes config option and command updates while it is
|
||||
* open. A failed attach leaves the session detached.
|
||||
*/
|
||||
readonly attach: (
|
||||
session: Session.Info,
|
||||
session: SessionInfo,
|
||||
cwd: string,
|
||||
mcpServers: readonly SupportedMcpServer[],
|
||||
) => Effect.Effect<{ readonly attached: Attached; readonly configOptions: SessionConfigOption[] }, ACPError.Failure>
|
||||
mcpServers: readonly McpServer[],
|
||||
) => Effect.Effect<Attached, ACPError.Error | RequestError | ACPCatalog.Error>
|
||||
/** Closes the session scope. No-op when the session is not attached. */
|
||||
readonly detach: (sessionID: string) => Effect.Effect<void>
|
||||
readonly release: (attached: Attached) => Effect.Effect<void>
|
||||
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
|
||||
/** Forks work into this attachment's scope, so it ends on detach or re-attach. Fails once the attachment is gone. */
|
||||
readonly fork: (attached: Attached, effect: Effect.Effect<void>) => Effect.Effect<void, ACPError.SessionNotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Sessions") {}
|
||||
|
||||
type Entry = {
|
||||
readonly attached: Attached
|
||||
readonly scope: Scope.Closeable
|
||||
/** Selection changes from other clients, applied by the session's fold. */
|
||||
readonly selected: Queue.Queue<Selection>
|
||||
}
|
||||
|
||||
@@ -45,12 +50,12 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
}) {
|
||||
const scope = yield* Effect.scope
|
||||
const sessions = new Map<string, Entry>()
|
||||
// Outlives entries so re-attaching does not re-add servers.
|
||||
const registeredMcpBySession = new Map<string, Map<string, Mcp.ServerConfig>>()
|
||||
// Kept across re-attachment so resuming with the same servers does not add them again.
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const connected = yield* Deferred.make<void>()
|
||||
|
||||
// Subscribe before any attach so a switch right after `sessions.set` reaches the session.
|
||||
yield* input.client.event.subscribe().pipe(
|
||||
yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
|
||||
Stream.tap((event) => (event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void)),
|
||||
Stream.filter(
|
||||
(event): event is SelectedEvent =>
|
||||
@@ -64,9 +69,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
event.type === "session.model.selected" ? { model: event.data.model } : { modeID: event.data.agent },
|
||||
)
|
||||
}),
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP selection event stream failed", cause),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.ensuring(Deferred.succeed(connected, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
@@ -89,20 +92,22 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
if (!isDeepStrictEqual(next.commands, previous.commands)) yield* sendCommands(attached.id, next)
|
||||
})
|
||||
|
||||
const registerMcp = (attached: Attached, servers: readonly SupportedMcpServer[]) =>
|
||||
const registerMcp = (attached: Attached, servers: readonly McpServer[]) =>
|
||||
Effect.suspend(() => {
|
||||
const registered = registeredMcpBySession.get(attached.id) ?? new Map<string, Mcp.ServerConfig>()
|
||||
registeredMcpBySession.set(attached.id, registered)
|
||||
const registered = registeredMcp.get(attached.id) ?? new Set<string>()
|
||||
registeredMcp.set(attached.id, registered)
|
||||
return Effect.forEach(
|
||||
servers,
|
||||
(server) =>
|
||||
Effect.suspend(() => {
|
||||
const config = mcpConfig(server)
|
||||
if (isDeepStrictEqual(registered.get(server.name), config)) return Effect.void
|
||||
registered.set(server.name, config)
|
||||
return input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }).pipe(
|
||||
Effect.catch(ACPClient.classify),
|
||||
Effect.onError(() => Effect.sync(() => registered.delete(server.name))),
|
||||
const key = `${server.name}:${stableStringify(config)}`
|
||||
if (registered.has(key)) return Effect.void
|
||||
registered.add(key)
|
||||
return ACPPromise.promise(() =>
|
||||
input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }),
|
||||
).pipe(
|
||||
Effect.onError(() => Effect.sync(() => registered.delete(key))),
|
||||
Effect.uninterruptible,
|
||||
)
|
||||
}),
|
||||
@@ -114,12 +119,12 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
Effect.suspend(() => {
|
||||
if (sessions.get(sessionID) === entry) {
|
||||
sessions.delete(sessionID)
|
||||
registeredMcpBySession.delete(sessionID)
|
||||
registeredMcp.delete(sessionID)
|
||||
}
|
||||
return Scope.close(entry.scope, Exit.void)
|
||||
})
|
||||
|
||||
return {
|
||||
return Service.of({
|
||||
attach: Effect.fn("cli.acp.sessions.attach")(function* (session, cwd, mcpServers) {
|
||||
yield* Deferred.await(connected)
|
||||
const current = yield* input.catalog.get(cwd)
|
||||
@@ -138,8 +143,9 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
if (replaced) yield* Scope.close(replaced.scope, Exit.void)
|
||||
yield* registerMcp(entry.attached, mcpServers).pipe(Effect.onError(() => remove(session.id, entry)))
|
||||
const responded = yield* ACPConnection.Responded
|
||||
// `changes` replays the latest catalog, so a reload since `current` still pushes.
|
||||
// One fold keeps catalog and selection consistent.
|
||||
// Updates wait for the response that hands the client this session. `changes` emits the latest catalog
|
||||
// first, so a reload since `current` is still pushed. One fold applies catalog and selection changes so
|
||||
// pushes leave the client on the latest pair.
|
||||
yield* Effect.gen(function* () {
|
||||
yield* responded
|
||||
yield* sendCommands(session.id, current)
|
||||
@@ -156,20 +162,13 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.ignore, Effect.forkIn(entry.scope))
|
||||
return {
|
||||
attached: entry.attached,
|
||||
configOptions: configOptions(current, yield* Ref.get(entry.attached.selection)),
|
||||
}
|
||||
return entry.attached
|
||||
}),
|
||||
detach: Effect.fn("cli.acp.sessions.detach")(function* (sessionID) {
|
||||
const entry = sessions.get(sessionID)
|
||||
if (entry) yield* remove(sessionID, entry)
|
||||
}),
|
||||
release: Effect.fn("cli.acp.sessions.release")(function* (attached) {
|
||||
const entry = sessions.get(attached.id)
|
||||
if (entry?.attached === attached) yield* remove(attached.id, entry)
|
||||
}),
|
||||
require: Effect.fnUntraced(function* (sessionID) {
|
||||
require: Effect.fn("cli.acp.sessions.require")(function* (sessionID) {
|
||||
const entry = sessions.get(sessionID)
|
||||
if (!entry) return yield* new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
||||
return entry.attached
|
||||
@@ -179,23 +178,33 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
|
||||
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
|
||||
}),
|
||||
} satisfies Interface
|
||||
})
|
||||
})
|
||||
|
||||
function mcpConfig(server: SupportedMcpServer) {
|
||||
function mcpConfig(server: McpServer) {
|
||||
if ("type" in server) {
|
||||
return new Mcp.RemoteConfig({
|
||||
type: "remote",
|
||||
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url: server.url,
|
||||
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
|
||||
oauth: false,
|
||||
})
|
||||
oauth: false as const,
|
||||
}
|
||||
}
|
||||
return new Mcp.LocalConfig({
|
||||
type: "local",
|
||||
return {
|
||||
type: "local" as const,
|
||||
command: [server.command, ...server.args],
|
||||
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
|
||||
if (!value || typeof value !== "object") return JSON.stringify(value)
|
||||
return `{${Object.entries(value)
|
||||
.toSorted(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(",")}}`
|
||||
}
|
||||
|
||||
export * as ACPSessions from "./sessions"
|
||||
@@ -1,13 +1,16 @@
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||
import type { Tool } from "@opencode/schema/tool"
|
||||
import { readDisplayText } from "@opencode/tui/mini/tool"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { Result } from "effect"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
export type ToolContent = ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | null }
|
||||
>
|
||||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
export function toToolKind(toolName: string): ToolKind {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "bash":
|
||||
case "shell":
|
||||
@@ -82,16 +85,18 @@ export function pendingToolCall(input: {
|
||||
export function runningToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: { readonly input: ToolInput }
|
||||
readonly state: { readonly input: ToolInput; readonly title?: string }
|
||||
readonly content?: ToolContent
|
||||
readonly cwd: string
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: toolTitle(input.toolName, input.state.input),
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
...(input.content?.length ? { content: toolContent(input.content) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,11 +104,12 @@ export function completedToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content: ReadonlyArray<Tool.Content>
|
||||
readonly content: ToolContent
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly cwd: string
|
||||
}): ToolCallUpdate {
|
||||
const normalized = toolContent(input.content)
|
||||
// Read's model content is a JSON page envelope; show the clean text instead.
|
||||
const firstText = input.content.find((part) => part.type === "text")
|
||||
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
|
||||
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
|
||||
@@ -133,7 +139,7 @@ export function errorToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content?: ReadonlyArray<Tool.Content>
|
||||
readonly content?: ToolContent
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly error: string
|
||||
readonly cwd: string
|
||||
@@ -142,7 +148,7 @@ export function errorToolUpdate(input: {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "failed",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: toolTitle(input.toolName, input.input),
|
||||
title: toolTitle(input.toolName, input.input, undefined),
|
||||
locations: toLocations(input.toolName, input.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.input, input.cwd),
|
||||
content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }],
|
||||
@@ -153,7 +159,7 @@ export function errorToolUpdate(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function toolContent(content: ReadonlyArray<Tool.Content>): ToolCallContent[] {
|
||||
function toolContent(content: ToolContent): ToolCallContent[] {
|
||||
return content.flatMap((part): ToolCallContent[] => {
|
||||
if (part.type === "text") return [{ type: "content", content: { type: "text", text: part.text } }]
|
||||
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/.exec(part.uri)
|
||||
@@ -162,7 +168,7 @@ function toolContent(content: ReadonlyArray<Tool.Content>): ToolCallContent[] {
|
||||
})
|
||||
}
|
||||
|
||||
function toolTitle(toolName: string, input: ToolInput, fallback?: string) {
|
||||
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
|
||||
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
|
||||
return fallback || toolName
|
||||
}
|
||||
@@ -203,3 +209,5 @@ export function absolutePath(path: string, cwd: string) {
|
||||
export function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export * as ACPTool from "./tool"
|
||||
@@ -1,27 +1,33 @@
|
||||
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionError } from "@opencode/schema/session-error"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPCompaction } from "./compaction"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolInput } from "./tool"
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
const RetryMeta = "opencode/retry"
|
||||
const CompactionMeta = "opencode/compaction"
|
||||
|
||||
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: SessionMessage.ID }
|
||||
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: string }
|
||||
|
||||
export type Terminal = "succeeded" | "failed" | "interrupted"
|
||||
|
||||
export type Context = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly childUpdates: boolean
|
||||
readonly compaction: boolean
|
||||
/** Background mode follows open children after the turn ends and never writes `session/update`. */
|
||||
/** A background consumer follows open children after the parent turn ends; it never writes `session/update`. */
|
||||
readonly mode: "turn" | "background"
|
||||
}
|
||||
|
||||
@@ -36,49 +42,86 @@ type Tool = {
|
||||
type RetryStatus = {
|
||||
readonly attempt: number
|
||||
readonly nextRetryAt: string
|
||||
readonly error: SessionError.Error
|
||||
readonly error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type ChildSession = {
|
||||
readonly id: string
|
||||
readonly parentID: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
type ChildSessionEvent =
|
||||
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||
| {
|
||||
readonly type: "status"
|
||||
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||
readonly error?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
export type ChildSessionUpdate = {
|
||||
readonly rootSessionId: string
|
||||
readonly childSessionId: string
|
||||
readonly parentSessionId: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
} & ChildSessionEvent
|
||||
|
||||
export type TurnState = {
|
||||
readonly started: boolean
|
||||
readonly tools: ReadonlyMap<string, Tool>
|
||||
readonly retries: ReadonlyMap<string, RetryStatus>
|
||||
readonly compactions: ACPCompaction.Tracked
|
||||
readonly children: ReadonlyMap<string, ACPChild.Session>
|
||||
readonly compactions: ReadonlyMap<string, string>
|
||||
readonly children: ReadonlyMap<string, ChildSession>
|
||||
readonly openChildren: ReadonlySet<string>
|
||||
readonly asks: ReadonlySet<string>
|
||||
readonly finish?: SessionMessage.Assistant["finish"]
|
||||
readonly usage?: { readonly turn: TokenUsage.Info; readonly last: TokenUsage.Info }
|
||||
readonly stepError?: SessionError.Error
|
||||
/** Forms asked of the client that the server has not yet answered or cancelled. */
|
||||
readonly forms: ReadonlySet<string>
|
||||
readonly finish?: SessionMessageAssistant["finish"]
|
||||
readonly usage?: { readonly turn: TokenUsageInfo; readonly last: TokenUsageInfo }
|
||||
readonly stepError?: SessionStructuredError
|
||||
readonly executionError?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
type FormEvent = Extract<OpenCodeEvent, { type: "form.created" }>
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type FormEvent = Extract<EventSubscribeOutput, { type: "form.created" }>
|
||||
|
||||
export type Output =
|
||||
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
|
||||
| { readonly _tag: "ChildUpdate"; readonly update: ACPChild.Update }
|
||||
| { readonly _tag: "ChildUpdate"; readonly update: ChildSessionUpdate }
|
||||
| {
|
||||
readonly _tag: "PermissionAsk"
|
||||
readonly event: PermissionEvent
|
||||
readonly tool?: Tool
|
||||
readonly child?: ACPChild.Session
|
||||
readonly child?: ChildSession
|
||||
}
|
||||
| {
|
||||
readonly _tag: "FormAsk"
|
||||
readonly form: FormEvent["data"]["form"]
|
||||
readonly child?: ACPChild.Session
|
||||
readonly child?: ChildSession
|
||||
/** Whether the form's session sends its tool calls to the client as `session/update` tool calls. */
|
||||
readonly toolCallSent: boolean
|
||||
}
|
||||
| { readonly _tag: "AskSettled"; readonly id: string }
|
||||
| { readonly _tag: "FormSettled"; readonly formID: string }
|
||||
|
||||
type Step = {
|
||||
export type Step = {
|
||||
readonly state: TurnState
|
||||
readonly outputs: ReadonlyArray<Output>
|
||||
readonly terminal?: Terminal
|
||||
}
|
||||
|
||||
type CompactionEvent = Extract<
|
||||
EventSubscribeOutput,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
type CompactionMarker = {
|
||||
readonly status: "started" | "completed" | "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error?: SessionStructuredError
|
||||
}
|
||||
|
||||
export const initial: TurnState = {
|
||||
started: false,
|
||||
tools: new Map(),
|
||||
@@ -86,10 +129,10 @@ export const initial: TurnState = {
|
||||
compactions: new Map(),
|
||||
children: new Map(),
|
||||
openChildren: new Set(),
|
||||
asks: new Set(),
|
||||
forms: new Set(),
|
||||
}
|
||||
|
||||
export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step {
|
||||
export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context): Step {
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) return { state, outputs: [] }
|
||||
@@ -115,14 +158,11 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
const tool = event.data.source?.id
|
||||
? state.tools.get(toolKey(event.data.sessionID, event.data.source.id))
|
||||
: undefined
|
||||
return {
|
||||
state: { ...state, asks: new Set(state.asks).add(event.data.id) },
|
||||
outputs: [{ _tag: "PermissionAsk", event, tool, child }],
|
||||
}
|
||||
return { state, outputs: [{ _tag: "PermissionAsk", event, tool, child }] }
|
||||
}
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === ctx.sessionID || child)) {
|
||||
return {
|
||||
state: { ...state, asks: new Set(state.asks).add(event.data.form.id) },
|
||||
state: { ...state, forms: new Set(state.forms).add(event.data.form.id) },
|
||||
outputs: [
|
||||
{
|
||||
_tag: "FormAsk",
|
||||
@@ -133,20 +173,13 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
],
|
||||
}
|
||||
}
|
||||
const settledID =
|
||||
event.type === "permission.replied"
|
||||
? event.data.requestID
|
||||
: event.type === "form.replied" || event.type === "form.cancelled"
|
||||
? event.data.id
|
||||
: undefined
|
||||
if (settledID && state.asks.has(settledID)) {
|
||||
const asks = new Set(state.asks)
|
||||
asks.delete(settledID)
|
||||
return { state: { ...state, asks }, outputs: [{ _tag: "AskSettled", id: settledID }] }
|
||||
if ((event.type === "form.replied" || event.type === "form.cancelled") && state.forms.has(event.data.id)) {
|
||||
const forms = new Set(state.forms)
|
||||
forms.delete(event.data.id)
|
||||
return { state: { ...state, forms }, outputs: [{ _tag: "FormSettled", formID: event.data.id }] }
|
||||
}
|
||||
if (!eventSessionID || (eventSessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
|
||||
if (event.type === "session.inbox.delivered" && event.data.inboxID === ctx.start.id)
|
||||
return { state: { ...state, started: true }, outputs: [] }
|
||||
if (matchesStart(event, ctx.start)) return { state: { ...state, started: true }, outputs: [] }
|
||||
if (!state.started) return { state, outputs: [] }
|
||||
|
||||
switch (event.type) {
|
||||
@@ -174,20 +207,11 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
case "session.compaction.started":
|
||||
case "session.compaction.ended":
|
||||
case "session.compaction.failed": {
|
||||
const applied = ACPCompaction.apply(
|
||||
event,
|
||||
state.compactions,
|
||||
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
|
||||
)
|
||||
return { state: { ...state, compactions: applied.tracked }, outputs: applied.updates.flatMap(send) }
|
||||
}
|
||||
case "session.compaction.delta": {
|
||||
const update = ACPCompaction.chunk(
|
||||
state.compactions.get(eventSessionID),
|
||||
event.data.text,
|
||||
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
|
||||
)
|
||||
return { state, outputs: update ? send(update) : [] }
|
||||
const compacted = compactionMarker(event, state.compactions)
|
||||
return {
|
||||
state: { ...state, compactions: compacted.compactions },
|
||||
outputs: compacted.marker ? send(compactionUpdate(compacted.marker)) : [],
|
||||
}
|
||||
}
|
||||
case "session.text.delta":
|
||||
return {
|
||||
@@ -203,7 +227,7 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
state,
|
||||
outputs: send({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: reasoningMessageID(event.data.assistantMessageID, event.data.ordinal),
|
||||
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
}),
|
||||
}
|
||||
@@ -321,6 +345,7 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
}
|
||||
}
|
||||
|
||||
/** The ACP failure a settled turn reports instead of a response, if any. */
|
||||
export function failure(state: TurnState) {
|
||||
const error = state.stepError ?? state.executionError
|
||||
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
|
||||
@@ -348,63 +373,155 @@ export function response(state: TurnState, sessionID: string, terminal: Terminal
|
||||
: undefined
|
||||
const error = (state.stepError ?? state.executionError)?.type
|
||||
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
|
||||
// Interruption clears the projected retry, so a retry pending at interrupt is reported here.
|
||||
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
|
||||
const retry = state.retries.get(sessionID)
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
// Child compactions are left to the background consumer.
|
||||
export function abandon(state: TurnState, ctx: Context): Step {
|
||||
const compaction = state.compactions.get(ctx.sessionID)
|
||||
/** Fails the tools a cancelled turn left open, for when the server's wind-down never reports them. */
|
||||
export function abandonTools(state: TurnState, ctx: Context): Step {
|
||||
return {
|
||||
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
|
||||
outputs: [
|
||||
...[...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
state: { ...state, tools: new Map() },
|
||||
outputs: [...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
),
|
||||
...(compaction
|
||||
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function reasoningMessageID(messageID: string, ordinal: number) {
|
||||
return `${messageID}:reasoning:${ordinal}`
|
||||
/** Lazy, so a message that fails to translate part way still replays the updates before the failure. */
|
||||
export function* replayMessage(message: SessionMessageInfo, cwd: string): Generator<SessionUpdate> {
|
||||
if (message.type === "user") {
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } }
|
||||
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
|
||||
type: "file",
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
}))
|
||||
for (const chunk of partsToContentChunks(files))
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk }
|
||||
return
|
||||
}
|
||||
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
|
||||
if (message.type === "compaction" && message.status !== "running") {
|
||||
yield compactionUpdate({
|
||||
status: message.status,
|
||||
messageId: message.id,
|
||||
reason: message.reason,
|
||||
...(message.status === "failed" ? { error: message.error } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
|
||||
let reasoningOrdinal = 0
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
yield { sessionUpdate: "agent_message_chunk", messageId: message.id, content: { type: "text", text: part.text } }
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
yield {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
|
||||
content: { type: "text", text: part.text },
|
||||
}
|
||||
continue
|
||||
}
|
||||
yield {
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.status === "streaming" ? {} : part.state.input },
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
switch (part.state.status) {
|
||||
case "completed":
|
||||
yield {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
break
|
||||
case "running":
|
||||
yield {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({ toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, cwd }),
|
||||
}
|
||||
break
|
||||
case "error":
|
||||
yield {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
error: part.state.error.message,
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
break
|
||||
case "streaming":
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function newTool(sessionID: string, id: string, name = "tool"): Tool {
|
||||
return { sessionID, id, name, input: {}, metadata: {} }
|
||||
}
|
||||
|
||||
function route(ctx: Context, child: ACPChild.Session | undefined, update: SessionUpdate): Output[] {
|
||||
function route(ctx: Context, child: ChildSession | undefined, update: SessionUpdate): Output[] {
|
||||
if (!child) return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update }] : []
|
||||
const projected = ACPChild.project(update, child)
|
||||
const projected = projectChildUpdate(update, child)
|
||||
if (ctx.childUpdates) return childStatus(ctx, child, { type: "update", update: projected })
|
||||
return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update: projected }] : []
|
||||
}
|
||||
|
||||
function childStatus(ctx: Context, child: ACPChild.Session, event: ACPChild.Event): Output[] {
|
||||
function childStatus(ctx: Context, child: ChildSession, value: ChildSessionEvent): Output[] {
|
||||
if (!ctx.childUpdates) return []
|
||||
return [{ _tag: "ChildUpdate", update: ACPChild.update(ctx.sessionID, child, event) }]
|
||||
return [
|
||||
{
|
||||
_tag: "ChildUpdate",
|
||||
update: {
|
||||
rootSessionId: ctx.sessionID,
|
||||
childSessionId: child.id,
|
||||
parentSessionId: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...value,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// A background consumer ends once its last open child settles.
|
||||
function childEnded(
|
||||
state: TurnState,
|
||||
ctx: Context,
|
||||
child: ACPChild.Session,
|
||||
status: ACPChild.Event,
|
||||
child: ChildSession,
|
||||
status: ChildSessionEvent,
|
||||
terminal: Terminal,
|
||||
): Step {
|
||||
const openChildren = new Set(state.openChildren)
|
||||
@@ -416,7 +533,7 @@ function childEnded(
|
||||
}
|
||||
}
|
||||
|
||||
function recordStep(state: TurnState, tokens: TokenUsage.Info): TurnState {
|
||||
function recordStep(state: TurnState, tokens: TokenUsageInfo): TurnState {
|
||||
const turn = state.usage?.turn
|
||||
return {
|
||||
...state,
|
||||
@@ -440,7 +557,7 @@ function without<K, V>(map: ReadonlyMap<K, V>, key: K) {
|
||||
return next
|
||||
}
|
||||
|
||||
function sessionIDFromEvent(event: OpenCodeEvent) {
|
||||
function sessionIDFromEvent(event: EventSubscribeOutput) {
|
||||
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||
if (event.type === "form.created") return event.data.form.sessionID
|
||||
return undefined
|
||||
@@ -450,9 +567,61 @@ function toolKey(sessionID: string, id: string) {
|
||||
return `${sessionID}:${id}`
|
||||
}
|
||||
|
||||
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
|
||||
function compactionMarker(event: CompactionEvent, compactions: ReadonlyMap<string, string>) {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
|
||||
const marker: CompactionMarker = { status: "started", messageId, reason: event.data.reason }
|
||||
return { marker, compactions: new Map(compactions).set(sessionID, messageId) }
|
||||
}
|
||||
const tracked = compactions.get(sessionID)
|
||||
const remaining = without(compactions, sessionID)
|
||||
if (event.type === "session.compaction.ended") {
|
||||
const marker: CompactionMarker | undefined = tracked
|
||||
? { status: "completed", messageId: tracked, reason: event.data.reason }
|
||||
: undefined
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
|
||||
const marker: CompactionMarker = {
|
||||
status: "failed",
|
||||
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
}
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
|
||||
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
|
||||
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
|
||||
return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
readonly terminal: Terminal
|
||||
readonly finish: SessionMessage.Assistant["finish"]
|
||||
readonly finish: SessionMessageAssistant["finish"]
|
||||
readonly error?: string
|
||||
}): PromptResponse["stopReason"] {
|
||||
if (input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
|
||||
|
||||
+121
-118
@@ -1,6 +1,10 @@
|
||||
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import {
|
||||
isSessionNotFoundError,
|
||||
type CommandInfo,
|
||||
type OpenCodeClient,
|
||||
type OpenCodeEvent,
|
||||
} from "@opencode/client/promise"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import {
|
||||
@@ -20,27 +24,38 @@ import {
|
||||
} from "effect"
|
||||
import { access, constants } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import { currentModel } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { linkReference, promptContentToParts, type PromptPart } from "./content"
|
||||
import { ACPElicitation } from "./elicitation"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPermission } from "./permission"
|
||||
import { ACPPromise } from "./promise"
|
||||
import type { ACPService } from "./service"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
|
||||
type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
export interface Interface {
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, ACPError.Failure>
|
||||
/**
|
||||
* Runs the session's only turn. Cancelling it, including through the request's `$/cancel_request` signal,
|
||||
* interrupts the turn and still resolves with `stopReason: "cancelled"`.
|
||||
*/
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
|
||||
/** Interrupts the session's active turn and waits for it to settle. No-op when the session is idle. */
|
||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
|
||||
/** Unlike `cancel`, interrupts an idle session too, since server work can outlive its turn. */
|
||||
/** Like `cancel`, but an idle session is still interrupted, since server work can outlive its turn. */
|
||||
readonly close: (sessionID: string) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
}
|
||||
|
||||
/** Core acknowledges an interrupt before its cleanup settles, and its shell tool waits 3s before SIGKILL. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Turn") {}
|
||||
|
||||
/**
|
||||
* How long a cancelled turn keeps forwarding the server's wind-down. Core acknowledges an interrupt before its
|
||||
* cleanup settles, and its shell tool waits 3 seconds before escalating to SIGKILL.
|
||||
*/
|
||||
export const CancelDrainTimeout = Context.Reference<Duration.Input>("@opencode/cli/acp/Turn/CancelDrainTimeout", {
|
||||
defaultValue: () => "5 seconds",
|
||||
})
|
||||
@@ -51,18 +66,21 @@ type PreparedPrompt = {
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: Command.Info
|
||||
readonly command?: CommandInfo
|
||||
}
|
||||
|
||||
type PermissionAsk = Extract<ACPTranslate.Output, { readonly _tag: "PermissionAsk" }>
|
||||
|
||||
/** A turn's event feed. It moves to the session scope when the turn ends with children still running. */
|
||||
type Subscription = {
|
||||
readonly scope: Scope.Closeable
|
||||
readonly events: Queue.Dequeue<OpenCodeEvent, unknown>
|
||||
/** Asks run serially off the event stream. */
|
||||
/** Runs permission and form asks one at a time in ask order, without holding back the rest of the stream. */
|
||||
readonly asks: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
|
||||
/** Completed when the turn is cancelled; pending and later asks then resolve without the client. */
|
||||
readonly cancelled: Deferred.Deferred<void>
|
||||
readonly settled: Map<string, Deferred.Deferred<void>>
|
||||
/** Completed per asked form once the server reports it answered or cancelled. */
|
||||
readonly forms: Map<string, Deferred.Deferred<void>>
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
@@ -70,23 +88,24 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly sessions: ACPSessions.Interface
|
||||
readonly catalog: ACPCatalog.Interface
|
||||
readonly capabilities: Ref.Ref<Capabilities>
|
||||
readonly capabilities: Ref.Ref<ACPService.Capabilities>
|
||||
}) {
|
||||
const scope = yield* Effect.scope
|
||||
const drainTimeout = yield* CancelDrainTimeout
|
||||
const turns = yield* FiberMap.make<string, PromptResponse, ACPError.Failure>()
|
||||
const turns = yield* FiberMap.make<string, PromptResponse, Failure>()
|
||||
|
||||
const subscribe = Effect.fnUntraced(function* () {
|
||||
// Parented to the service scope; the session scope may already be closed.
|
||||
// Parented, so it still closes when the session scope it is handed to is already gone.
|
||||
const subscriptionScope = yield* Scope.fork(scope)
|
||||
const subscription: Subscription = {
|
||||
scope: subscriptionScope,
|
||||
events: yield* input.client.event
|
||||
.subscribe()
|
||||
.pipe(Stream.toQueue({ capacity: "unbounded" }), Scope.provide(subscriptionScope)),
|
||||
events: yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
|
||||
Stream.toQueue({ capacity: "unbounded" }),
|
||||
Scope.provide(subscriptionScope),
|
||||
),
|
||||
asks: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
|
||||
cancelled: yield* Deferred.make<void>(),
|
||||
settled: new Map(),
|
||||
forms: new Map(),
|
||||
}
|
||||
yield* Queue.take(subscription.asks).pipe(
|
||||
Effect.flatten,
|
||||
@@ -102,22 +121,18 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const take = (subscription: Subscription) =>
|
||||
Queue.take(subscription.events).pipe(
|
||||
Effect.catch((error) =>
|
||||
Cause.isDone(error) ? Effect.fail(new ACPError.ServerUnavailableError()) : ACPClient.classify(error),
|
||||
Cause.isDone(error) ? Effect.fail(new ACPError.ServerUnavailableError()) : ACPPromise.classify(error),
|
||||
),
|
||||
)
|
||||
|
||||
// A turn settles only after the asks it saw have been resolved.
|
||||
const asksSettled = Effect.fnUntraced(function* (subscription: Subscription) {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
yield* Queue.offer(subscription.asks, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
|
||||
yield* Deferred.await(settled)
|
||||
})
|
||||
|
||||
const reply = (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
ask: PermissionAsk,
|
||||
settled: Deferred.Deferred<void>,
|
||||
) =>
|
||||
const reply = (subscription: Subscription, ctx: ACPTranslate.Context, ask: PermissionAsk) =>
|
||||
ACPPermission.reply(
|
||||
{
|
||||
client: input.client,
|
||||
@@ -127,8 +142,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
clientSessionID: ctx.sessionID,
|
||||
cwd: ctx.cwd,
|
||||
tool: ask.tool,
|
||||
child: ask.child,
|
||||
settled: Deferred.await(settled),
|
||||
...(ask.child ? { toolCallPrefix: ask.child.id, titlePrefix: ask.child.title } : {}),
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
)
|
||||
@@ -139,27 +153,17 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
return input.connection.sessionUpdate({ sessionId: ctx.sessionID, update: output.update })
|
||||
case "ChildUpdate":
|
||||
return input.connection
|
||||
.extNotification(ACPChild.UpdateMethod, output.update)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logWarning("ACP child session update failed", cause),
|
||||
),
|
||||
)
|
||||
.extNotification(ACPTranslate.ChildSessionUpdateMethod, output.update)
|
||||
.pipe(Effect.ignoreCause)
|
||||
case "PermissionAsk":
|
||||
return Effect.gen(function* () {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.settled.set(output.event.data.id, settled)
|
||||
yield* Queue.offer(subscription.asks, reply(subscription, ctx, output, settled))
|
||||
})
|
||||
return Queue.offer(subscription.asks, reply(subscription, ctx, output)).pipe(Effect.asVoid)
|
||||
case "FormAsk":
|
||||
return Effect.gen(function* () {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancelUnshown(input.client, output.form)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancel(input.client, output.form)
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.settled.set(output.form.id, settled)
|
||||
subscription.forms.set(output.form.id, settled)
|
||||
yield* Queue.offer(
|
||||
subscription.asks,
|
||||
ACPElicitation.reply(
|
||||
@@ -177,10 +181,10 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
),
|
||||
)
|
||||
})
|
||||
case "AskSettled":
|
||||
case "FormSettled":
|
||||
return Effect.suspend(() => {
|
||||
const settled = subscription.settled.get(output.id)
|
||||
subscription.settled.delete(output.id)
|
||||
const settled = subscription.forms.get(output.formID)
|
||||
subscription.forms.delete(output.formID)
|
||||
return settled ? Deferred.succeed(settled, undefined) : Effect.void
|
||||
})
|
||||
}
|
||||
@@ -193,10 +197,8 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
) {
|
||||
while (true) {
|
||||
const event = yield* take(subscription)
|
||||
const next = yield* Ref.modify(state, (current) => {
|
||||
const step = ACPTranslate.step(current, event, ctx)
|
||||
return [step, step.state]
|
||||
})
|
||||
const next = ACPTranslate.step(yield* Ref.get(state), event, ctx)
|
||||
yield* Ref.set(state, next.state)
|
||||
yield* Effect.forEach(next.outputs, (output) => interpret(subscription, ctx, output), { discard: true })
|
||||
if (next.terminal) {
|
||||
yield* asksSettled(subscription)
|
||||
@@ -208,56 +210,60 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const sessionID = attached.id
|
||||
if (prompt.synthetic.length > 0) {
|
||||
yield* input.client.session
|
||||
.synthetic({
|
||||
sessionID,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.synthetic(
|
||||
{
|
||||
sessionID,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (prompt.start.type === "compaction") {
|
||||
yield* input.client.session.compact({ sessionID, id: prompt.start.id }).pipe(Effect.catch(ACPClient.classify))
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.compact({ sessionID, id: prompt.start.id }, { signal }),
|
||||
)
|
||||
return
|
||||
}
|
||||
const command = prompt.command
|
||||
if (command) {
|
||||
yield* input.client.session
|
||||
.command({
|
||||
sessionID,
|
||||
name: command.name,
|
||||
text: prompt.slash?.args ?? "",
|
||||
files: prompt.files,
|
||||
delivery: "steer",
|
||||
})
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.command(
|
||||
{ sessionID, name: command.name, text: prompt.slash?.args ?? "", files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* input.client.session
|
||||
.prompt({ sessionID, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.prompt(
|
||||
{ sessionID, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const interruptServer = (sessionID: string) =>
|
||||
ACPPromise.promise(() => input.client.session.interrupt({ sessionID })).pipe(Effect.ignoreCause)
|
||||
|
||||
// Rejects pending asks, interrupts the server once, then forwards its wind-down until the terminal event or the
|
||||
// timeout. Tools still open at the timeout are reported failed so the client never shows them running.
|
||||
const windDown = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
events: Fiber.Fiber<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
events: Fiber.Fiber<ACPTranslate.Terminal, Failure>,
|
||||
) {
|
||||
yield* Deferred.succeed(subscription.cancelled, undefined)
|
||||
yield* input.client.session
|
||||
.interrupt({ sessionID: ctx.sessionID })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP server interrupt failed", cause),
|
||||
),
|
||||
)
|
||||
yield* interruptServer(ctx.sessionID)
|
||||
if (!(yield* Ref.get(state)).started) return
|
||||
if (Option.exists(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)), Exit.isSuccess)) return
|
||||
if (Option.isSome(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)))) return
|
||||
yield* Fiber.interrupt(events)
|
||||
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
|
||||
const abandoned = ACPTranslate.abandonTools(yield* Ref.get(state), ctx)
|
||||
yield* Ref.set(state, abandoned.state)
|
||||
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
|
||||
Effect.ignore,
|
||||
@@ -293,12 +299,12 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
|
||||
) {
|
||||
const close = Scope.close(subscription.scope, Exit.void)
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) return yield* close
|
||||
if ((yield* Ref.get(state)).openChildren.size === 0) return yield* close
|
||||
// Children outlive a cancelled turn, so their asks still reach the client.
|
||||
// Children that outlive a cancelled turn were not cancelled, so their asks still go to the client.
|
||||
const cancelled = yield* Deferred.make<void>()
|
||||
const background = consume({ ...subscription, cancelled }, { ...ctx, mode: "background" }, state).pipe(
|
||||
Effect.ignore,
|
||||
@@ -311,7 +317,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const settle = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
|
||||
) {
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
const current = yield* Ref.get(state)
|
||||
@@ -329,7 +335,9 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const current = currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model?.limit.context) return
|
||||
const info = yield* input.client.session.get({ sessionID: attached.id }).pipe(Effect.catch(ACPClient.classify))
|
||||
const info = yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.get({ sessionID: attached.id }, { signal }),
|
||||
)
|
||||
yield* input.connection.sessionUpdate({
|
||||
sessionId: attached.id,
|
||||
update: {
|
||||
@@ -340,39 +348,37 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
},
|
||||
})
|
||||
},
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP usage update failed", cause),
|
||||
),
|
||||
(effect) => Effect.ignoreCause(effect),
|
||||
)
|
||||
|
||||
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (
|
||||
attached: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
childUpdates: boolean,
|
||||
) {
|
||||
const state = yield* Ref.make(ACPTranslate.initial)
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: attached.id,
|
||||
cwd: attached.cwd,
|
||||
start: prompt.start,
|
||||
childUpdates: capabilities.childSessionUpdates,
|
||||
compaction: capabilities.compaction,
|
||||
childUpdates,
|
||||
mode: "turn",
|
||||
}
|
||||
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
|
||||
return yield* settle(attached, state, exit)
|
||||
})
|
||||
|
||||
return {
|
||||
prompt: Effect.fnUntraced(function* (params, signal) {
|
||||
return Service.of({
|
||||
prompt: Effect.fn("cli.acp.turn.prompt")(function* (params, signal) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
|
||||
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
|
||||
}
|
||||
const childUpdates = (yield* Ref.get(input.capabilities)).childSessionUpdates
|
||||
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const prompt = preparePrompt(catalog, parts, SessionMessage.ID.create())
|
||||
// Synchronous, so concurrent prompts for one session cannot both register.
|
||||
// Check and register in one synchronous step.
|
||||
const turn = yield* Effect.withFiber((fiber) => {
|
||||
if (FiberMap.hasUnsafe(turns, attached.id)) {
|
||||
return Effect.fail(
|
||||
@@ -382,7 +388,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt), { uninterruptible: true })
|
||||
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt, childUpdates), { uninterruptible: true })
|
||||
FiberMap.setUnsafe(turns, attached.id, forked)
|
||||
return Effect.succeed(forked)
|
||||
})
|
||||
@@ -390,18 +396,18 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
yield* aborted(signal).pipe(Effect.andThen(Fiber.interrupt(turn)), Effect.forkChild)
|
||||
return yield* Fiber.join(turn)
|
||||
}),
|
||||
cancel: Effect.fnUntraced(function* (params) {
|
||||
cancel: Effect.fn("cli.acp.turn.cancel")(function* (params) {
|
||||
yield* FiberMap.remove(turns, params.sessionId)
|
||||
}),
|
||||
close: Effect.fn("cli.acp.turn.close")(function* (sessionID) {
|
||||
if (FiberMap.hasUnsafe(turns, sessionID)) return yield* FiberMap.remove(turns, sessionID)
|
||||
yield* ACPClient.decodeSessionID(sessionID).pipe(
|
||||
Effect.flatMap((id) => input.client.session.interrupt({ sessionID: id })),
|
||||
Effect.catchTag(["ACPInvalidRequestError", "SessionNotFoundError"], () => Effect.void),
|
||||
Effect.catch(ACPClient.classify),
|
||||
yield* ACPPromise.promise(() =>
|
||||
input.client.session.interrupt({ sessionID }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
}),
|
||||
)
|
||||
}),
|
||||
} satisfies Interface
|
||||
})
|
||||
})
|
||||
|
||||
function aborted(signal: AbortSignal) {
|
||||
@@ -413,26 +419,18 @@ function aborted(signal: AbortSignal) {
|
||||
})
|
||||
}
|
||||
|
||||
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: SessionMessage.ID): PreparedPrompt {
|
||||
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: string): PreparedPrompt {
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
|
||||
const slash = detectSlashCommand(text)
|
||||
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
|
||||
return {
|
||||
start:
|
||||
slash && builtinCommands.get(slash.name)?.start === "compaction"
|
||||
? { type: "compaction", id: messageID }
|
||||
: { type: "input", id: messageID },
|
||||
text,
|
||||
files,
|
||||
synthetic,
|
||||
slash,
|
||||
command,
|
||||
}
|
||||
const start = turnStart(messageID, slash)
|
||||
return { start, text, files, synthetic, slash, command }
|
||||
}
|
||||
|
||||
// Covers only missing or permission-denied targets; the server still rejects oversized, non-regular, or unlistable ones.
|
||||
function referenceUnreadableFile(part: PromptPart) {
|
||||
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
|
||||
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
|
||||
@@ -441,7 +439,12 @@ function referenceUnreadableFile(part: PromptPart) {
|
||||
)
|
||||
}
|
||||
|
||||
function detectSlashCommand(text: string) {
|
||||
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): ACPTranslate.TurnStart {
|
||||
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
|
||||
return { type: "input", id: messageID }
|
||||
}
|
||||
|
||||
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { MessageTooLargeError, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/effect"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Writable } from "node:stream"
|
||||
import { ACP } from "../../acp/agent"
|
||||
import { Commands } from "../commands"
|
||||
@@ -15,15 +14,7 @@ export default Runtime.handler(
|
||||
Effect.fn("cli.acp")(function* () {
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const endpoint = yield* Standalone.start()
|
||||
const client = yield* OpenCode.make({ baseUrl: endpoint.url }).pipe(
|
||||
Effect.provideServiceEffect(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.HttpClient.pipe(
|
||||
Effect.map(HttpClient.mapRequest(HttpClientRequest.setHeaders(Service.headers(endpoint) ?? {}))),
|
||||
),
|
||||
),
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
)
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const connection = yield* ACP.connect(client, ndJsonStream(Writable.toWeb(process.stdout), Bun.stdin.stream()))
|
||||
const failure = yield* Effect.raceFirst(
|
||||
Effect.promise(() => connection.closed).pipe(
|
||||
@@ -42,8 +33,10 @@ export default Runtime.handler(
|
||||
Effect.map((reason) => `server exited unexpectedly (${reason})`),
|
||||
),
|
||||
)
|
||||
// Exit directly: closing the scope would wait on the server's graceful shutdown, and its lease pipe ends it anyway.
|
||||
// Closing the handler scope would wait for the private server's graceful shutdown; its lease pipe already
|
||||
// ends the server once this process exits.
|
||||
yield* Effect.sync(() => {
|
||||
// stdout carries ACP, so the diagnostic goes to stderr.
|
||||
if (failure) process.stderr.write(`opencode acp: ${failure}\n`)
|
||||
process.exit(failure ? 1 : 0)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { OpenCode, type PluginInfo } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Config } from "../../../config"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { readFile, rename, writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
|
||||
@@ -20,7 +20,7 @@ export default Runtime.handler(
|
||||
)
|
||||
|
||||
log.info(`Using method: ${method}`)
|
||||
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest(method))
|
||||
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
|
||||
const version = target.trim().replace(/^v/, "")
|
||||
if (version === OPENCODE_VERSION) {
|
||||
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Service, type Endpoint } from "@opencode/client/effect/service"
|
||||
import { OpenCode, type SessionMessageAssistantTool } from "@opencode/client/promise"
|
||||
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
@@ -49,11 +49,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
inherited === undefined
|
||||
? undefined
|
||||
: yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PersistentPty.Handoff))(inherited).pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.logWarning("Ignoring invalid PTY restart handoff; persistent terminals will start fresh").pipe(
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
Effect.mapError(() => new Error("Invalid PTY restart handoff")),
|
||||
)
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Deferred, Effect, Schema, Stream } from "effect"
|
||||
|
||||
@@ -98,7 +98,7 @@ export interface Interface {
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: (method?: Method) => Effect.Effect<string, Error>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
readonly removal: (
|
||||
method: Method,
|
||||
@@ -131,8 +131,8 @@ const make = Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
const executable = yield* fs.realPath(process.execPath)
|
||||
const directory = path.dirname(path.dirname(executable))
|
||||
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
|
||||
.readFileString(path.join(directory, "package.json"))
|
||||
@@ -142,10 +142,6 @@ const make = Effect.gen(function* () {
|
||||
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
|
||||
return manifest.name
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
// "opencode" is Homebrew Core's formula; the others are published to anomalyco/tap.
|
||||
const installedFormula = ["opencode", "opencode-beta", "opencode-v2"].find((name) =>
|
||||
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
|
||||
)
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
@@ -182,7 +178,13 @@ const make = Effect.gen(function* () {
|
||||
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
if (path.resolve(process.execPath) === curlBinary) return "curl"
|
||||
if (installedFormula) return "brew"
|
||||
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
|
||||
if (
|
||||
["opencode-beta", "opencode-v2"].some((name) =>
|
||||
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
|
||||
)
|
||||
)
|
||||
return "brew"
|
||||
if (!installedPackage) return
|
||||
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
@@ -234,14 +236,10 @@ const make = Effect.gen(function* () {
|
||||
|
||||
const release = Effect.fnUntraced(function* (method?: Method) {
|
||||
const distribution = method === "brew" ? "homebrew" : "npm"
|
||||
// Homebrew Core builds its formula on its own schedule, so the tap release does not describe it.
|
||||
const core = method === "brew" && installedFormula === "opencode"
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
fetch(
|
||||
core
|
||||
? "https://formulae.brew.sh/api/formula/opencode.json"
|
||||
: `https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
{
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
@@ -264,22 +262,19 @@ const make = Effect.gen(function* () {
|
||||
retry: "Try again in a few minutes.",
|
||||
}),
|
||||
)
|
||||
const data: { version?: string; metadata?: { package?: string }; versions?: { stable?: string } } =
|
||||
yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) =>
|
||||
new UpgradeError(
|
||||
{
|
||||
title: "Could not read the OpenCode update information",
|
||||
detail: errorDetail(cause),
|
||||
retry: "Try again in a few minutes.",
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
})
|
||||
const version = core ? data.versions?.stable : data.version
|
||||
const packageName = core ? "opencode" : data.metadata?.package
|
||||
if (!version || !packageName)
|
||||
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) =>
|
||||
new UpgradeError(
|
||||
{
|
||||
title: "Could not read the OpenCode update information",
|
||||
detail: errorDetail(cause),
|
||||
retry: "Try again in a few minutes.",
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
})
|
||||
if (!data.metadata?.package)
|
||||
return yield* Effect.fail(
|
||||
new UpgradeError({
|
||||
title: "Could not read the OpenCode update information",
|
||||
@@ -287,11 +282,11 @@ const make = Effect.gen(function* () {
|
||||
retry: "Try again in a few minutes.",
|
||||
}),
|
||||
)
|
||||
return { package: packageName, version }
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
const latest = (selected?: Method) =>
|
||||
(selected ? Effect.succeed(selected) : method()).pipe(
|
||||
const latest = () =>
|
||||
method().pipe(
|
||||
Effect.flatMap(release),
|
||||
Effect.map((data) => data.version),
|
||||
)
|
||||
@@ -351,8 +346,7 @@ const make = Effect.gen(function* () {
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const packageName =
|
||||
method === "brew" && installedFormula === "opencode" ? "opencode" : (yield* release(method)).package
|
||||
const packageName = (yield* release(method)).package
|
||||
const target = `${packageName}@${version}`
|
||||
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
|
||||
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
|
||||
|
||||
@@ -2,16 +2,30 @@ import type { NewSessionResponse, PromptResponse } from "@agentclientprotocol/sd
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createAcpFixture, expectOk, initialize, toolCallStream } from "./subprocess"
|
||||
import { createAcpFixture, expectOk, initialize } from "./subprocess"
|
||||
|
||||
// The first completion reads the file outside cwd; the follow-up completion ends the turn.
|
||||
function readingModel(file: () => string) {
|
||||
return (request: unknown) => {
|
||||
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
|
||||
return toolCallStream("call_read", "read", { path: file() })
|
||||
return new Response(toolCall(file()), { headers: { "content-type": "text/event-stream" } })
|
||||
}
|
||||
}
|
||||
|
||||
function toolCall(file: string) {
|
||||
const call = { index: 0, id: "call_read", type: "function", function: { name: "read", arguments: "" } }
|
||||
const chunks = [
|
||||
{ choices: [{ delta: { role: "assistant", tool_calls: [call] }, finish_reason: null }], usage: null },
|
||||
{
|
||||
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ path: file }) } }] } }],
|
||||
usage: null,
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
|
||||
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
|
||||
]
|
||||
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
|
||||
}
|
||||
|
||||
describe("acp additional directories subprocess", () => {
|
||||
test("tools read files in an additional directory without an external directory ask", async () => {
|
||||
const target = { file: "" }
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { PermissionRule } from "@opencode/client/promise"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { makeSession, rpcError, startWire, type Wire } from "./wire-fixture"
|
||||
|
||||
const key = "opencode.acp.additionalDirectories"
|
||||
|
||||
const grant = (directory: string): Permission.Rule => ({
|
||||
const grant = (directory: string): PermissionRule => ({
|
||||
action: "external_directory",
|
||||
resource: path.join(directory, "*"),
|
||||
effect: "allow",
|
||||
@@ -15,9 +17,9 @@ const sharedLib = path.resolve("/shared/lib")
|
||||
const productDocs = path.resolve("/product-docs")
|
||||
const old = path.resolve("/old")
|
||||
|
||||
const userGrant: Permission.Rule = { action: "external_directory", resource: "/x/**", effect: "allow" }
|
||||
const userGrant: PermissionRule = { action: "external_directory", resource: "/x/**", effect: "allow" }
|
||||
|
||||
const other: Permission.Rule[] = [
|
||||
const other: PermissionRule[] = [
|
||||
{ action: "read", resource: "*.secret", effect: "deny" },
|
||||
{ action: "external_directory", resource: "/shared/lib/private/*", effect: "deny" },
|
||||
]
|
||||
@@ -26,6 +28,56 @@ const updates = (acp: Wire) =>
|
||||
acp.server.requests.filter((request) => request.method === "PATCH" && request.path.startsWith("/api/session/"))
|
||||
|
||||
describe("acp additional directories over the wire", () => {
|
||||
test("session/new grants normalized unique directories other than cwd and lists them", async () => {
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.request("session/new", {
|
||||
cwd: "/workspace",
|
||||
additionalDirectories: ["/shared/lib/", "/workspace", "/docs/../product-docs", "/shared/lib", "/workspace/"],
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
|
||||
permissions: [grant(sharedLib), grant(productDocs)],
|
||||
metadata: { [key]: [sharedLib, productDocs] },
|
||||
})
|
||||
expect((await acp.request("session/list", { cwd: "/workspace" })).sessions).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: created.sessionId,
|
||||
additionalDirectories: [sharedLib, productDocs],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("grants both the written and real spelling of a symlinked root and drops links to cwd", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const root = await fs.realpath(tmp.path)
|
||||
const cwd = path.join(root, "workspace")
|
||||
const shared = path.join(root, "shared")
|
||||
await Promise.all([fs.mkdir(cwd), fs.mkdir(shared)])
|
||||
await Promise.all([
|
||||
fs.symlink(shared, path.join(root, "shared-link")),
|
||||
fs.symlink(cwd, path.join(root, "workspace-link")),
|
||||
])
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.request("session/new", {
|
||||
cwd,
|
||||
additionalDirectories: [path.join(root, "workspace-link"), path.join(root, "shared-link")],
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
|
||||
permissions: [grant(path.join(root, "shared-link")), grant(shared)],
|
||||
metadata: { [key]: [path.join(root, "shared-link")] },
|
||||
})
|
||||
expect((await acp.request("session/list", { cwd })).sessions[0]?.additionalDirectories).toEqual([
|
||||
path.join(root, "shared-link"),
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["shared/lib", "", "/shared/*", "/shared/lib?"])(
|
||||
"rejects %p before creating a session",
|
||||
async (directory) => {
|
||||
@@ -103,4 +155,28 @@ describe("acp additional directories over the wire", () => {
|
||||
})
|
||||
expect(acp.server.sessions.get("ses_source")?.permissions).toEqual([grant(old), ...other])
|
||||
})
|
||||
|
||||
test("leaves sessions alone without additional directories", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_saved", {
|
||||
...makeSession("ses_saved"),
|
||||
metadata: { host: "tui" },
|
||||
permissions: [userGrant, ...other],
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.request("session/new", { cwd: "/workspace", mcpServers: [] })
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_saved", mcpServers: [] })
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_saved", additionalDirectories: [] })
|
||||
|
||||
const create = acp.server.requests.find((request) => request.method === "POST" && request.path === "/api/session")
|
||||
expect(create?.body).toEqual({ location: { directory: "/workspace" } })
|
||||
expect(acp.server.sessions.get(created.sessionId)?.permissions).toBeUndefined()
|
||||
expect(updates(acp)).toEqual([])
|
||||
expect(
|
||||
(await acp.request("session/list", { cwd: "/workspace" })).sessions.map(
|
||||
(session) => session.additionalDirectories,
|
||||
),
|
||||
).toEqual([undefined, undefined])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { CloseSessionResponse, PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
// The mock model holds any completion whose request mentions "hold" until the test releases it,
|
||||
// so cancellation can be exercised while the model turn is genuinely in flight.
|
||||
function heldModel() {
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<string>()
|
||||
return {
|
||||
started: started.promise,
|
||||
release: () => release.resolve("released"),
|
||||
respond(request: unknown) {
|
||||
if (!JSON.stringify(request).includes("hold")) return "accepted"
|
||||
started.resolve()
|
||||
return release.promise
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("acp cancel subprocess", () => {
|
||||
test("$/cancel_request cancels the in-flight prompt and the session stays usable", async () => {
|
||||
const model = heldModel()
|
||||
await using fixture = await createAcpFixture({ respond: model.respond })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const prompt = acp.send<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hold" }],
|
||||
})
|
||||
await model.started
|
||||
await acp.notify("$/cancel_request", { requestId: prompt.id })
|
||||
|
||||
expect(expectOk(await prompt.response).stopReason).toBe("cancelled")
|
||||
model.release()
|
||||
const next = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "again" }],
|
||||
}),
|
||||
)
|
||||
expect(next.stopReason).toBe("end_turn")
|
||||
}, 60_000)
|
||||
|
||||
test("session/close settles the active prompt before responding and leaves other sessions alone", async () => {
|
||||
const model = heldModel()
|
||||
await using fixture = await createAcpFixture({ respond: model.respond })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const closing = await newSession(acp, fixture.home)
|
||||
const other = await newSession(acp, fixture.home)
|
||||
|
||||
const prompt = acp.send<PromptResponse>("session/prompt", {
|
||||
sessionId: closing.sessionId,
|
||||
prompt: [{ type: "text", text: "hold" }],
|
||||
})
|
||||
await model.started
|
||||
const order: string[] = []
|
||||
const close = acp
|
||||
.request<CloseSessionResponse>("session/close", { sessionId: closing.sessionId })
|
||||
.then((response) => {
|
||||
order.push("close")
|
||||
return response
|
||||
})
|
||||
const cancelled = await prompt.response.then((response) => {
|
||||
order.push("prompt")
|
||||
return response
|
||||
})
|
||||
|
||||
expect(expectOk(cancelled).stopReason).toBe("cancelled")
|
||||
expect(expectOk(await close)).toEqual({})
|
||||
expect(order).toEqual(["prompt", "close"])
|
||||
model.release()
|
||||
const next = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: other.sessionId,
|
||||
prompt: [{ type: "text", text: "still here" }],
|
||||
}),
|
||||
)
|
||||
expect(next.stopReason).toBe("end_turn")
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { Clock, Duration, Effect, Fiber } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { it } from "../../../core/test/lib/effect"
|
||||
import { ACPCatalog } from "../../src/acp/catalog"
|
||||
import { buildAgent, planAgent, startWire, testModel, type Wire, type WireOptions } from "./wire-fixture"
|
||||
|
||||
describe("acp catalog service", () => {
|
||||
it.effect("coalesces reloads requested during a reload into one more load", () => {
|
||||
const gate = { held: false, release: Promise.withResolvers<void>() }
|
||||
return withCatalog(
|
||||
{
|
||||
fetch: (request) =>
|
||||
request.path === "/api/agent" && gate.held ? gate.release.promise.then(() => undefined) : undefined,
|
||||
},
|
||||
(acp) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
yield* catalog.get("/workspace")
|
||||
gate.held = true
|
||||
|
||||
const running = yield* catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.promise(() => acp.until(() => requests(acp, "/api/agent") === 2, "the held reload"))
|
||||
const queued = yield* Effect.all(
|
||||
[0, 1].map(() => catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))),
|
||||
)
|
||||
acp.server.catalog.agents = [planAgent, buildAgent]
|
||||
gate.held = false
|
||||
gate.release.resolve()
|
||||
yield* Fiber.join(running)
|
||||
yield* Fiber.joinAll(queued)
|
||||
|
||||
expect(reads(acp, "agent")).toBe(3)
|
||||
expect((yield* catalog.get("/workspace")).defaultModeID).toBe("plan")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("keeps the previous catalog when a reload fails", () => {
|
||||
const failing = { model: false }
|
||||
return withCatalog(
|
||||
{
|
||||
fetch: (request) =>
|
||||
failing.model && request.path === "/api/model"
|
||||
? Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
|
||||
: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
const before = yield* catalog.get("/workspace")
|
||||
failing.model = true
|
||||
|
||||
yield* catalog.reload("/workspace")
|
||||
|
||||
expect(yield* catalog.get("/workspace")).toBe(before)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("waits 25ms between readiness reads", () =>
|
||||
withCatalog({}, (acp) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
acp.server.catalog.models = []
|
||||
|
||||
const loading = yield* catalog.get("/workspace").pipe(Effect.forkChild)
|
||||
yield* Effect.promise(() => acp.until(() => reads(acp, "model") === 1, "the first model read"))
|
||||
acp.server.catalog.models = [testModel]
|
||||
yield* advance("5 millis", () => reads(acp, "model") === 2)
|
||||
const retriedAt = yield* Clock.currentTimeMillis
|
||||
const loaded = yield* Fiber.join(loading)
|
||||
|
||||
expect(retriedAt).toBeGreaterThanOrEqual(25)
|
||||
expect(loaded.defaultModel).toEqual({ providerID: "test", id: "test-model", variant: "default" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("gives up with the last readiness failure after 5 seconds", () =>
|
||||
withCatalog({}, (acp) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
acp.server.catalog.agents = []
|
||||
|
||||
const loading = yield* catalog.get("/workspace").pipe(Effect.flip, Effect.timed, Effect.forkChild)
|
||||
yield* advance("25 millis", () => loading.pollUnsafe() !== undefined)
|
||||
const [elapsed, error] = yield* Fiber.join(loading)
|
||||
|
||||
expect(error).toEqual(new ACPCatalog.NotReadyError({ reason: "agents" }))
|
||||
expect(error.message).toBe("No primary agents are available")
|
||||
// Reads in flight while the clock steps push the last attempt slightly past the deadline.
|
||||
expect(Duration.toMillis(elapsed)).toBeGreaterThanOrEqual(5_000)
|
||||
expect(Duration.toMillis(elapsed)).toBeLessThan(6_000)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function withCatalog<A, E>(options: WireOptions, body: (acp: Wire) => Effect.Effect<A, E, ACPCatalog.Service>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => startWire(options)),
|
||||
(acp) => Effect.promise(() => acp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((acp) =>
|
||||
body(acp).pipe(
|
||||
Effect.provideServiceEffect(ACPCatalog.Service, ACPCatalog.make(OpenCode.make({ baseUrl: acp.server.url }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Catalog reads are real HTTP that settles between sleeps, so the clock moves in steps until the reads catch up.
|
||||
function advance(step: Duration.Input, done: () => boolean) {
|
||||
return TestClock.adjust(step).pipe(
|
||||
Effect.andThen(TestClock.withLive(Effect.sleep("1 millis"))),
|
||||
Effect.repeat({ until: done }),
|
||||
)
|
||||
}
|
||||
|
||||
function reads(acp: Wire, kind: "model" | "agent") {
|
||||
return acp.server.catalogReads.filter((read) => read.kind === kind).length
|
||||
}
|
||||
|
||||
function requests(acp: Wire, path: string) {
|
||||
return acp.server.requests.filter((request) => request.path === path).length
|
||||
}
|
||||
@@ -1,8 +1,230 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { currentValue } from "./select-options"
|
||||
import { rpcError, secondModel, startSession } from "./wire-fixture"
|
||||
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(Array.from({ length: 3 }, () => ["review", "compact"]))
|
||||
})
|
||||
|
||||
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 === 3)
|
||||
expect(commandNames(commands)).toEqual(["review", "ship", "compact"])
|
||||
expect(agentReads(acp)).toBe(reads + 1)
|
||||
|
||||
const second = await acp.newSession()
|
||||
expect(currentValue(second, "mode")).toBe("copilot-build")
|
||||
})
|
||||
|
||||
test("defaults the mode to the first selectable agent the server lists", async () => {
|
||||
const configured = { ...buildAgent, id: "review", name: "Review", mode: "all" as const }
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.agents = [configured, buildAgent, planAgent]
|
||||
await acp.initialize()
|
||||
|
||||
const session = await acp.newSession()
|
||||
|
||||
expect(modeOption(session.configOptions ?? [])).toEqual({
|
||||
currentValue: "review",
|
||||
options: ["review", "build", "plan"],
|
||||
})
|
||||
})
|
||||
|
||||
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 === 3)
|
||||
expect(commands).toEqual({
|
||||
sessionId: session.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
})
|
||||
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 set = (configId: string, value: string) =>
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId, value })
|
||||
const initialModelReads = modelReads(acp)
|
||||
|
||||
const model = await set("model", "test/second-model")
|
||||
const reloadedModelReads = modelReads(acp)
|
||||
const missingModel = await rpcError(set("model", "test/missing-model"))
|
||||
const missingModelReads = modelReads(acp)
|
||||
await acp.request("session/set_mode", { sessionId: session.sessionId, modeId: "copilot-build" })
|
||||
const reads = agentReads(acp)
|
||||
const missing = await rpcError(set("mode", "missing"))
|
||||
|
||||
expect(currentValue(model, "model")).toBe("test/second-model")
|
||||
expect([reloadedModelReads, missingModelReads]).toEqual([initialModelReads + 1, initialModelReads + 2])
|
||||
expect(missingModel).toMatchObject({ code: -32602, data: { modelId: "test/missing-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([
|
||||
[
|
||||
"a sibling session closes",
|
||||
async (acp: Wire) => {
|
||||
const closed = await acp.newSession()
|
||||
const open = await acp.newSession()
|
||||
await acp.request("session/close", { sessionId: closed.sessionId })
|
||||
await initialCommands(acp, open.sessionId, 1)
|
||||
return open.sessionId
|
||||
},
|
||||
],
|
||||
...(["session/load", "session/resume"] as const).map(
|
||||
(method) =>
|
||||
[
|
||||
`${method} re-attaches the session`,
|
||||
async (acp: Wire) => {
|
||||
const session = await acp.newSession()
|
||||
const params = { cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] }
|
||||
await acp.request(method, params)
|
||||
await acp.request(method, params)
|
||||
await initialCommands(acp, session.sessionId, 3)
|
||||
return session.sessionId
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
])("pushes exactly one update per catalog change after %s", async (_, setup) => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const sessionId = await setup(acp)
|
||||
const since = acp.updates.length
|
||||
|
||||
await change(acp, sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
})
|
||||
await change(acp, sessionId, "available_commands_update", () => {
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
})
|
||||
|
||||
expect(updateKinds(acp, since)).toEqual([
|
||||
[sessionId, "config_option_update"],
|
||||
[sessionId, "available_commands_update"],
|
||||
])
|
||||
})
|
||||
|
||||
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
|
||||
@@ -24,6 +246,7 @@ describe("acp catalog and config options over the wire", () => {
|
||||
{ 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" } })
|
||||
@@ -33,4 +256,79 @@ describe("acp catalog and config options over the wire", () => {
|
||||
data: { modelId: "test/missing-model" },
|
||||
})
|
||||
})
|
||||
|
||||
test("advertises and runs the built-in compact over a server command (https://github.com/anomalyco/opencode/issues/37229)", async () => {
|
||||
await using acp = await startSession()
|
||||
const advertised = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
|
||||
|
||||
acp.server.catalog.commands = [
|
||||
reviewCommand,
|
||||
{ name: "compact", description: "Server compact" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
const replaced = await acp.waitForUpdate((item) => item !== advertised && commandNames(item) !== undefined)
|
||||
const compacted = await acp.prompt(acp.sessionId, "/compact")
|
||||
|
||||
expect([advertised, replaced].map((item) => item.update)).toEqual([
|
||||
{
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(compacted.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions.map((item) => item.kind)).toEqual(["compact"])
|
||||
})
|
||||
})
|
||||
|
||||
// Each change waits on a catalog reload over HTTP, so a stray update for an earlier change lands before the next one.
|
||||
async function change(acp: Wire, sessionId: string, kind: string, trigger: () => void) {
|
||||
const seen = acp.updates.filter((item) => item.sessionId === sessionId && item.update.sessionUpdate === kind).length
|
||||
trigger()
|
||||
await acp.until(
|
||||
() =>
|
||||
acp.updates.filter((item) => item.sessionId === sessionId && item.update.sessionUpdate === kind).length > seen,
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
// Each attach sends its commands after the response, so later counts start once every attach has sent them.
|
||||
function initialCommands(acp: Wire, sessionId: string, attaches: number) {
|
||||
return acp.until(
|
||||
() => acp.updates.filter((item) => item.sessionId === sessionId && commandNames(item)).length === attaches,
|
||||
"initial commands",
|
||||
)
|
||||
}
|
||||
|
||||
function updateKinds(acp: Wire, since: number) {
|
||||
return acp.updates.slice(since).map((item) => [item.sessionId, item.update.sessionUpdate])
|
||||
}
|
||||
|
||||
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") }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
|
||||
describe("acp command", () => {
|
||||
test("is registered", async () => {
|
||||
const result = await cli(["--help"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toMatch(/^ acp[ \t]+Start an Agent Client Protocol server\r?$/m)
|
||||
})
|
||||
})
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
cwd: path.join(import.meta.dir, "../.."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
@@ -1,66 +1,200 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { CompactionUpdate, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Schema } from "effect"
|
||||
import { durableEvent, enqueued, ephemeralEvent, startSession, turn, type InitializeOptions } from "./wire-fixture"
|
||||
import {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
makeSession,
|
||||
startSession,
|
||||
startWire,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
turn,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const summary = "Summary of the earlier conversation"
|
||||
const providerError = { type: "provider.error", message: "summary request failed", status: 500 }
|
||||
const decodeCompact = Schema.decodeUnknownSync(Schema.Struct({ id: Schema.String }))
|
||||
|
||||
describe("acp standard compaction updates over the wire", () => {
|
||||
test("streams a /compact turn's summary and completes it with the full summary", async () => {
|
||||
const compacted = await compactTurn(
|
||||
(sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "Summary of " }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "the earlier conversation" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
|
||||
],
|
||||
{ compaction: {} },
|
||||
)
|
||||
await using acp = compacted.acp
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId: compacted.id, status: "in_progress" }),
|
||||
summaryChunk(acp.sessionId, compacted.id, "Summary of "),
|
||||
summaryChunk(acp.sessionId, compacted.id, "the earlier conversation"),
|
||||
compaction(acp.sessionId, {
|
||||
compactionId: compacted.id,
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: summary }],
|
||||
}),
|
||||
describe("acp compaction markers over the wire", () => {
|
||||
test("marks a /compact turn without forwarding the summary text", async () => {
|
||||
const compacted = await compactTurn((sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
|
||||
])
|
||||
expect(compacted.response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("treats a null compaction capability as unsupported", async () => {
|
||||
const compacted = await compactTurn(
|
||||
(sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
|
||||
],
|
||||
{ compaction: null },
|
||||
)
|
||||
await using acp = compacted.acp
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
|
||||
marker(acp.sessionId, { status: "completed", messageId: compacted.id, reason: "manual" }),
|
||||
])
|
||||
expect(compacted.response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("marks a failed /compact turn with the full compaction error", async () => {
|
||||
const compacted = await compactTurn((sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "manual", inputID: id, error: providerError }),
|
||||
])
|
||||
await using acp = compacted.acp
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
|
||||
marker(acp.sessionId, { status: "failed", messageId: compacted.id, reason: "manual", error: providerError }),
|
||||
])
|
||||
expect(compacted.response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("marks an automatic compaction between steps with the ID its replayed message gets", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
textDelta(sessionID, "msg_before", "before"),
|
||||
stepEnded(sessionID, "msg_before"),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: summary, recent: "" }),
|
||||
textDelta(sessionID, "msg_after", "after"),
|
||||
stepEnded(sessionID, "msg_after"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const messageId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
chunk(acp.sessionId, "msg_before", "before"),
|
||||
marker(acp.sessionId, { status: "started", messageId, reason: "auto" }),
|
||||
marker(acp.sessionId, { status: "completed", messageId, reason: "auto" }),
|
||||
chunk(acp.sessionId, "msg_after", "after"),
|
||||
])
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
|
||||
const live = turnUpdates(acp.updates)
|
||||
acp.server.messages.set(acp.sessionId, [
|
||||
{
|
||||
id: messageId,
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary,
|
||||
recent: "",
|
||||
time: { created: 1 },
|
||||
},
|
||||
])
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: acp.sessionId, mcpServers: [] })
|
||||
|
||||
expect(turnUpdates(acp.updates).slice(live.length)).toEqual([
|
||||
marker(acp.sessionId, { status: "completed", messageId, reason: "auto" }),
|
||||
])
|
||||
})
|
||||
|
||||
test("marks automatic compaction failures before and after the compaction starts", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
}),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: providerError }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const unstarted = await events.messageID("session.compaction.failed")
|
||||
const started = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
marker(acp.sessionId, {
|
||||
status: "failed",
|
||||
messageId: unstarted,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
}),
|
||||
marker(acp.sessionId, { status: "started", messageId: started, reason: "auto" }),
|
||||
marker(acp.sessionId, { status: "failed", messageId: started, reason: "auto", error: providerError }),
|
||||
])
|
||||
})
|
||||
|
||||
test("projects child session compaction markers onto the parent turn", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID: "ses_child", text: summary }),
|
||||
durableEvent("session.compaction.ended", {
|
||||
sessionID: "ses_child",
|
||||
reason: "auto",
|
||||
text: summary,
|
||||
recent: "",
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const messageId = await events.messageID("session.compaction.started")
|
||||
const child = { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" }
|
||||
expect(turnUpdates(acp.updates).map((item) => item.update._meta)).toEqual([
|
||||
{ "opencode/compaction": { status: "started", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
{ "opencode/compaction": { status: "completed", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
])
|
||||
})
|
||||
|
||||
test("replays settled compactions at their position on session/load", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_compacted", makeSession("ses_compacted"))
|
||||
acp.server.messages.set("ses_compacted", compactedHistory())
|
||||
await acp.initialize()
|
||||
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_compacted", mcpServers: [] })
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
{
|
||||
sessionId: "ses_compacted",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "msg_user",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
marker("ses_compacted", {
|
||||
status: "failed",
|
||||
messageId: "msg_compaction_failed",
|
||||
reason: "auto",
|
||||
error: providerError,
|
||||
}),
|
||||
marker("ses_compacted", { status: "completed", messageId: "msg_compaction", reason: "manual" }),
|
||||
chunk("ses_compacted", "msg_after", "after"),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// Holds the compact response so the test can publish the turn's events while the request is in flight.
|
||||
async function compactTurn(
|
||||
events: (sessionID: string, id: string) => OpenCodeEventEncoded[],
|
||||
capabilities: InitializeOptions,
|
||||
) {
|
||||
async function compactTurn(events: (sessionID: string, id: string) => OpenCodeEvent[]) {
|
||||
const held = Promise.withResolvers<Response>()
|
||||
const acp = await startSession({
|
||||
capabilities,
|
||||
fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined),
|
||||
})
|
||||
const acp = await startSession({ fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined) })
|
||||
const response = acp.prompt(acp.sessionId, "/compact")
|
||||
const request = await acp.until(
|
||||
() => acp.server.requests.find((item) => item.path.endsWith("/compact")),
|
||||
@@ -68,23 +202,33 @@ async function compactTurn(
|
||||
)
|
||||
const id = decodeCompact(request.body).id
|
||||
acp.server.send(...turn(acp.sessionId, id, ...events(acp.sessionId, id)))
|
||||
held.resolve(enqueued(acp.sessionId, id, "compaction", {}))
|
||||
held.resolve(Response.json({ data: {} }))
|
||||
return { acp, id, response: await response }
|
||||
}
|
||||
|
||||
// Core derives an automatic compaction's message ID from the event ID the server stamps on publish.
|
||||
async function watchEvents(url: string) {
|
||||
const controller = new AbortController()
|
||||
const stream = OpenCode.make({ baseUrl: url }).event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
await stream.next()
|
||||
return {
|
||||
async messageID(type: OpenCodeEvent["type"]) {
|
||||
while (true) {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error(`event stream ended before ${type}`)
|
||||
if (next.value.type === type) return SessionMessage.ID.fromEvent(Event.ID.make(next.value.id))
|
||||
}
|
||||
},
|
||||
[Symbol.dispose]: () => controller.abort(),
|
||||
}
|
||||
}
|
||||
|
||||
function marker(sessionId: string, value: Record<string, unknown>): SessionNotification {
|
||||
return { sessionId, update: { sessionUpdate: "session_info_update", _meta: { "opencode/compaction": value } } }
|
||||
}
|
||||
|
||||
function compaction(sessionId: string, update: CompactionUpdate): SessionNotification {
|
||||
return { sessionId, update: { sessionUpdate: "compaction_update", ...update } }
|
||||
}
|
||||
|
||||
function summaryChunk(sessionId: string, compactionId: string, text: string): SessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: { sessionUpdate: "compaction_summary_chunk", compactionId, content: { type: "text", text } },
|
||||
}
|
||||
function chunk(sessionId: string, messageId: string, text: string): SessionNotification {
|
||||
return { sessionId, update: { sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text } } }
|
||||
}
|
||||
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
@@ -92,3 +236,36 @@ function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
|
||||
)
|
||||
}
|
||||
|
||||
function compactedHistory(): SessionMessageInfo[] {
|
||||
return [
|
||||
{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_compaction_failed",
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
error: providerError,
|
||||
time: { created: 2 },
|
||||
},
|
||||
{
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary,
|
||||
recent: "",
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_compaction_running",
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: "auto",
|
||||
summary: "",
|
||||
recent: "",
|
||||
time: { created: 4 },
|
||||
},
|
||||
assistantMessage("msg_after", { time: { created: 5, completed: 6 }, content: [{ type: "text", text: "after" }] }),
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildEffortSelectOption, parseModelSelection, type ConfigOptionProvider } from "../../src/acp/config-option"
|
||||
import {
|
||||
buildConfigOptions,
|
||||
buildEffortSelectOption,
|
||||
buildModeSelectOption,
|
||||
buildModelSelectOption,
|
||||
formatVariantName,
|
||||
parseModelSelection,
|
||||
type ConfigOptionProvider,
|
||||
} from "../../src/acp/config-option"
|
||||
|
||||
const providers: ConfigOptionProvider[] = [
|
||||
{
|
||||
@@ -14,6 +22,26 @@ const providers: ConfigOptionProvider[] = [
|
||||
]
|
||||
|
||||
describe("acp config options", () => {
|
||||
test("builds the model select option with ACP verifier category", () => {
|
||||
expect(
|
||||
buildModelSelectOption({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
}),
|
||||
).toEqual({
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "anthropic/claude/sonnet-4",
|
||||
options: [
|
||||
{ value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" },
|
||||
{ value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" },
|
||||
{ value: "openai/gpt-5", name: "OpenAI/GPT-5" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
|
||||
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
|
||||
id: "effort",
|
||||
@@ -28,21 +56,96 @@ describe("acp config options", () => {
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("effort fallback uses the first variant when default is absent", () => {
|
||||
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" }).currentValue).toBe(
|
||||
"minimal",
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["openai/gpt-5", { model: { providerID: "openai", modelID: "gpt-5" } }],
|
||||
["openai/gpt-5/low", { model: { providerID: "openai", modelID: "gpt-5" }, variant: "low" }],
|
||||
["anthropic/claude/sonnet-4", { model: { providerID: "anthropic", modelID: "claude/sonnet-4" } }],
|
||||
[
|
||||
"anthropic/claude/sonnet-4/high",
|
||||
{ model: { providerID: "anthropic", modelID: "claude/sonnet-4" }, variant: "high" },
|
||||
],
|
||||
["anthropic/claude/sonnet-4/missing", { model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" } }],
|
||||
])("parses the model selection %s, preferring exact slash-containing model ids", (value, expected) => {
|
||||
expect(parseModelSelection(value, providers)).toEqual(expected)
|
||||
test("builds the mode select option with descriptions when present", () => {
|
||||
expect(
|
||||
buildModeSelectOption({
|
||||
currentModeId: "build",
|
||||
modes: [
|
||||
{ id: "build", name: "Build", description: "Make code changes" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: "build",
|
||||
options: [
|
||||
{ value: "build", name: "Build", description: "Make code changes" },
|
||||
{ value: "plan", name: "Plan" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("builds full config options with model, effort, and mode in stable order", () => {
|
||||
const options = buildConfigOptions({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
currentVariant: "very-high",
|
||||
modes: [
|
||||
{ id: "build", name: "Build" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
],
|
||||
currentModeId: "plan",
|
||||
})
|
||||
|
||||
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||
expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"])
|
||||
expect(options[0]?.currentValue).toBe("anthropic/claude/sonnet-4")
|
||||
expect(options[1]?.currentValue).toBe("very-high")
|
||||
})
|
||||
|
||||
test("full config options omit effort for models without variants", () => {
|
||||
expect(
|
||||
buildConfigOptions({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude-haiku" },
|
||||
}).map((option) => option.id),
|
||||
).toEqual(["model"])
|
||||
})
|
||||
|
||||
test("parses provider/model selections", () => {
|
||||
expect(parseModelSelection("openai/gpt-5", providers)).toEqual({
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
})
|
||||
})
|
||||
|
||||
test("parses provider/model/variant selections when the base model exposes that variant", () => {
|
||||
expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "low",
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers exact slash-containing model ids before treating the tail as a variant", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
})
|
||||
})
|
||||
|
||||
test("parses trailing variants for slash-containing model ids", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps unknown trailing segments in the model id when they are not valid variants", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" },
|
||||
})
|
||||
})
|
||||
|
||||
test("formats variant names for display", () => {
|
||||
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
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 () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const model = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "model")
|
||||
|
||||
expect(model.category).toBe("model")
|
||||
expect(model.currentValue).toBe("test/test-model")
|
||||
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
|
||||
}, 60_000)
|
||||
|
||||
test("model switch updates currentValue", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const model = requireSelectOption(session.configOptions, "model")
|
||||
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
|
||||
expect(nextModel).toBe("test/second-model")
|
||||
|
||||
const updated = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: nextModel,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
|
||||
}, 60_000)
|
||||
|
||||
test('effort option is listed with category "thought_level" when selected model supports variants', async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
|
||||
|
||||
expect(effort.category).toBe("thought_level")
|
||||
expect(effort.currentValue).toBe("default")
|
||||
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"])
|
||||
}, 60_000)
|
||||
|
||||
test("effort survives model synchronization and can be reset to default", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const nextEffort = alternateValue(requireSelectOption(session.configOptions, "effort"))
|
||||
|
||||
const updated = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: nextEffort,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
|
||||
|
||||
const synchronized = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: requireSelectOption(session.configOptions, "model").currentValue,
|
||||
}),
|
||||
)
|
||||
expect(selectConfigOption(synchronized.configOptions, "effort")?.currentValue).toBe(nextEffort)
|
||||
|
||||
const reset = expectOk(
|
||||
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "default",
|
||||
}),
|
||||
)
|
||||
expect(selectConfigOption(reset.configOptions, "effort")?.currentValue).toBe("default")
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
|
||||
|
||||
describe("acp content conversion", () => {
|
||||
test("plain text block becomes a text part", () => {
|
||||
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
|
||||
})
|
||||
|
||||
test("assistant-only text audience becomes synthetic", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "text",
|
||||
text: "internal",
|
||||
annotations: { audience: ["assistant"] },
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "internal", synthetic: true }])
|
||||
})
|
||||
|
||||
test("user-only text audience becomes ignored", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "text",
|
||||
text: "visible to user",
|
||||
annotations: { audience: ["user"] },
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "visible to user", ignored: true }])
|
||||
})
|
||||
|
||||
test("image block with base64 data becomes a data URL file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "image",
|
||||
data: "AAAA",
|
||||
mimeType: "image/png",
|
||||
uri: "file:///tmp/screenshot.png",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:image/png;base64,AAAA",
|
||||
filename: "screenshot.png",
|
||||
mime: "image/png",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("image block with http URI becomes a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "image",
|
||||
data: "",
|
||||
mimeType: "image/jpeg",
|
||||
uri: "http://example.com/assets/photo.jpg",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "http://example.com/assets/photo.jpg",
|
||||
filename: "photo.jpg",
|
||||
mime: "image/jpeg",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("resource_link file URL becomes a file part with name and fallback mime", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource_link",
|
||||
uri: "file:///tmp/notes.txt",
|
||||
name: "client-notes.txt",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "file:///tmp/notes.txt",
|
||||
filename: "client-notes.txt",
|
||||
mime: "text/plain",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("resource_link to another scheme becomes a markdown link", () => {
|
||||
expect(contentBlockToParts({ type: "resource_link", uri: "https://example.com/spec", name: "spec" })).toEqual([
|
||||
{ type: "text", text: "[spec](https://example.com/spec)" },
|
||||
])
|
||||
})
|
||||
|
||||
test("resource_link zed path becomes a file URL part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource_link",
|
||||
uri: "zed://workspace?path=/tmp/project/src/app.ts",
|
||||
name: "app.ts",
|
||||
mimeType: "text/typescript",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: pathToFileURL("/tmp/project/src/app.ts").href,
|
||||
filename: "app.ts",
|
||||
mime: "text/typescript",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("resource with text becomes a sourced text part", () => {
|
||||
const result = contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/context.txt#L12-L14",
|
||||
mimeType: "text/plain",
|
||||
text: "context",
|
||||
},
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.type).toBe("text")
|
||||
if (result[0]?.type === "text") {
|
||||
expect(result[0].text.endsWith("\ncontext")).toBe(true)
|
||||
expect(result[0].text.includes("context.txt")).toBe(true)
|
||||
expect(result[0].text.includes("12")).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("resource with text uses URI fallback for non-file resources", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "mcp://server/context",
|
||||
text: "context",
|
||||
},
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }])
|
||||
})
|
||||
|
||||
test("resource with text includes file path", () => {
|
||||
const result = contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/context.txt",
|
||||
mimeType: "text/plain",
|
||||
text: "context",
|
||||
},
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.type).toBe("text")
|
||||
if (result[0]?.type === "text") {
|
||||
expect(result[0].text.endsWith("\ncontext")).toBe(true)
|
||||
expect(result[0].text.includes("context.txt")).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("resource with blob and mimeType becomes a data URL file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
blob: "JVBERg==",
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:application/pdf;base64,JVBERg==",
|
||||
filename: "report.pdf",
|
||||
mime: "application/pdf",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("data URL resource is preserved as a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "data:text/plain;base64,aGVsbG8=",
|
||||
mimeType: "text/plain",
|
||||
blob: "ignored",
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:text/plain;base64,aGVsbG8=",
|
||||
filename: "file",
|
||||
mime: "text/plain",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("unsupported blocks are ignored", () => {
|
||||
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
|
||||
expect(
|
||||
promptContentToParts([
|
||||
// @ts-expect-error Exercise forward compatibility with an unknown ACP content block.
|
||||
{ type: "unknown", text: "skip" },
|
||||
]),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp replay conversion", () => {
|
||||
test("replays text audience annotations", () => {
|
||||
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
|
||||
{
|
||||
content: {
|
||||
type: "text",
|
||||
text: "cached",
|
||||
annotations: { audience: ["assistant"] },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("replays file and data URL parts as ACP content", () => {
|
||||
expect(
|
||||
partsToContentChunks([
|
||||
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
|
||||
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
content: {
|
||||
type: "resource_link",
|
||||
uri: "file:///tmp/readme.md",
|
||||
name: "readme.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
},
|
||||
{
|
||||
content: {
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: pathToFileURL("note.txt").href,
|
||||
mimeType: "text/plain",
|
||||
text: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { CreateElicitationResponse } from "@agentclientprotocol/sdk"
|
||||
import type { AnyMessage, AnyRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk"
|
||||
import { ACPElicitation } from "../../src/acp/elicitation"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
interrupted,
|
||||
startSession,
|
||||
succeeded,
|
||||
textDelta,
|
||||
@@ -11,13 +14,13 @@ import {
|
||||
toolSucceeded,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const questions = (sessionID: string, id = "frm_question") =>
|
||||
const questions = (sessionID: string, id = "frm_question", tool = "call_question") =>
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id,
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "msg_tools", id: "call_question" } },
|
||||
metadata: { kind: "question", tool: { messageID: "msg_tools", id: tool } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
@@ -42,7 +45,7 @@ const questions = (sessionID: string, id = "frm_question") =>
|
||||
},
|
||||
})
|
||||
|
||||
const capable = { childSessionUpdates: false, formElicitation: true, compaction: false }
|
||||
const capable = { childSessionUpdates: false, formElicitation: true }
|
||||
|
||||
const form = (
|
||||
fields: ACPElicitation.AskedForm["fields"],
|
||||
@@ -54,7 +57,130 @@ const accept = (content: Record<string, string | number | boolean | string[]>):
|
||||
content,
|
||||
})
|
||||
|
||||
const pendingUntilAborted = (_request: unknown, signal: AbortSignal) =>
|
||||
new Promise<CreateElicitationResponse>((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ action: "cancel" }), { once: true })
|
||||
})
|
||||
|
||||
const firstElicitationCancel = (received: readonly AnyMessage[]): AnyMessage => {
|
||||
const asked = received.find(
|
||||
(message): message is AnyRequest =>
|
||||
"method" in message && "id" in message && message.method === "elicitation/create",
|
||||
)
|
||||
return { jsonrpc: "2.0", method: "$/cancel_request", params: { requestId: asked?.id } }
|
||||
}
|
||||
|
||||
describe("acp elicitation mapping", () => {
|
||||
test("maps every representable field type", () => {
|
||||
expect(
|
||||
ACPElicitation.requestedSchema(
|
||||
form([
|
||||
{
|
||||
key: "email",
|
||||
title: "Email",
|
||||
description: "Work address",
|
||||
type: "string",
|
||||
format: "email",
|
||||
maxLength: 80,
|
||||
pattern: ".+@.+",
|
||||
placeholder: "you@example.com",
|
||||
default: "a@b.co",
|
||||
required: true,
|
||||
},
|
||||
{ key: "name", type: "string", minLength: 2 },
|
||||
{
|
||||
key: "plan",
|
||||
type: "string",
|
||||
options: [
|
||||
{ value: "pro", label: "Pro", description: "Paid" },
|
||||
{ value: "free", label: "Free" },
|
||||
],
|
||||
default: "free",
|
||||
},
|
||||
{ key: "ratio", type: "number", minimum: 0, maximum: 1, default: 0.5 },
|
||||
{ key: "count", type: "integer", minimum: 1, required: true },
|
||||
{ key: "confirm", type: "boolean", default: false },
|
||||
{
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
],
|
||||
maxItems: 2,
|
||||
default: ["a"],
|
||||
required: true,
|
||||
},
|
||||
{ key: "server", type: "string", format: "uri", hidden: true, default: "https://example.com" },
|
||||
]),
|
||||
capable,
|
||||
),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
title: "Email",
|
||||
description: "Work address",
|
||||
format: "email",
|
||||
minLength: 1,
|
||||
maxLength: 80,
|
||||
pattern: ".+@.+",
|
||||
default: "a@b.co",
|
||||
},
|
||||
name: { type: "string", minLength: 2 },
|
||||
plan: {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{ const: "pro", title: "Pro", description: "Paid" },
|
||||
{ const: "free", title: "Free" },
|
||||
],
|
||||
default: "free",
|
||||
},
|
||||
ratio: { type: "number", minimum: 0, maximum: 1, default: 0.5 },
|
||||
count: { type: "integer", minimum: 1 },
|
||||
confirm: { type: "boolean", default: false },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: "a", title: "A" },
|
||||
{ const: "b", title: "B" },
|
||||
],
|
||||
},
|
||||
minItems: 1,
|
||||
maxItems: 2,
|
||||
default: ["a"],
|
||||
},
|
||||
},
|
||||
required: ["email", "count", "tags"],
|
||||
})
|
||||
})
|
||||
|
||||
test("adds a free-text property next to options that accept a custom answer", () => {
|
||||
expect(
|
||||
ACPElicitation.requestedSchema(
|
||||
form([
|
||||
{
|
||||
key: "q0",
|
||||
title: "Runtime",
|
||||
type: "string",
|
||||
options: [{ value: "Bun", label: "Bun" }],
|
||||
custom: true,
|
||||
maxLength: 20,
|
||||
},
|
||||
{ key: "q1", type: "multiselect", options: [{ value: "Fast", label: "Fast" }], custom: true },
|
||||
]),
|
||||
capable,
|
||||
)?.properties,
|
||||
).toEqual({
|
||||
q0: { type: "string", title: "Runtime", oneOf: [{ const: "Bun", title: "Bun" }] },
|
||||
q0_custom: { type: "string", title: "Runtime (other)", description: "Type your own answer", maxLength: 20 },
|
||||
q1: { type: "array", items: { anyOf: [{ const: "Fast", title: "Fast" }] } },
|
||||
q1_custom: { type: "string", title: "q1 (other)", description: "Add your own answer" },
|
||||
})
|
||||
})
|
||||
|
||||
test("cancels forms from unsupported clients, unknown flows, and credential-looking fields", () => {
|
||||
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
|
||||
expect(ACPElicitation.requestedSchema(form(fields), { ...capable, formElicitation: false })).toBeUndefined()
|
||||
@@ -65,13 +191,96 @@ describe("acp elicitation mapping", () => {
|
||||
[{ key: "api_key", type: "string" }],
|
||||
[{ key: "q0", title: "GitHub token", type: "string" }],
|
||||
[{ key: "q0", title: "Password", type: "string", hidden: true, default: "" }],
|
||||
[{ key: "q0", title: "Setup", description: "Paste your API key", type: "string" }],
|
||||
[{ key: "q0", title: "Setup", type: "string", options: [{ value: "a", label: "Use my access token" }] }],
|
||||
]
|
||||
expect(credentials.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
|
||||
credentials.map(() => undefined),
|
||||
)
|
||||
})
|
||||
|
||||
test("cancels forms it cannot represent faithfully", () => {
|
||||
const options = [{ value: "a", label: "A" }]
|
||||
const unrepresentable: Array<ACPElicitation.AskedForm["fields"]> = [
|
||||
[
|
||||
{ key: "mode", type: "boolean" },
|
||||
{ key: "detail", type: "string", when: [{ key: "mode", op: "eq", value: true }] },
|
||||
],
|
||||
[
|
||||
{ key: "mode", type: "boolean" },
|
||||
{ key: "detail", type: "string", hidden: true, default: "x", when: [{ key: "mode", op: "eq", value: true }] },
|
||||
],
|
||||
[{ key: "login", type: "external", url: "https://example.com/login" }],
|
||||
[{ key: "server", type: "string", hidden: true, required: true }],
|
||||
[{ key: "pick", type: "string", options, custom: true, required: true }],
|
||||
[{ key: "pick", type: "multiselect", options, custom: true, maxItems: 1 }],
|
||||
[{ key: "pick", type: "string", options, default: "b" }],
|
||||
[{ key: "pick", type: "multiselect", options, default: ["a", "b"] }],
|
||||
[
|
||||
{ key: "pick", type: "string", options, custom: true },
|
||||
{ key: "pick_custom", type: "string" },
|
||||
],
|
||||
]
|
||||
expect(unrepresentable.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
|
||||
unrepresentable.map(() => undefined),
|
||||
)
|
||||
expect(
|
||||
ACPElicitation.requestedSchema(
|
||||
form([
|
||||
{ key: "name", type: "string" },
|
||||
{ key: "pick", type: "string", options, default: "b", hidden: true },
|
||||
]),
|
||||
capable,
|
||||
)?.properties,
|
||||
).toEqual({ name: { type: "string" } })
|
||||
})
|
||||
|
||||
test("maps an accepted response back to answers", () => {
|
||||
const options = [{ value: "a", label: "A" }]
|
||||
const fields: ACPElicitation.AskedForm["fields"] = [
|
||||
{ key: "single", type: "string", options, custom: true },
|
||||
{ key: "multi", type: "multiselect", options, custom: true },
|
||||
{ key: "blank", type: "string", options, custom: true },
|
||||
{ key: "count", type: "integer" },
|
||||
{ key: "server", type: "string", hidden: true, default: "https://example.com" },
|
||||
{ key: "region", type: "string", hidden: true },
|
||||
]
|
||||
expect(
|
||||
ACPElicitation.answer(
|
||||
form(fields),
|
||||
accept({
|
||||
single: "a",
|
||||
single_custom: "typed",
|
||||
multi: ["a"],
|
||||
multi_custom: "extra",
|
||||
blank: "a",
|
||||
blank_custom: " ",
|
||||
count: 3,
|
||||
server: "https://other.example.com",
|
||||
unknown: true,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
single: "typed",
|
||||
multi: ["a", "extra"],
|
||||
blank: "a",
|
||||
count: 3,
|
||||
server: "https://example.com",
|
||||
})
|
||||
expect(ACPElicitation.answer(form(fields), accept({ multi_custom: "only" }))).toEqual({
|
||||
multi: ["only"],
|
||||
server: "https://example.com",
|
||||
})
|
||||
expect(ACPElicitation.answer(form(fields), { action: "accept" })).toEqual({ server: "https://example.com" })
|
||||
})
|
||||
|
||||
test("has no answer unless the user accepted valid content", () => {
|
||||
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
|
||||
expect(ACPElicitation.answer(form(fields), { action: "decline" })).toBeUndefined()
|
||||
expect(ACPElicitation.answer(form(fields), { action: "cancel" })).toBeUndefined()
|
||||
expect(ACPElicitation.answer(form(fields), { action: "_custom" })).toBeUndefined()
|
||||
expect(
|
||||
ACPElicitation.answer(form(fields), { action: "accept", content: { name: { nested: true } } }),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp elicitation over the wire", () => {
|
||||
@@ -123,8 +332,137 @@ describe("acp elicitation over the wire", () => {
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toHaveLength(ids.length)
|
||||
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(ids)
|
||||
expect(acp.server.cancelledForms.map((item) => item.message)).toEqual(ids.map(() => undefined))
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
|
||||
test("cancels forms outside the allowed flows without asking", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_plugin",
|
||||
sessionID,
|
||||
title: "Plugin",
|
||||
metadata: { kind: "plugin" },
|
||||
fields: [{ key: "name", type: "string" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toEqual([])
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_plugin" }])
|
||||
})
|
||||
|
||||
test("cancelling the turn cancels its pending elicitation and the form, and never sends queued ones", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
questions(sessionID, "frm_pending"),
|
||||
questions(sessionID, "frm_queued"),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
elicitation: pendingUntilAborted,
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
await acp.until(() => acp.server.cancelledForms.length === 2, "form cancellation")
|
||||
expect(acp.elicitations).toHaveLength(1)
|
||||
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(["frm_pending", "frm_queued"])
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
|
||||
})
|
||||
|
||||
test("withdraws an elicitation for a form settled elsewhere and moves on to the next ask", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID, "frm_elsewhere")],
|
||||
elicitation: (request, signal) =>
|
||||
acp.elicitations.length === 1 ? pendingUntilAborted(request, signal) : accept({ q0: "Node" }),
|
||||
onFormReply: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
|
||||
acp.server.send(
|
||||
ephemeralEvent("form.replied", { sessionID: acp.sessionId, id: "frm_elsewhere", answer: { q0: "Bun" } }),
|
||||
questions(acp.sessionId, "frm_next"),
|
||||
)
|
||||
|
||||
expect((await prompt).stopReason).toBe("end_turn")
|
||||
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
|
||||
expect(acp.elicitations).toHaveLength(2)
|
||||
expect(acp.server.repliedForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_next", answer: { q0: "Node" } }])
|
||||
expect(acp.server.cancelledForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
|
||||
test("leaves the session alone when the user's answer arrives after the form settled", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID)],
|
||||
elicitation: () => accept({ q0: "Bun" }),
|
||||
fetch: (request) => {
|
||||
if (!request.path.endsWith("/form/frm_question/reply")) return undefined
|
||||
acp.server.send(succeeded(acp.sessionId))
|
||||
return Response.json(
|
||||
{ _tag: "FormAlreadySettledError", id: "frm_question", message: "Form already settled: frm_question" },
|
||||
{ status: 409 },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.server.requests.filter((request) => request.path.endsWith("/reply"))).toHaveLength(1)
|
||||
expect(acp.server.cancelledForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
|
||||
test("prefixes a foreground child form's tool call and message with the child", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
questions("ses_child", "frm_child", "call_child"),
|
||||
],
|
||||
elicitation: () => accept({ q0: "Node" }),
|
||||
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toMatchObject([
|
||||
{ sessionId: acp.sessionId, toolCallId: "ses_child:call_child", message: "Review code: Questions" },
|
||||
])
|
||||
expect(acp.server.repliedForms).toEqual([{ sessionID: "ses_child", formID: "frm_child", answer: { q0: "Node" } }])
|
||||
})
|
||||
|
||||
test("omits the tool call when the child's tool calls only reach the client as child updates", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true, childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
questions("ses_child", "frm_child", "call_child"),
|
||||
],
|
||||
elicitation: () => accept({ q0: "Node" }),
|
||||
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toHaveLength(1)
|
||||
expect(acp.elicitations[0]).toMatchObject({ sessionId: acp.sessionId, message: "Review code: Questions" })
|
||||
expect(acp.elicitations[0]).not.toHaveProperty("toolCallId")
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Cause } from "effect"
|
||||
import { ACPError } from "../../src/acp/error"
|
||||
import { delivered, rpcError, startSession, textDelta, type Wire } from "./wire-fixture"
|
||||
import { rpcError, startSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp errors", () => {
|
||||
test("maps validation failures to invalid params", () => {
|
||||
const cases: ACPError.Error[] = [
|
||||
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
|
||||
new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
|
||||
new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
|
||||
new ACPError.InvalidEffortError({ effort: "extreme" }),
|
||||
new ACPError.InvalidModeError({ mode: "turbo" }),
|
||||
new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" }),
|
||||
]
|
||||
|
||||
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([
|
||||
-32602, -32602, -32602, -32602, -32602, -32602,
|
||||
])
|
||||
})
|
||||
|
||||
test("includes safe validation details", () => {
|
||||
expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: "ses_123" },
|
||||
})
|
||||
expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { modelId: "gpt-missing" },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps auth required to the SDK auth error", () => {
|
||||
const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError())
|
||||
|
||||
expect(requestError).toBeInstanceOf(RequestError)
|
||||
expect(requestError.code).toBe(-32000)
|
||||
expect(requestError.message).toBe("Authentication required: provider authentication required")
|
||||
expect(requestError.data).toEqual({})
|
||||
})
|
||||
|
||||
test("maps service failures to safe internal errors", () => {
|
||||
const requestError = ACPError.toRequestError(
|
||||
new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
|
||||
)
|
||||
|
||||
expect(requestError.code).toBe(-32603)
|
||||
expect(requestError.message).toBe("Internal error: Provider request failed")
|
||||
expect(requestError.data).toEqual({ service: "provider" })
|
||||
})
|
||||
|
||||
test("wraps unknown defects without leaking raw details", () => {
|
||||
const requestError = ACPError.toRequestError(
|
||||
ACPError.fromUnknown(new Error("stack has sk-ant-secret and oauth refresh token")),
|
||||
@@ -18,34 +65,51 @@ describe("acp errors", () => {
|
||||
})
|
||||
|
||||
describe("acp error boundary over the wire", () => {
|
||||
test.each<[string, (acp: Wire) => void, string[]]>([
|
||||
["ends", (acp) => acp.server.closeEvents(), []],
|
||||
[
|
||||
"drops its connection",
|
||||
(acp) => acp.server.dropEvents(),
|
||||
["ACP catalog event stream failed", "ACP selection event stream failed"],
|
||||
],
|
||||
])(
|
||||
"reports an unavailable server when the event stream %s mid-turn and once the server stops",
|
||||
async (_, lose, logs) => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), textDelta(sessionID, "msg_held", "working")],
|
||||
})
|
||||
const unavailable = {
|
||||
code: -32603,
|
||||
message: "Internal error: OpenCode server is unavailable",
|
||||
data: { errorName: "ServerUnavailable" },
|
||||
}
|
||||
test("maps unexpected server failures to the generic internal error", async () => {
|
||||
await using acp = await startWire({
|
||||
fetch: (request) =>
|
||||
request.method === "POST" && request.path === "/api/session" ? new Response(null, { status: 500 }) : undefined,
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk")
|
||||
await acp.request("session/set_mode", { sessionId: acp.sessionId, modeId: "build" })
|
||||
lose(acp)
|
||||
expect(await rpcError(acp.newSession())).toEqual({
|
||||
code: -32603,
|
||||
message: "Internal error: Internal service failure",
|
||||
data: { errorName: "ClientError" },
|
||||
})
|
||||
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
||||
{ message: ["ACP request failed"], cause: { name: "ClientError", reason: "UnexpectedStatus" } },
|
||||
])
|
||||
})
|
||||
|
||||
expect(await rpcError(prompt)).toEqual(unavailable)
|
||||
await acp.server.stop()
|
||||
expect(await rpcError(acp.request("session/list", {}))).toEqual(unavailable)
|
||||
expect(acp.logs.map((log) => String(log.message)).toSorted()).toEqual(logs)
|
||||
},
|
||||
)
|
||||
test("maps rejected prompt submissions to invalid params with the server's message", async () => {
|
||||
await using acp = await startSession({
|
||||
fetch: (request) =>
|
||||
request.method === "POST" && request.path.endsWith("/prompt")
|
||||
? Response.json(
|
||||
{ _tag: "InvalidRequestError", message: "File not readable: missing.png", field: "files" },
|
||||
{ status: 400 },
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toEqual({
|
||||
code: -32602,
|
||||
message: "Invalid params: File not readable: missing.png",
|
||||
data: { field: "files" },
|
||||
})
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("reports an unavailable server once the server stops", async () => {
|
||||
await using acp = await startSession()
|
||||
await acp.server.stop()
|
||||
|
||||
expect(await rpcError(acp.request("session/list", {}))).toEqual({
|
||||
code: -32603,
|
||||
message: "Internal error: OpenCode server is unavailable",
|
||||
data: { errorName: "ServerUnavailable" },
|
||||
})
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize } from "./subprocess"
|
||||
|
||||
describe("acp initialize/auth subprocess", () => {
|
||||
test("initialize responds with capabilities", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const initialized = await initialize(fixture.spawn())
|
||||
|
||||
expect(initialized.protocolVersion).toBe(1)
|
||||
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
|
||||
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
|
||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
expect(initialized.agentInfo?.name).toBe("OpenCode")
|
||||
}, 60_000)
|
||||
|
||||
test("auth negotiation is explicit and safe", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const secret = "subprocess-auth-secret"
|
||||
const acp = fixture.spawn({ OPENCODE_AUTH_CONTENT: secret })
|
||||
const initialized = await initialize(acp)
|
||||
|
||||
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
|
||||
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
|
||||
expect(expectOk(await acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" }))).toEqual(
|
||||
{},
|
||||
)
|
||||
|
||||
const rejected = await acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
|
||||
expect(rejected.error?.code).toBe(-32602)
|
||||
expect(JSON.stringify(rejected.error)).not.toContain(secret)
|
||||
}, 60_000)
|
||||
|
||||
test("initialize without terminal-auth metadata keeps auth command implicit", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const initialized = expectOk(
|
||||
await fixture.spawn().request<InitializeResponse>("initialize", { protocolVersion: 1 }),
|
||||
)
|
||||
|
||||
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
|
||||
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
import type {
|
||||
CloseSessionResponse,
|
||||
DeleteSessionResponse,
|
||||
ListSessionsResponse,
|
||||
LoadSessionResponse,
|
||||
ResumeSessionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, initialize } from "./subprocess"
|
||||
import { selectConfigOption } from "./select-options"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
@@ -9,6 +17,103 @@ describe("acp lifecycle subprocess", () => {
|
||||
expect(await acp.close()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
test("an incoming message over the size limit exits with an error", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const [code] = await Promise.all([
|
||||
acp.exited,
|
||||
// The agent stops reading partway through the line, so the write may fail.
|
||||
acp.notify("opencode/oversized", { data: "a".repeat(32 * 1024 * 1024) }).catch(() => undefined),
|
||||
])
|
||||
await acp[Symbol.asyncDispose]()
|
||||
expect(code).toBe(1)
|
||||
expect(acp.stderr()).toContain("opencode acp: incoming message exceeded the 32 MiB limit\n")
|
||||
}, 60_000)
|
||||
|
||||
test("close capability and close request", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
const initialized = await initialize(acp)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
|
||||
const session = await newSession(acp, fixture.home)
|
||||
expect(
|
||||
expectOk(await acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId })),
|
||||
).toEqual({})
|
||||
}, 60_000)
|
||||
|
||||
test("new session succeeds on the first request", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
|
||||
expect((await newSession(acp, fixture.home)).sessionId).toStartWith("ses_")
|
||||
}, 60_000)
|
||||
|
||||
test("loadSession capability and load request return session config options", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
const initialized = await initialize(acp)
|
||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const loaded = expectOk(
|
||||
await acp.request<LoadSessionResponse>("session/load", {
|
||||
cwd: fixture.home,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
|
||||
const mismatched = await acp.request<LoadSessionResponse>("session/load", {
|
||||
cwd: fixture.root,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
})
|
||||
expect(mismatched.error?.code).toBe(-32602)
|
||||
}, 60_000)
|
||||
|
||||
test("list request includes a live ACP-created session", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
|
||||
|
||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
|
||||
}, 60_000)
|
||||
|
||||
test("delete capability and delete request", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
const initialized = await initialize(acp)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
expect(
|
||||
expectOk(await acp.request<DeleteSessionResponse>("session/delete", { sessionId: session.sessionId })),
|
||||
).toEqual({})
|
||||
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
|
||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
|
||||
}, 60_000)
|
||||
|
||||
test("resume request returns session config options", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
const resumed = expectOk(
|
||||
await acp.request<ResumeSessionResponse>("session/resume", {
|
||||
cwd: fixture.home,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
|
||||
}, 60_000)
|
||||
|
||||
// The private server is found with `pgrep`, which Windows lacks.
|
||||
const testOutsideWindows = process.platform === "win32" ? test.skip : test
|
||||
testOutsideWindows(
|
||||
@@ -17,6 +122,7 @@ describe("acp lifecycle subprocess", () => {
|
||||
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")
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyRequest, CreateElicitationResponse, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import { Cause } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
ephemeralEvent,
|
||||
durableEvent,
|
||||
interrupted,
|
||||
permissionAsked,
|
||||
startSession,
|
||||
startWire,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
toolCalled,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
turn,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
@@ -23,24 +26,11 @@ const allowOnce = () => ({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
|
||||
describe("acp permissions over the wire", () => {
|
||||
test("forwards allow-once and allow-always selections to the server", async () => {
|
||||
const selections: Record<string, () => RequestPermissionResponse> = {
|
||||
call_once: allowOnce,
|
||||
call_always: () => ({ outcome: { outcome: "selected", optionId: "always" } }),
|
||||
perm_selected_reject: () => ({ outcome: { outcome: "selected", optionId: "reject" } }),
|
||||
perm_cancelled: () => ({ outcome: { outcome: "cancelled" } }),
|
||||
perm_failed: () => {
|
||||
throw new Error("client permission UI failed")
|
||||
},
|
||||
}
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_once", "shell"),
|
||||
toolCalled(sessionID, "call_once", { command: "printf hello" }),
|
||||
toolStarted(sessionID, "call_always", "read"),
|
||||
toolCalled(sessionID, "call_always", { path: "/workspace/file.ts" }),
|
||||
permissionAsked(sessionID, "perm_once", {
|
||||
action: "shell",
|
||||
metadata: { command: "printf hello" },
|
||||
@@ -51,14 +41,13 @@ describe("acp permissions over the wire", () => {
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_selected_reject"),
|
||||
permissionAsked(sessionID, "perm_cancelled"),
|
||||
permissionAsked(sessionID, "perm_failed"),
|
||||
),
|
||||
permission: (request) => selections[request.toolCall.toolCallId](),
|
||||
permission: (request) => ({
|
||||
outcome: { outcome: "selected", optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always" },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
@@ -90,171 +79,301 @@ describe("acp permissions over the wire", () => {
|
||||
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: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "/tmp/outside",
|
||||
locations: [{ path: "/tmp/outside/a.ts" }],
|
||||
rawInput: { filepath: "/tmp/outside/a.ts", parentDir: "/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("asks for a background child's permission after the parent turn ends without the child capability", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => turn(sessionID, id, childCreated("ses_background", sessionID, "Research")),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
acp.server.send(
|
||||
durableEvent("session.execution.started", { sessionID: "ses_background" }),
|
||||
permissionAsked("ses_background", "perm_background", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/notes.md" },
|
||||
}),
|
||||
)
|
||||
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
|
||||
|
||||
expect(acp.permissions).toMatchObject([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_background:perm_background", title: "Research: /workspace/notes.md" },
|
||||
},
|
||||
])
|
||||
expect(acp.server.replies).toEqual([
|
||||
{ sessionID: "ses_background", requestID: "perm_background", 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.each<[string, string, string, (sessionID: string, id: string) => OpenCodeEventEncoded]>([
|
||||
["session/request_permission", "perm", "reject", (sessionID, id) => permissionAsked(sessionID, id)],
|
||||
["elicitation/create", "frm", "cancelled", (sessionID, id) => question(sessionID, id)],
|
||||
])(
|
||||
"cancelling the turn cancels its pending %s and settles queued asks without sending them",
|
||||
async (method, prefix, outcome, ask) => {
|
||||
const pendingUntilAborted =
|
||||
<Response>(cancelled: Response) =>
|
||||
(_request: unknown, signal: AbortSignal) =>
|
||||
new Promise<Response>((resolve) => signal.addEventListener("abort", () => resolve(cancelled), { once: true }))
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ask(sessionID, `${prefix}_pending`),
|
||||
ask(sessionID, `${prefix}_queued`),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
permission: pendingUntilAborted<RequestPermissionResponse>({ outcome: { outcome: "cancelled" } }),
|
||||
elicitation: pendingUntilAborted<CreateElicitationResponse>({ action: "cancel" }),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.permissions.length + acp.elicitations.length === 1, "the pending ask")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
const settled = () => [
|
||||
...acp.server.replies.map((reply) => [reply.requestID, reply.decision]),
|
||||
...acp.server.cancelledForms.map((form) => [form.formID, "cancelled"]),
|
||||
]
|
||||
await acp.until(() => settled().length === 2, "both asks settled")
|
||||
expect(settled()).toEqual([
|
||||
[`${prefix}_pending`, outcome],
|
||||
[`${prefix}_queued`, outcome],
|
||||
])
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
const asked = acp.received.filter(
|
||||
(message): message is AnyRequest => "method" in message && "id" in message && message.method === method,
|
||||
)
|
||||
expect(asked).toHaveLength(1)
|
||||
expect(acp.received).toContainEqual({
|
||||
jsonrpc: "2.0",
|
||||
method: "$/cancel_request",
|
||||
params: { requestId: asked[0]?.id },
|
||||
})
|
||||
},
|
||||
)
|
||||
test("withdraws permission requests settled elsewhere and still asks the remaining one", async () => {
|
||||
test("logs a failed server reply and still answers later asks", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
permissionAsked(sessionID, "perm_elsewhere"),
|
||||
permissionAsked(sessionID, "perm_queued"),
|
||||
permissionAsked(sessionID, "perm_remaining"),
|
||||
],
|
||||
fetch: (request) => {
|
||||
if (request.path.endsWith("/permission/perm_remaining/reply")) acp.server.send(succeeded(acp.sessionId))
|
||||
return undefined
|
||||
},
|
||||
permission: (_request, signal) =>
|
||||
acp.permissions.length === 1
|
||||
? new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
|
||||
})
|
||||
: allowOnce(),
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, permissionAsked(sessionID, "perm_failed"), permissionAsked(sessionID, "perm_next")),
|
||||
fetch: (request) =>
|
||||
request.path.endsWith("/permission/perm_failed/reply") ? new Response(null, { status: 500 }) : undefined,
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_failed", "perm_next"])
|
||||
expect(decisions(acp)).toEqual([["perm_next", "once"]])
|
||||
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
||||
{ message: ["ACP permission reply failed"], cause: { name: "ClientError", reason: "UnexpectedStatus" } },
|
||||
])
|
||||
})
|
||||
|
||||
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, "permission request")
|
||||
acp.server.send(
|
||||
ephemeralEvent("permission.replied", { sessionID: acp.sessionId, requestID: "perm_queued", reply: "always" }),
|
||||
ephemeralEvent("permission.replied", { sessionID: acp.sessionId, requestID: "perm_elsewhere", reply: "always" }),
|
||||
)
|
||||
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([])
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_elsewhere", "perm_remaining"])
|
||||
expect(decisions(acp)).toEqual([["perm_remaining", "once"]])
|
||||
const cancels = acp.received.filter((message) => "method" in message && message.method === "$/cancel_request")
|
||||
expect(cancels).toHaveLength(1)
|
||||
expect(acp.logs).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"],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp edit previews over the wire", () => {
|
||||
test("previews added files as new and deleted files as empty", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Promise.all(
|
||||
Object.entries({
|
||||
"file.ts": "one\r\ntwo\r\n",
|
||||
"first.ts": "one\n",
|
||||
"second.ts": "alpha\n",
|
||||
"gone.ts": "gone\n",
|
||||
"stale.ts": "changed\n",
|
||||
"unpatched.ts": "changed\n",
|
||||
}).map(([name, content]) => fs.writeFile(path.join(dir.path, name), content)),
|
||||
)
|
||||
await fs.mkdir(path.join(dir.path, "folder"))
|
||||
const file = (name: string) => path.join(dir.path, name)
|
||||
const patch = (...lines: string[]) => ["*** Begin Patch", ...lines, "*** End Patch"].join("\n")
|
||||
const edit = (sessionID: string, call: string, tool: string, input: Record<string, unknown>, metadata = {}) => [
|
||||
toolStarted(sessionID, call, tool),
|
||||
toolCalled(sessionID, call, input),
|
||||
permissionAsked(sessionID, `perm_${call}`, {
|
||||
action: "edit",
|
||||
metadata,
|
||||
source: { type: "tool", messageID: "msg_edit", id: call },
|
||||
}),
|
||||
]
|
||||
await using acp = await startWire({
|
||||
test("keeps streaming other children while one child's permission is pending", async () => {
|
||||
const release = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
...edit(
|
||||
sessionID,
|
||||
"call_edit",
|
||||
"edit",
|
||||
{ path: "file.ts", oldString: "one\ntwo", newString: "one\nthree" },
|
||||
{ files: [fileDiff("file.ts", "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
|
||||
),
|
||||
...edit(sessionID, "call_patch", "patch", {
|
||||
patchText: patch(
|
||||
"*** Update File: first.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** Update File: second.ts",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+beta",
|
||||
),
|
||||
}),
|
||||
...edit(
|
||||
sessionID,
|
||||
"call_write",
|
||||
"write",
|
||||
{ path: "written.ts", content: "two\n" },
|
||||
{ files: [fileDiff("written.ts", "", "two\n", "added")] },
|
||||
),
|
||||
...edit(sessionID, "call_add", "patch", { patchText: patch("*** Add File: added.ts", "+one") }),
|
||||
permissionAsked(sessionID, "perm_delete", {
|
||||
action: "edit",
|
||||
metadata: { files: [fileDiff("gone.ts", "gone\n", "", "deleted")] },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_stale", {
|
||||
action: "edit",
|
||||
resources: ["stale.ts"],
|
||||
metadata: { files: [fileDiff("stale.ts", "one\n", "two\n")] },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_folder", {
|
||||
action: "edit",
|
||||
resources: ["folder"],
|
||||
metadata: { files: [fileDiff("folder", "one\n", "two\n")] },
|
||||
}),
|
||||
...edit(sessionID, "call_unpatched", "patch", {
|
||||
patchText: patch("*** Update File: unpatched.ts", "@@", "-one", "+two"),
|
||||
}),
|
||||
childCreated("ses_a", sessionID, "A"),
|
||||
childCreated("ses_b", sessionID, "B"),
|
||||
permissionAsked("ses_a", "perm_a"),
|
||||
textDelta("ses_b", "msg_b", "still streaming"),
|
||||
succeeded("ses_b"),
|
||||
succeeded("ses_a"),
|
||||
),
|
||||
permission: () => release.promise,
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk", "child B's chunk")
|
||||
await acp.until(() => acp.permissions.length === 1, "child A's permission")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["ses_a:perm_a"])
|
||||
expect(acp.server.replies).toEqual([])
|
||||
release.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
expect((await prompt).stopReason).toBe("end_turn")
|
||||
expect(decisions(acp)).toEqual([["perm_a", "once"]])
|
||||
})
|
||||
|
||||
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 },
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects asks queued behind a cancelled one without sending them to the client", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
permissionAsked(sessionID, "perm_pending"),
|
||||
permissionAsked(sessionID, "perm_queued"),
|
||||
],
|
||||
onInterrupt: ({ 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(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_pending"])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_pending", "reject"],
|
||||
["perm_queued", "reject"],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp edit previews over the wire", () => {
|
||||
test("previews edits during approval", 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()
|
||||
@@ -262,48 +381,185 @@ describe("acp edit previews over the wire", () => {
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([
|
||||
[{ type: "diff", path: file("file.ts"), oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" }],
|
||||
[
|
||||
{ type: "diff", path: file("first.ts"), oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: file("second.ts"), oldText: "alpha\n", newText: "beta\n" },
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: file }],
|
||||
content: [{ type: "diff", path: file, oldText: "before", newText: "after" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("previews 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" },
|
||||
}),
|
||||
],
|
||||
[{ type: "diff", path: file("written.ts"), oldText: null, newText: "two\n" }],
|
||||
[{ type: "diff", path: file("added.ts"), oldText: null, newText: "one\n" }],
|
||||
[{ type: "diff", path: file("gone.ts"), oldText: "gone\n", newText: "" }],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
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()
|
||||
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: path.join(dir.path, "first.ts") }, { path: path.join(dir.path, "second.ts") }],
|
||||
content: [
|
||||
{ type: "diff", path: path.join(dir.path, "first.ts"), oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: path.join(dir.path, "second.ts"), oldText: "alpha\n", newText: "beta\n" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("asks without previews when a patch does not apply to the current file", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "first.ts"), "changed\n")
|
||||
const patchText = ["*** Begin Patch", "*** Update File: first.ts", "@@", "-one", "+two", "*** 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: ({ sessionID }) => [succeeded(sessionID)],
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
kind: "edit",
|
||||
locations: [{ path: path.join(dir.path, "first.ts") }],
|
||||
})
|
||||
expect(acp.permissions[0]?.toolCall.content).toBeUndefined()
|
||||
expect(decisions(acp)).toEqual([["perm_patch", "once"]])
|
||||
})
|
||||
|
||||
test("reports the same absolute locations for a moved file in the permission and tool updates", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "old.ts"), "one\n")
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: old.ts",
|
||||
"*** Move to: new.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_move", "patch"),
|
||||
toolCalled(sessionID, "call_move", { patchText }),
|
||||
permissionAsked(sessionID, "perm_move", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_move", id: "call_move" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: ({ sessionID }) => [
|
||||
toolSucceeded(sessionID, "call_move", {}, "patched"),
|
||||
succeeded(sessionID),
|
||||
],
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
const locations = [{ path: path.join(dir.path, "old.ts") }, { path: path.join(dir.path, "new.ts") }]
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
locations,
|
||||
content: [{ type: "diff", path: path.join(dir.path, "new.ts"), oldText: "one\n", newText: "two\n" }],
|
||||
})
|
||||
expect(
|
||||
acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" && item.update.toolCallId === "call_move"
|
||||
? [[item.update.status, item.update.locations]]
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
["in_progress", locations],
|
||||
["completed", locations],
|
||||
])
|
||||
expect(acp.permissions.slice(0, 2).map((request) => request.toolCall)).toMatchObject([
|
||||
{ title: "file.ts", kind: "edit", locations: [{ path: file("file.ts") }] },
|
||||
{ title: "2 files", kind: "edit", locations: [{ path: file("first.ts") }, { path: file("second.ts") }] },
|
||||
})
|
||||
|
||||
test("does not echo completed edits to a client that advertises writeTextFile", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "after")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { filePath: file, oldString: "before", newString: "after" }),
|
||||
toolSucceeded(sessionID, "call_edit", { files: [{ file }] }, "edited"),
|
||||
),
|
||||
})
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.writes).toEqual([])
|
||||
expect(
|
||||
acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed" ? [item.update] : [],
|
||||
),
|
||||
).toMatchObject([
|
||||
{
|
||||
toolCallId: "call_edit",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "edited" } },
|
||||
{ type: "diff", path: file, oldText: "before", newText: "after" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(acp.permissions.slice(5).map((request) => request.toolCall.locations)).toEqual([
|
||||
[{ path: file("stale.ts") }],
|
||||
[{ path: file("folder") }],
|
||||
[{ path: file("unpatched.ts") }],
|
||||
])
|
||||
expect(decisions(acp)).toHaveLength(8)
|
||||
})
|
||||
})
|
||||
|
||||
function question(sessionID: string, id: string) {
|
||||
return ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id,
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "q0", title: "Runtime", type: "string", options: [{ value: "Bun", label: "Bun" }] }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function decisions(acp: Wire) {
|
||||
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
|
||||
}
|
||||
|
||||
function fileDiff(file: string, before: string, after: string, status: "added" | "deleted" | "modified" = "modified") {
|
||||
return { file, patch: createTwoFilesPatch(file, file, before, after), additions: 1, deletions: 1, status }
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess
|
||||
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
|
||||
|
||||
describe("acp prompt content subprocess", () => {
|
||||
test("accepts embedded text resource, inline and remote image, and file resource link prompt content", async () => {
|
||||
test("accepts embedded text resource image and file resource link prompt content", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
await Bun.write(path.join(fixture.home, "README.md"), "# ACP content smoke\n")
|
||||
const acp = fixture.spawn()
|
||||
@@ -41,16 +41,6 @@ describe("acp prompt content subprocess", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const remote = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [
|
||||
{ type: "text", text: "Use this remote image." },
|
||||
{ type: "image", mimeType: "image/png", data: "", uri: "https://example.com/remote.png" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const linked = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
@@ -80,9 +70,8 @@ describe("acp prompt content subprocess", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(remote.stopReason).toBe("end_turn")
|
||||
expect(linked.stopReason).toBe("end_turn")
|
||||
expect(missing.stopReason).toBe("end_turn")
|
||||
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(5)
|
||||
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(4)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,32 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import type { StopReason } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { Schema } from "effect"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
makeSession,
|
||||
reasoningDelta,
|
||||
reviewCommand,
|
||||
rpcError,
|
||||
startSession,
|
||||
startWire,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
textDelta,
|
||||
tokens,
|
||||
toolCalled,
|
||||
toolFailed,
|
||||
toolProgress,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
turn,
|
||||
type Wire,
|
||||
type WireOptions,
|
||||
@@ -41,159 +36,65 @@ const held = {
|
||||
|
||||
describe("acp prompt turns over the wire", () => {
|
||||
test("streams an admitted turn and resolves with usage after its terminal event", async () => {
|
||||
const releaseAdmission = Promise.withResolvers<void>()
|
||||
await using acp = await startSession({ onPrompt: () => releaseAdmission.promise })
|
||||
await acp.request("session/set_config_option", {
|
||||
sessionId: acp.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, textDelta(sessionID, "msg_assistant", "hello"), stepEnded(sessionID, "msg_assistant")),
|
||||
})
|
||||
acp.server.sessions.set(acp.sessionId, makeSession(acp.sessionId, { cost: 3.5 }))
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hi")
|
||||
const settled = { value: false }
|
||||
void prompt.finally(() => {
|
||||
settled.value = true
|
||||
})
|
||||
const submitted = await acp.until(() => acp.server.prompts[0], "prompt submission")
|
||||
const sessionID = acp.sessionId
|
||||
acp.server.send(
|
||||
...turn(
|
||||
sessionID,
|
||||
submitted.id,
|
||||
reasoningDelta(sessionID, "msg_assistant", "think-1"),
|
||||
reasoningDelta(sessionID, "msg_assistant", " continued"),
|
||||
textDelta(sessionID, "msg_assistant", "hello", 1),
|
||||
reasoningDelta(sessionID, "msg_assistant", "think-2", 1),
|
||||
toolStarted(sessionID, "call_ok", "shell"),
|
||||
toolCalled(sessionID, "call_ok", { command: "printf done", workdir: "sub" }),
|
||||
toolProgress(sessionID, "call_ok", { phase: 1 }),
|
||||
toolSucceeded(sessionID, "call_ok", { exit: 0 }, "done"),
|
||||
toolStarted(sessionID, "call_fail", "read"),
|
||||
toolCalled(sessionID, "call_fail", { path: "/workspace/missing.ts" }),
|
||||
toolFailed(sessionID, "call_fail", { error: { type: "tool.error", message: "not found" } }),
|
||||
stepEnded(sessionID, "msg_assistant", {
|
||||
tokens: { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
await acp.until(() => turnUpdates(acp.updates).length === 11, "streamed updates")
|
||||
expect(settled.value).toBe(false)
|
||||
releaseAdmission.resolve()
|
||||
const response = await acp.prompt(acp.sessionId, "hi")
|
||||
|
||||
expect(await prompt).toEqual({
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
thoughtTokens: 7,
|
||||
cachedReadTokens: 11,
|
||||
cachedWriteTokens: 13,
|
||||
totalTokens: 171,
|
||||
},
|
||||
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, id: expect.stringMatching(/^msg_/), text: "hi", files: [], delivery: "steer" },
|
||||
{
|
||||
kind: "prompt",
|
||||
sessionID: acp.sessionId,
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
text: "hi",
|
||||
files: [],
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
expect(
|
||||
turnUpdates(acp.updates).map((item) => [
|
||||
item.update.sessionUpdate,
|
||||
"toolCallId" in item.update
|
||||
? item.update.toolCallId
|
||||
: "messageId" in item.update
|
||||
? item.update.messageId
|
||||
: undefined,
|
||||
"status" in item.update ? item.update.status : undefined,
|
||||
]),
|
||||
).toEqual([
|
||||
["agent_thought_chunk", "msg_assistant:reasoning:0", undefined],
|
||||
["agent_thought_chunk", "msg_assistant:reasoning:0", undefined],
|
||||
["agent_message_chunk", "msg_assistant", undefined],
|
||||
["agent_thought_chunk", "msg_assistant:reasoning:1", undefined],
|
||||
["tool_call", "call_ok", "pending"],
|
||||
["tool_call_update", "call_ok", "in_progress"],
|
||||
["tool_call_update", "call_ok", "in_progress"],
|
||||
["tool_call_update", "call_ok", "completed"],
|
||||
["tool_call", "call_fail", "pending"],
|
||||
["tool_call_update", "call_fail", "in_progress"],
|
||||
["tool_call_update", "call_fail", "failed"],
|
||||
])
|
||||
expect(turnUpdates(acp.updates)[2]?.update).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_assistant",
|
||||
content: { type: "text", text: "hello" },
|
||||
})
|
||||
expect(turnUpdates(acp.updates)[5]?.update).toMatchObject({
|
||||
title: "printf done",
|
||||
kind: "execute",
|
||||
locations: [{ path: path.resolve("/workspace", "sub") }],
|
||||
rawInput: { command: "printf done", workdir: "sub" },
|
||||
})
|
||||
expect(turnUpdates(acp.updates)[7]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
})
|
||||
expect(turnUpdates(acp.updates)[10]?.update).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "not found" } }],
|
||||
rawOutput: { error: "not found" },
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
|
||||
sessionId: sessionID,
|
||||
update: { sessionUpdate: "usage_update", used: 171, size: 200_000, cost: { amount: 3.5, currency: "USD" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("routes slash commands and compact through their session endpoints", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "compact", description: "Server compact" }]
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession()
|
||||
await using acp = await startSession()
|
||||
|
||||
const command = await acp.prompt(session.sessionId, "/review now")
|
||||
const compact = await acp.prompt(session.sessionId, "/compact")
|
||||
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: session.sessionId, name: "review", text: "now", files: [], delivery: "steer" },
|
||||
{ kind: "compact", sessionID: session.sessionId, id: expect.stringMatching(/^msg_/) },
|
||||
{ kind: "command", sessionID: acp.sessionId, name: "review", text: "now", files: [], delivery: "steer" },
|
||||
{ kind: "compact", sessionID: acp.sessionId, id: expect.stringMatching(/^msg_/) },
|
||||
])
|
||||
expect(
|
||||
(await acp.waitForUpdate((item) => item.update.sessionUpdate === "available_commands_update")).update,
|
||||
).toEqual({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("attaches readable file links and references unreadable ones in place", async () => {
|
||||
test("submits assistant-only context as synthetic input before the visible prompt", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = pathToFileURL(path.join(dir.path, "notes.md")).href
|
||||
const folder = pathToFileURL(path.join(dir.path, "src")).href
|
||||
const missing = pathToFileURL(path.join(dir.path, "missing.md")).href
|
||||
const image = pathToFileURL(path.join(dir.path, "local.png")).href
|
||||
const missingImage = pathToFileURL(path.join(dir.path, "missing.png")).href
|
||||
await Bun.write(path.join(dir.path, "notes.md"), "# notes\n")
|
||||
await Bun.write(path.join(dir.path, "local.png"), "png")
|
||||
await mkdir(path.join(dir.path, "src"))
|
||||
const readme = pathToFileURL(path.join(dir.path, "README.md")).href
|
||||
await Bun.write(path.join(dir.path, "README.md"), "# readme\n")
|
||||
await using acp = await startSession()
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "compare" },
|
||||
await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "visible" },
|
||||
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
|
||||
{ type: "resource_link", uri: file, name: "notes.md" },
|
||||
{ type: "resource_link", uri: missing, name: "missing.md" },
|
||||
{ type: "resource_link", uri: folder, name: "src" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/remote.png" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: image },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: missingImage },
|
||||
{ type: "text", text: "please" },
|
||||
{ type: "resource_link", uri: readme, name: "README.md", mimeType: "text/markdown" },
|
||||
])
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions).toEqual([
|
||||
{
|
||||
kind: "synthetic",
|
||||
@@ -205,57 +106,207 @@ describe("acp prompt turns over the wire", () => {
|
||||
},
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: `compare\n[missing.md](${missing})\n[remote.png](https://example.com/remote.png)\n[missing.png](${missingImage})\nplease`,
|
||||
text: "visible",
|
||||
files: [{ uri: readme, name: "README.md" }],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("attaches readable file links and references unreadable ones in place", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = pathToFileURL(path.join(dir.path, "notes.md")).href
|
||||
const folder = pathToFileURL(path.join(dir.path, "src")).href
|
||||
const missing = pathToFileURL(path.join(dir.path, "missing.md")).href
|
||||
await Bun.write(path.join(dir.path, "notes.md"), "# notes\n")
|
||||
await mkdir(path.join(dir.path, "src"))
|
||||
await using acp = await startSession()
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "compare" },
|
||||
{ type: "resource_link", uri: file, name: "notes.md" },
|
||||
{ type: "resource_link", uri: missing, name: "missing.md" },
|
||||
{ type: "resource_link", uri: folder, name: "src" },
|
||||
{ type: "text", text: "please" },
|
||||
])
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: `compare\n[missing.md](${missing})\nplease`,
|
||||
files: [
|
||||
{ uri: file, name: "notes.md" },
|
||||
{ uri: folder, name: "src" },
|
||||
{ uri: image, name: "local.png" },
|
||||
],
|
||||
}),
|
||||
])
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image", data: "", mimeType: "image/png" },
|
||||
]),
|
||||
),
|
||||
).toMatchObject({ code: -32602, message: expect.stringContaining("image content has no data or uri") })
|
||||
expect(acp.server.submissions).toHaveLength(2)
|
||||
})
|
||||
|
||||
test.each<[string, (sessionID: string) => OpenCodeEventEncoded, object]>([
|
||||
[
|
||||
"an execution auth failure",
|
||||
(sessionID) => failed(sessionID, { type: "provider.auth", message: "missing key" }),
|
||||
{ code: -32000 },
|
||||
],
|
||||
[
|
||||
"a step auth failure",
|
||||
(sessionID) =>
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_auth",
|
||||
error: { type: "provider.auth", message: "expired" },
|
||||
}),
|
||||
{ code: -32000 },
|
||||
],
|
||||
[
|
||||
"another execution failure",
|
||||
(sessionID) => failed(sessionID, { type: "provider.rate-limit", message: "slow down" }),
|
||||
{ code: -32603, message: expect.stringContaining("slow down") },
|
||||
],
|
||||
])("maps %s to an ACP error", async (_, failure, expected) => {
|
||||
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 }))
|
||||
|
||||
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"))
|
||||
},
|
||||
})
|
||||
|
||||
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),
|
||||
textDelta(sessionID, "msg_auth", "partial"),
|
||||
failure(sessionID),
|
||||
succeeded(sessionID),
|
||||
failed(sessionID, { type: "provider.auth", message: "missing key" }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject(expected)
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
|
||||
test("maps an assistant step auth error to auth required", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
textDelta(sessionID, "msg_auth", "partial"),
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "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 () => {
|
||||
@@ -280,29 +331,45 @@ describe("acp prompt turns over the wire", () => {
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
|
||||
await aborted.promise
|
||||
expect(acp.server.interrupts).toContain(acp.sessionId)
|
||||
})
|
||||
|
||||
test("session/cancel before admission returns interrupts the session exactly once", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ signal }) =>
|
||||
new Promise<void>((resolve) => signal.addEventListener("abort", () => 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: {} })
|
||||
expect(acp.server.interrupts).toEqual([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("session/cancel forwards the server's wind-down before resolving cancelled", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { compaction: {} },
|
||||
onPrompt: ({ sessionID, id, text }) =>
|
||||
text === "again"
|
||||
? turn(sessionID, id)
|
||||
: [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_sleep", "shell"),
|
||||
toolCalled(sessionID, "call_sleep", { command: "sleep 60" }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
textDelta(sessionID, "msg_held", "working"),
|
||||
],
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_sleep", "shell"),
|
||||
toolCalled(sessionID, "call_sleep", { command: "sleep 60" }),
|
||||
textDelta(sessionID, "msg_held", "working"),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [
|
||||
toolFailed(sessionID, "call_sleep", { error: { type: "aborted", message: "interrupted" } }),
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}),
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_held",
|
||||
@@ -323,31 +390,22 @@ describe("acp prompt turns over the wire", () => {
|
||||
usage: { inputTokens: 30, outputTokens: 3, totalTokens: 33 },
|
||||
_meta: {},
|
||||
})
|
||||
const before = receivedBeforeResponse(acp)
|
||||
expect(before).toEqual(
|
||||
expect(receivedBeforeResponse(acp)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ sessionUpdate: "tool_call_update", toolCallId: "call_sleep", status: "failed" }),
|
||||
expect.objectContaining({ sessionUpdate: "usage_update", used: 33 }),
|
||||
]),
|
||||
)
|
||||
expect(before.filter((update) => update.sessionUpdate === "compaction_update")).toMatchObject([
|
||||
{ status: "in_progress" },
|
||||
{ status: "cancelled", summary: null },
|
||||
])
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("stops waiting for a wind-down that never ends and fails the tools left running", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { compaction: {} },
|
||||
cancelDrainTimeout: "50 millis",
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_stuck", "shell"),
|
||||
toolCalled(sessionID, "call_stuck", { command: "sleep 60" }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
|
||||
textDelta(sessionID, "msg_held", "working"),
|
||||
],
|
||||
})
|
||||
@@ -357,9 +415,7 @@ describe("acp prompt turns over the wire", () => {
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
|
||||
const compactionId = acp.server.prompts[0]?.id
|
||||
const before = receivedBeforeResponse(acp)
|
||||
expect(before).toContainEqual(
|
||||
expect(receivedBeforeResponse(acp)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call_stuck",
|
||||
@@ -367,11 +423,26 @@ describe("acp prompt turns over the wire", () => {
|
||||
rawOutput: expect.objectContaining({ error: "Cancelled" }),
|
||||
}),
|
||||
)
|
||||
expect(before.filter((update) => update.sessionUpdate.startsWith("compaction_"))).toEqual([
|
||||
{ sessionUpdate: "compaction_update", compactionId, status: "in_progress" },
|
||||
{ sessionUpdate: "compaction_summary_chunk", compactionId, content: { type: "text", text: "partial" } },
|
||||
{ sessionUpdate: "compaction_update", compactionId, status: "cancelled", summary: null },
|
||||
])
|
||||
})
|
||||
|
||||
test("session/close interrupts a slash command still running after its prompt ended", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "/review now")).stopReason).toBe("end_turn")
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
await acp.request("session/close", { sessionId: acp.sessionId })
|
||||
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
})
|
||||
|
||||
test("fails the prompt as server unavailable when the event stream ends mid-turn", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold")
|
||||
await admitted(acp, acp.sessionId)
|
||||
acp.server.closeEvents()
|
||||
|
||||
expect(await rpcError(prompt)).toMatchObject({ code: -32603, data: { errorName: "ServerUnavailable" } })
|
||||
})
|
||||
|
||||
test("$/cancel_request on the prompt request cancels the turn like session/cancel", async () => {
|
||||
@@ -411,10 +482,123 @@ describe("acp prompt turns over the wire", () => {
|
||||
data: { sessionId: acp.sessionId },
|
||||
})
|
||||
expect((await acp.prompt(other.sessionId, "still here")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
expect((await acp.prompt(other.sessionId, "/review now")).stopReason).toBe("end_turn")
|
||||
await acp.request("session/close", { sessionId: other.sessionId })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId, other.sessionId])
|
||||
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("reports usage summed across every step of the turn", 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 } }),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
|
||||
})
|
||||
|
||||
test("publishes the last step's context usage rather than the turn sum", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
stepEnded(sessionID, "msg_step_1", {
|
||||
finish: "tool-calls",
|
||||
tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 50 } },
|
||||
}),
|
||||
stepEnded(sessionID, "msg_step_2", {
|
||||
tokens: { input: 20, output: 5, reasoning: 3, cache: { read: 150, write: 0 } },
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({
|
||||
inputTokens: 120,
|
||||
outputTokens: 15,
|
||||
thoughtTokens: 3,
|
||||
cachedReadTokens: 150,
|
||||
cachedWriteTokens: 50,
|
||||
totalTokens: 338,
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "usage_update", used: 178, size: 100_000, cost: { amount: 0, currency: "USD" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("counts a failed step's tokens and clears its error when the next step starts", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_1",
|
||||
error: { type: "provider.stream", message: "stream interrupted" },
|
||||
cost: 0,
|
||||
tokens: { ...tokens(), input: 40, output: 4 },
|
||||
}),
|
||||
durableEvent("session.step.started", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_2",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
started: 0,
|
||||
}),
|
||||
stepEnded(sessionID, "msg_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 60, outputTokens: 11, totalTokens: 71 },
|
||||
_meta: {},
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
|
||||
update: { used: 27 },
|
||||
})
|
||||
})
|
||||
|
||||
test("excludes child session steps from the turn usage", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
stepEnded("ses_child", "msg_child", { tokens: { ...tokens(), input: 500, output: 50 } }),
|
||||
succeeded("ses_child"),
|
||||
stepEnded(sessionID, "msg_root", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({ inputTokens: 20, outputTokens: 7, totalTokens: 27 })
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
|
||||
update: { used: 27 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -439,8 +623,20 @@ function receivedBeforeResponse(acp: Wire) {
|
||||
return acp.updates.slice(0, count).map((item) => item.update)
|
||||
}
|
||||
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
return updates.filter(
|
||||
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
|
||||
)
|
||||
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" },
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { assistantMessage, makeSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp session replay over the wire", () => {
|
||||
@@ -59,9 +59,57 @@ describe("acp session replay over the wire", () => {
|
||||
})
|
||||
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 replayFixtureMessages(): Array<typeof SessionMessage.Info.Encoded> {
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp retry subprocess", () => {
|
||||
test("reports provider retries during the turn and on cancellation", async () => {
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: () =>
|
||||
Response.json(
|
||||
{ error: { message: "rate-limited upstream", type: "rate_limit_error" } },
|
||||
// A long backoff keeps the retry pending until the cancel lands.
|
||||
{ status: 429, headers: { "retry-after": "30" } },
|
||||
),
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const prompt = acp.send<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
const scheduled = await acp.waitForNotification<SessionNotification>(
|
||||
"session/update",
|
||||
(params) => params.sessionId === session.sessionId && params.update.sessionUpdate === "session_info_update",
|
||||
)
|
||||
const retry = scheduled.params.update._meta?.["opencode/retry"]
|
||||
expect(retry).toMatchObject({ attempt: 2, error: { message: expect.stringContaining("rate-limited upstream") } })
|
||||
|
||||
await acp.notify("session/cancel", { sessionId: session.sessionId })
|
||||
const response = expectOk(await prompt.response)
|
||||
expect(response.stopReason).toBe("cancelled")
|
||||
expect(response._meta?.["opencode/retry"]).toEqual(retry)
|
||||
}, 60_000)
|
||||
})
|
||||
Loaded 100 of 382 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user