Compare commits

...

41 Commits

Author SHA1 Message Date
James Long 9aaa24f879 fix(tui): restore shell mode styling 2026-07-22 03:00:10 +00:00
James Long 6b9136e797 fix(tui): inherit elevated tool theme (#38224) 2026-07-21 22:45:21 -04:00
opencode-agent[bot] 80dc21d8f7 fix(cli): use hosted updater API (#38223)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-22 02:34:48 +00:00
Aiden Cline c8a40450e5 feat(codemode): support generator functions (#38172) 2026-07-21 20:36:42 -05:00
Dax Raad e84938b309 fix(core): make session warming observable 2026-07-21 21:30:05 -04:00
Dax Raad c821d49386 feat(updates): add artifact build endpoints 2026-07-21 21:29:42 -04:00
Dax Raad f683eef5f9 feat(core): add session warming 2026-07-21 20:43:26 -04:00
Aiden Cline a0a6963beb fix(core): discover Copilot API endpoint (#38184) 2026-07-21 19:28:31 -05:00
Dax Raad da35eac93f fix(sdk): build without legacy sources 2026-07-21 20:13:27 -04:00
Dax Raad c944048bcf feat(updates): add release control service 2026-07-21 19:58:56 -04:00
Dax Raad c2351e308f fix(ci): skip legacy artifacts on v2 2026-07-21 19:11:52 -04:00
Dax 8de40be6ea feat(plugin): expose app metadata (#38179) 2026-07-21 22:16:25 +00:00
Simon Klee 2ed8fe5960 mini: add hideable footer details (#38192) 2026-07-21 23:28:40 +02:00
Dax 1ea9137c01 fix(core): identify spawned subagents (#38189) 2026-07-21 16:54:01 -04:00
opencode-agent[bot] 77d3289d27 chore: merge dev into v2 (#38182)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
2026-07-21 15:24:47 -05:00
opencode-agent[bot] e6607fb58d fix(core): authorize symlinks by lexical path (#38180)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-21 15:13:18 -05:00
opencode-agent[bot] 128e2550ef fix(core): use shared app node import (#38181)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-21 15:08:53 -05:00
Simon Klee e8d273ae5b mini: add monochrome ASCII mode (#38173) 2026-07-21 22:01:09 +02:00
Aiden Cline 7ec1b6e580 feat(core): deliver CodeMode catalog through instructions (#38003) 2026-07-21 14:46:18 -05:00
Simon Klee c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden Cline 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
Dax 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
opencode-agent[bot] caf727ecb7 fix(core): filter unsupported media inputs (#38145)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-21 13:44:10 -05:00
Dax 9c38358197 refactor: centralize client identity (#38148) 2026-07-21 14:01:04 -04:00
Simon Klee dd6c95fdc7 mini: add quiet transcript settings (#38152) 2026-07-21 19:36:34 +02:00
Aiden Cline 6f4b9504e5 feat(codemode): support custom async iterators (#38141) 2026-07-21 12:18:46 -05:00
Aiden Cline dbfbb13ccc fix(core): authorize patch move destinations (#38133) 2026-07-21 12:18:23 -05:00
Simon Klee 99b2e78d75 fix(tui): improve responsive footer details (#38116) 2026-07-21 19:00:21 +02:00
opencode-agent[bot] 023f76f6fc fix(core): refresh model catalog every five minutes (#38142)
Co-authored-by: Victor Navarro <36263538+vimtor@users.noreply.github.com>
2026-07-21 16:34:54 +00:00
Dax 69d7c2add7 feat(util): configure package publishing (#38129) 2026-07-21 12:32:23 -04:00
Shoubhit Dash cf651bc41b feat(cli): port ACP to V2 (#37907) 2026-07-21 21:13:21 +05:30
Aiden Cline 8d80365ef4 refactor(codemode): simplify model-facing wording (#38042) 2026-07-21 10:19:53 -05:00
Aiden Cline 3ea8895299 fix(core): enforce patch EOF anchors (#38039) 2026-07-21 10:15:51 -05:00
Dax 3304e79827 fix(tui): prevent autocomplete search flicker (#38128) 2026-07-21 10:41:36 -04:00
opencode-agent[bot] cf2c1bf425 refactor(core): break plugin host cycle (#38120)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-21 10:35:00 -04:00
Dax e0810753f2 refactor: extract shared util package (#37828) 2026-07-21 10:34:29 -04:00
Dax 7243bd9e12 fix: preserve runtime option defaults (#38118) 2026-07-21 10:30:18 -04:00
Simon Klee dd50d457b0 refactor(tui): simplify mini footer status line (#38112) 2026-07-21 16:08:19 +02:00
Simon Klee 592ef7433a mini: settle on wait and durable pending (#37984) 2026-07-21 15:42:55 +02:00
opencode-agent[bot] fd97d789ef test(app): stabilize offset observer test (#38044)
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404 target:linux-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404-arm target:linux-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:macos-26 target:darwin-arm64]) (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-21 00:26:46 -05:00
Aiden Cline 7111f93836 feat(codemode): support bounded async iteration (#38040) 2026-07-21 00:02:31 -05:00
467 changed files with 18657 additions and 3618 deletions
+15 -4
View File
@@ -90,11 +90,18 @@ jobs:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build
- name: Build legacy CLI
if: github.ref_name != 'v2'
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
- name: Build preview CLI
id: build
run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -102,6 +109,7 @@ jobs:
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with:
name: opencode-cli
path: |
@@ -109,6 +117,7 @@ jobs:
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
@@ -491,11 +500,13 @@ jobs:
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-windows
path: packages/opencode/dist
+176 -19
View File
@@ -124,16 +124,18 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
@@ -358,52 +360,38 @@
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
},
"devDependencies": {
@@ -418,10 +406,7 @@
"@parcel/watcher-win32-x64": "2.5.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
@@ -706,6 +691,7 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
@@ -771,6 +757,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"effect": "catalog:",
},
@@ -898,6 +885,7 @@
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@@ -972,6 +960,51 @@
"solid-js": "^1.9.0",
},
},
"packages/updates": {
"name": "@opencode-ai/updates",
"version": "1.18.4",
"dependencies": {
"jose": "6.0.11",
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"wrangler": "4.110.0",
},
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.18.3",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@npmcli/arborist": "catalog:",
"@npmcli/config": "10.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"cross-spawn": "catalog:",
"effect": "catalog:",
"glob": "13.0.5",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"xdg-basedir": "5.1.0",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@typescript/native-preview": "catalog:",
},
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.4",
@@ -1140,6 +1173,8 @@
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="],
@@ -2098,6 +2133,10 @@
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
"@opencode-ai/updates": ["@opencode-ai/updates@workspace:packages/updates"],
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@opencode-ai/www": ["@opencode-ai/www@workspace:packages/www"],
@@ -6564,6 +6603,8 @@
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opencode-ai/www/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.44.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260708.1", "unenv": "2.0.0-rc.24", "wrangler": "4.110.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-8wGGunqRcs34o4GRq0Rurp7GZg30xtLJeRGUU81a49r9zQRjlp3xIlsWr3nFlSCso4eE3cjZfiKC/2y116M4TQ=="],
@@ -7834,6 +7875,16 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/updates/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@opencode-ai/updates/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@opencode-ai/updates/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode-ai/updates/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="],
"@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
@@ -8682,6 +8733,72 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@opencode-ai/updates/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@opencode-ai/updates/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@@ -8966,6 +9083,44 @@
"@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
@@ -9116,6 +9271,8 @@
"@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@opencode-ai/www/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
+1 -1
View File
@@ -15,7 +15,7 @@
"dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
+128 -25
View File
@@ -17,6 +17,11 @@ import {
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore, produce } from "solid-js/store"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
@@ -1002,7 +1007,7 @@ function HomeServerRow(props: {
)
}
function HomeProjectList(props: {
type HomeProjectListProps = {
server: ServerConnection.Any
projects: LocalProject[]
selected: HomeProjectSelection
@@ -1013,29 +1018,82 @@ function HomeProjectList(props: {
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
language: ReturnType<typeof useLanguage>
}) {
}
function HomeProjectList(props: HomeProjectListProps) {
const global = useGlobal()
let listRef!: HTMLDivElement
const projects = () => global.ensureServerCtx(props.server).projects
return (
<div class="flex min-w-0 flex-col gap-1">
<For each={props.projects}>
{(project) => (
<HomeProjectRow
project={project}
server={props.server}
selected={
props.selected.server === ServerConnection.key(props.server) &&
props.selected.directory === project.worktree
}
unseenCount={props.unseenCount(props.server, project)}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</For>
</div>
<DragDropProvider
sensors={(defaults) => [
...defaults.filter((sensor) => sensor !== PointerSensor),
PointerSensor.configure({
activationConstraints: (event) =>
event.pointerType === "touch"
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
}),
]}
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
if (props.selected.server !== ServerConnection.key(props.server))
props.selectProject(props.server, source.id.toString())
}}
>
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
{/* Keyed on worktree strings: the enriched project objects are
recreated on every store or sync update, so iterating them directly
remounts all rows — killing any in-flight drag activation (the
row's sortable unregisters on unmount) and discarding animations.
String keys keep row elements alive and move them on reorder. */}
<For each={props.projects.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
</For>
</div>
</DragDropProvider>
)
}
function HomeProjectSlot(
props: HomeProjectListProps & {
worktree: string
index: () => number
},
) {
const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
return (
<Show when={project()}>
{(item) => (
<HomeProjectRow
project={item()}
server={props.server}
index={props.index}
serverSelected={props.selected.server === ServerConnection.key(props.server)}
selected={
props.selected.server === ServerConnection.key(props.server) && props.selected.directory === props.worktree
}
unseenCount={props.unseenCount(props.server, item())}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</Show>
)
}
@@ -1115,6 +1173,8 @@ function HomeRecentlyClosedRow(props: {
function HomeProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean
unseenCount: number
selectProject: (server: ServerConnection.Any, directory: string) => void
@@ -1128,6 +1188,15 @@ function HomeProjectRow(props: {
const platform = usePlatform()
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const [state, setState] = createStore({ menuOpen: false })
const sortable = useSortable({
get id() {
return props.project.worktree
},
get index() {
return props.index()
},
})
let pointerDownSelected: boolean | undefined
const canRevealInFileManager = () =>
platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
const fileManagerActionLabel = () =>
@@ -1144,15 +1213,49 @@ function HomeProjectRow(props: {
)
}
return (
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
<div
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
>
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
sortable.isDragSource(),
}}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
disabled={serverUnreachable()}
onClick={() => props.selectProject(props.server, props.project.worktree)}
onPointerDown={(event) => {
// Same-server mouse selection happens on pointerdown (like tabs),
// but only ever selects; selectProject toggles, and deselecting here
// would fire on every drag before the threshold is met. Cross-server
// selection waits for click so reordering a remote server's projects
// does not focus that server and load its session index. Touch is
// excluded so flick-scrolling the list cannot select rows.
pointerDownSelected = undefined
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
if (!props.selected) props.selectProject(props.server, props.project.worktree)
}}
onClick={(event) => {
// The drag sensor calls preventDefault on post-drag clicks; never
// toggle selection as part of a reorder.
if (event.defaultPrevented) return
// Keyboard activation and touch taps keep the original toggle.
if (event.detail === 0 || pointerDownSelected === undefined) {
props.selectProject(props.server, props.project.worktree)
return
}
// Mouse: pointerdown already selected unselected rows; a plain click
// on an already-selected row toggles it off.
if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
pointerDownSelected = undefined
}}
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
@@ -38,17 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([[0, false]])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
+3 -1
View File
@@ -23,16 +23,18 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
+1 -2
View File
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto"
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
@@ -40,7 +39,7 @@ export async function collectNodeAssets(target: NodeTarget) {
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
{
key: photonWasmAsset,
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
...attentionSoundAssets.map((key) => ({
key,
+8
View File
@@ -3,6 +3,7 @@ import { $ } from "bun"
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
import { fileURLToPath } from "url"
import { UpdateArtifact } from "../../../script/update-artifact"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
@@ -81,3 +82,10 @@ await publishDistribution({
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
})
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
distribution: "npm",
version: Script.version,
metadata: {},
})
+65
View File
@@ -0,0 +1,65 @@
import {
RequestError,
type Agent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
import { ACPError } from "./error"
import { ACPService } from "./service"
export function create(client: OpenCodeClient, connection: AgentSideConnection) {
const service = ACPService.make({ client, connection })
return {
initialize: (params: InitializeRequest) => run(service.initialize(params)),
authenticate: (params: AuthenticateRequest) => run(service.authenticate(params)),
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)),
prompt: (params: PromptRequest) => run(service.prompt(params)),
cancel: (params: CancelNotification) => run(service.cancel(params)),
} satisfies Agent
}
async function run<A>(promise: Promise<A>) {
try {
return await promise
} catch (error) {
if (error instanceof RequestError) throw error
if (isACPError(error)) throw ACPError.toRequestError(error)
throw ACPError.toRequestError(ACPError.fromUnknown(error))
}
}
function isACPError(error: unknown): error is ACPError.Error {
return (
error instanceof ACPError.SessionNotFoundError ||
error instanceof ACPError.InvalidConfigOptionError ||
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidEffortError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.AuthRequiredError ||
error instanceof ACPError.UnknownAuthMethodError ||
error instanceof ACPError.ServiceFailureError
)
}
export * as ACP from "./agent"
+133
View File
@@ -0,0 +1,133 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
export const DEFAULT_VARIANT_VALUE = "default"
export type ConfigOptionModel = {
id: string
name: string
variants?: ReadonlyArray<string>
}
export type ConfigOptionProvider = {
id: string
name: string
models: ReadonlyArray<ConfigOptionModel>
}
export type ConfigOptionMode = {
id: string
name: string
description?: string
}
export type ModelSelection = {
model: { providerID: string; modelID: string }
variant?: string
}
export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
modes?: readonly ConfigOptionMode[]
currentModeId?: string
}): SessionConfigOption[] {
const variants =
input.providers
.find((provider) => provider.id === input.currentModel.providerID)
?.models.find((model) => model.id === input.currentModel.modelID)?.variants ?? []
const effort =
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
return [
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
...(effort ? [effort] : []),
...(input.modes && input.currentModeId
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
: []),
]
}
export function buildModelSelectOption(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
}): SessionConfigOption {
return {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
options: input.providers.flatMap((provider) =>
provider.models
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
),
}
}
export function buildEffortSelectOption(input: {
variants: readonly string[]
currentVariant?: string
}): SessionConfigOption {
return {
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
}
}
export function buildModeSelectOption(input: {
modes: readonly ConfigOptionMode[]
currentModeId: string
}): SessionConfigOption {
return {
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.currentModeId,
options: input.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
}
}
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
if (!provider) {
const separator = modelId.indexOf("/")
if (separator === -1) return { model: { providerID: modelId, modelID: "" } }
return { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
}
const modelID = modelId.slice(provider.id.length + 1)
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
const separator = modelID.lastIndexOf("/")
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
const model = provider.models.find((item) => item.id === baseModelID)
if (model && variant && model.variants?.includes(variant)) {
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
}
return { model: { providerID: provider.id, modelID } }
}
export function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
}
export * as ACPConfigOption from "./config-option"
+183
View File
@@ -0,0 +1,183 @@
import type { ContentBlock, ContentChunk, ResourceLink } from "@agentclientprotocol/sdk"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
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 }
export type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
return content.flatMap(contentBlockToParts)
}
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
switch (block.type) {
case "text": {
const audience = block.annotations?.audience
if (audience?.length === 1 && audience[0] === "assistant") {
return [{ type: "text", text: block.text, synthetic: true }]
}
if (audience?.length === 1 && audience[0] === "user") {
return [{ type: "text", text: block.text, ignored: true }]
}
return [{ type: "text", text: block.text }]
}
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) {
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 [
{
type: "file",
url: block.resource.uri.startsWith("data:")
? block.resource.uri
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
filename: filenameFromUri(block.resource.uri) ?? "file",
mime: block.resource.mimeType,
},
]
default:
return []
}
}
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
return parts.flatMap((part): ContentChunk[] => {
if (part.type === "text") {
if (!part.text) return []
return [
{
content: {
type: "text",
text: part.text,
...(part.synthetic ? { annotations: { audience: ["assistant" as const] } } : {}),
...(!part.synthetic && part.ignored ? { annotations: { audience: ["user" as const] } } : {}),
},
},
]
}
if (part.type === "reasoning") {
return part.text ? [{ content: { type: "text", text: part.text } }] : []
}
if (part.url.startsWith("file://")) {
return [
{
content: {
type: "resource_link",
uri: part.url,
name: part.filename ?? "file",
mimeType: part.mime,
},
},
]
}
if (!part.url.startsWith("data:")) return []
const match = /^data:([^;]+);base64,(.*)$/.exec(part.url)
if (!match?.[1] || match[2] === undefined) return []
const mime = match[1]
const data = match[2]
if (mime.startsWith("image/")) {
return [
{
content: {
type: "image",
mimeType: mime,
data,
uri: pathToFileURL(part.filename ?? "image").href,
},
},
]
}
return [
{
content: {
type: "resource",
resource:
mime.startsWith("text/") || mime === "application/json"
? {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: mime,
text: Buffer.from(data, "base64").toString("utf8"),
}
: {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: mime,
blob: data,
},
},
},
]
})
}
function resourceLinkToPart(link: ResourceLink): PromptPart {
if (link.uri.startsWith("file://")) {
return {
type: "file",
url: link.uri,
filename: link.name || filenameFromUri(link.uri) || "file",
mime: link.mimeType ?? "text/plain",
}
}
if (link.uri.startsWith("zed://") && URL.canParse(link.uri)) {
const pathname = new URL(link.uri).searchParams.get("path")
if (pathname)
return {
type: "file",
url: pathToFileURL(pathname).href,
filename: link.name || path.basename(pathname) || "file",
mime: link.mimeType ?? "text/plain",
}
}
return { type: "text", text: link.uri }
}
function filenameFromUri(uri: string | undefined): string | undefined {
if (!uri || uri.startsWith("data:")) return undefined
if (URL.canParse(uri)) return path.basename(new URL(uri).pathname) || undefined
return path.basename(uri) || undefined
}
export * as ACPContent from "./content"
+86
View File
@@ -0,0 +1,86 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()("ACPSessionNotFoundError", {
sessionId: Schema.String,
}) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPInvalidConfigOptionError",
{ configId: Schema.String },
) {}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
effort: Schema.String,
}) {}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
mode: Schema.String,
}) {}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPUnknownAuthMethodError",
{ methodId: Schema.String },
) {}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
errorName: Schema.optional(Schema.String),
}) {}
export type Error =
| SessionNotFoundError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| AuthRequiredError
| UnknownAuthMethodError
| ServiceFailureError
export function toRequestError(error: Error): RequestError {
switch (error._tag) {
case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
case "ACPInvalidConfigOptionError":
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
case "ACPInvalidModelError":
return RequestError.invalidParams(
{ providerId: error.providerId, modelId: error.modelId },
`model not found: ${error.modelId}`,
)
case "ACPInvalidEffortError":
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
case "ACPInvalidModeError":
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
case "ACPAuthRequiredError":
return RequestError.authRequired({}, "provider authentication required")
case "ACPUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPServiceFailureError":
return RequestError.internalError(
{
...(error.service ? { service: error.service } : {}),
...(error.errorName ? { errorName: error.errorName } : {}),
},
error.safeMessage,
)
}
const exhaustive: never = error
return exhaustive
}
export function fromUnknown(error: unknown, service?: string) {
const errorName = error instanceof Error ? error.name : undefined
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
}
export * as ACPError from "./error"
+441
View File
@@ -0,0 +1,441 @@
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
OpenCodeClient,
SessionMessageAssistant,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ACPError } from "./error"
import { replyPermission, syncEditedFiles } from "./permission"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
type ToolContent,
type ToolInput,
} from "./tool"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
export type TurnControl = {
cancelled: boolean
readonly admission: AbortController
}
type ToolState = {
readonly name: string
input: ToolInput
structured: Record<string, unknown>
content: ToolContent
}
export type TurnStart =
| { readonly type: "input"; readonly id: string }
| { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string }
function emptyToolState(): ToolState {
return { name: "tool", input: {}, structured: {}, content: [] }
}
export async function streamTurn(input: {
readonly client: OpenCodeClient
readonly connection: Connection
readonly sessionID: string
readonly cwd: string
readonly start: TurnStart
readonly userMessageID?: string | null
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
}): Promise<PromptResponse> {
const streamController = new AbortController()
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission")
const control = input.control
let started = false
let assistantMessageID: string | undefined
let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>()
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
const consume = async () => {
while (!streamController.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.v2.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
await replyPermission({
client: input.client,
connection: input.connection,
event,
sessionID: input.sessionID,
cwd: input.cwd,
tool,
})
continue
}
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
await input.client.form
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
if (matchesStart(event, input.start)) {
started = true
continue
}
if (!started) continue
if (event.type === "session.step.started") {
assistantMessageID = event.data.assistantMessageID
continue
}
if (event.type === "session.text.delta") {
assistantMessageID = event.data.assistantMessageID
await update({
sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
}
if (event.type === "session.reasoning.delta") {
assistantMessageID = event.data.assistantMessageID
await update({
sessionUpdate: "agent_thought_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
}
if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.callID,
toolName: event.data.name,
state: { input: {} },
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.called") {
assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.callID) ?? emptyToolState()
current.input = event.data.input
tools.set(event.data.callID, current)
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(event.data.callID)
if (!current) continue
current.structured = event.data.structured
current.content = event.data.content
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
content: current.content,
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await syncEditedFiles({
connection: input.connection,
sessionID: input.sessionID,
cwd: input.cwd,
toolName: current.name,
toolInput: current.input,
structured: event.data.structured,
}).catch(() => {})
await update({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: event.data.structured,
content: event.data.content,
result: event.data.result,
}),
})
continue
}
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await update({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: current.structured,
content: current.content,
error: event.data.error.message,
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.step.ended") {
assistantMessageID = event.data.assistantMessageID
finish = event.data.finish
continue
}
if (event.type === "session.execution.succeeded") return "succeeded" as const
if (event.type === "session.execution.interrupted") return "interrupted" as const
if (event.type === "session.execution.failed") {
executionError = event.data.error
return "failed" as const
}
}
return "interrupted" as const
}
const completed = consume()
try {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
})
if (control.cancelled) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (!started) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID)
}
}
const terminal = await completed
const assistant = assistantMessageID
? await input.client.session
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
.catch(() => undefined)
: undefined
return response(
assistant?.type === "assistant" ? assistant : undefined,
executionError,
terminal,
control.cancelled,
finish,
input.userMessageID,
)
} catch (error) {
streamController.abort()
await completed.catch(() => {})
throw error
} finally {
streamController.abort()
await stream.return?.(undefined).catch(() => {})
}
}
export async function replayMessages(
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
cwd: string,
messages: readonly SessionMessageInfo[],
) {
for (const message of messages) await replayMessage(connection, sessionID, cwd, message).catch(() => {})
}
async function replayMessage(
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
cwd: string,
message: SessionMessageInfo,
) {
if (message.type === "user") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
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)) {
await connection.sessionUpdate({
sessionId: sessionID,
update: { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk },
})
}
return
}
if (message.type !== "assistant") return
for (const part of message.content) {
if (part.type === "text") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "agent_message_chunk",
messageId: message.id,
content: { type: "text", text: part.text },
},
})
continue
}
if (part.type === "reasoning") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: message.id,
content: { type: "text", text: part.text },
},
})
continue
}
await connection.sessionUpdate({
sessionId: sessionID,
update: {
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":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
content: part.state.content,
result: part.state.result,
}),
},
})
break
case "running":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.input },
content: part.state.content,
cwd,
}),
},
})
break
case "error":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
content: part.state.content,
error: part.state.error.message,
cwd,
}),
},
})
break
case "streaming":
break
}
}
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
if (start.type === "input") return event.type === "session.input.promoted" && event.data.inputID === start.id
if (start.type === "compaction")
return event.type === "session.compaction.admitted" && event.data.inputID === start.id
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
}
function response(
assistant: SessionMessageAssistant | undefined,
executionError: { readonly type: string; readonly message: string } | undefined,
terminal: "succeeded" | "failed" | "interrupted",
cancelled: boolean,
finish: SessionMessageAssistant["finish"],
messageID: string | null | undefined,
): PromptResponse {
const error = assistant?.error ?? executionError
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
throw new ACPError.ServiceFailureError({
safeMessage: error.message || "OpenCode prompt failed",
service: "session",
errorName: error.type,
})
}
const tokens = assistant?.tokens
const usage = tokens
? {
inputTokens: tokens.input,
outputTokens: tokens.output,
totalTokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write,
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
}
: undefined
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} }
}
function resolveStopReason(input: {
readonly terminal: "succeeded" | "failed" | "interrupted"
readonly cancelled: boolean
readonly finish: SessionMessageAssistant["finish"]
readonly error?: string
}): PromptResponse["stopReason"] {
if (input.cancelled || input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
if (input.finish === "length") return "max_tokens"
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
return "end_turn"
}
export * as ACPEvent from "./event"
+179
View File
@@ -0,0 +1,179 @@
import type { AgentSideConnection, PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { Patch } from "@opencode-ai/util/patch"
import { Result } from "effect"
import { isAbsolute, resolve } from "node:path"
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.v2.asked" }>
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
type Tool = { readonly name: string; readonly input: ToolInput }
const options: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
export async function replyPermission(input: {
readonly client: OpenCodeClient
readonly connection: Connection
readonly event: PermissionEvent
readonly sessionID: string
readonly cwd: string
readonly tool?: Tool
}) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const result = await input.connection
.requestPermission({
sessionId: input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.event.data.source?.callID ?? input.event.data.id,
toolName,
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}),
},
options,
})
.catch(() => undefined)
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
const reply = selected === "once" || selected === "always" ? selected : "reject"
await input.client.permission.reply({
sessionID: input.sessionID,
requestID: input.event.data.id,
reply,
})
}
export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly sessionID: string
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly structured: Readonly<Record<string, unknown>>
}) {
if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.structured.files)
? input.structured.files.flatMap((file): string[] => {
if (!file || typeof file !== "object") return []
const path = Reflect.get(file, "file")
return typeof path === "string" ? [path] : []
})
: []
const path = filePath(input.toolInput)
const paths = [...new Set([...files, ...(path ? [path] : [])])]
await Promise.all(
paths.map(async (path) => {
const target = resolvePath(path, input.cwd)
const file = Bun.file(target)
if (!(await file.exists())) return
await input.connection.writeTextFile?.({ sessionId: input.sessionID, path: target, content: await file.text() })
}),
)
}
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const tool = toolName.toLocaleLowerCase()
if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
const path = filePath(input)
if (!path) return []
const oldText = await readText(path, cwd)
if (tool === "write") {
const content = stringValue(input.content)
return content === undefined ? [] : [{ type: "diff", path, oldText, newText: content }]
}
if (tool !== "edit") return []
const oldString = stringValue(input.oldString)
const newString = stringValue(input.newString)
if (oldString === undefined || newString === undefined) return []
const newText =
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
return [{ type: "diff", path, oldText, newText }]
}
async function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const patchText = stringValue(input.patchText)
if (!patchText) return []
try {
const parsed = Patch.parse(patchText)
if (Result.isFailure(parsed)) return []
return await Promise.all(
parsed.success.map(async (hunk): Promise<ToolCallContent> => {
const oldText = hunk.type === "add" ? "" : await readText(hunk.path, cwd)
if (hunk.type === "add") {
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
return { type: "diff", path: hunk.path, oldText, newText }
}
if (hunk.type === "delete") return { type: "diff", path: hunk.path, oldText, newText: "" }
return {
type: "diff",
path: hunk.movePath ?? hunk.path,
oldText,
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
}
}),
)
} catch {
return []
}
}
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
if (previews.length > 1) return `${previews.length} files`
switch (toolName.toLocaleLowerCase()) {
case "external_directory":
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
case "webfetch":
return stringValue(input.url)
case "websearch":
return stringValue(input.query)
case "grep":
case "glob":
return stringValue(input.pattern)
case "read":
case "edit":
case "write":
case "patch":
case "apply_patch":
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
default:
return undefined
}
}
function permissionLocations(
toolName: string,
input: ToolInput,
resources: ReadonlyArray<string>,
cwd: string,
previews: ReadonlyArray<ToolCallContent>,
): ToolCallLocation[] {
const paths = previews.flatMap((preview) => (preview.type === "diff" ? [preview.path] : []))
if (paths.length > 0) return [...new Set(paths)].map((path) => ({ path }))
const locations = toLocations(toolName, input, cwd)
if (locations.length > 0) return locations
return resources.filter((resource) => resource !== "*").map((path) => ({ path }))
}
function readText(path: string, cwd: string) {
return Bun.file(resolvePath(path, cwd))
.text()
.catch(() => "")
}
function filePath(input: ToolInput) {
return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath)
}
function resolvePath(path: string, cwd: string) {
return isAbsolute(path) ? path : resolve(cwd, path)
}
export * as ACPPermission from "./permission"
+531
View File
@@ -0,0 +1,531 @@
import {
isSessionNotFoundError,
type CommandInfo,
type ModelInfo,
type ModelRef,
type OpenCodeClient,
type SessionInfo,
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import type {
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
AuthMethod,
CancelNotification,
CloseSessionRequest,
CloseSessionResponse,
ForkSessionRequest,
ForkSessionResponse,
InitializeRequest,
InitializeResponse,
ListSessionsRequest,
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
McpServer,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
ResumeSessionRequest,
ResumeSessionResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content"
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
readonly defaultModel: ModelRef
readonly modes: Array<{ id: string; name: string; description?: string }>
readonly defaultModeID: string
readonly commands: CommandInfo[]
readonly skills: SkillInfo[]
}
type Attached = {
readonly id: string
readonly cwd: string
catalog: Catalog
model: ModelRef
modeID: string
}
type PreparedPrompt = {
readonly start: TurnStart
readonly text: string
readonly files: Array<{ readonly uri: string; readonly name?: string }>
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: CommandInfo
readonly skill?: SkillInfo
}
export interface Interface {
initialize(input: InitializeRequest): Promise<InitializeResponse>
authenticate(input: AuthenticateRequest): Promise<AuthenticateResponse>
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
setSessionModel(input: SetSessionModelRequest): Promise<SetSessionModelResponse>
prompt(input: PromptRequest): Promise<PromptResponse>
cancel(input: CancelNotification): Promise<void>
}
export function make(input: { readonly client: OpenCodeClient; readonly connection: Connection }): Interface {
const sessions = new Map<string, Attached>()
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
const catalog = (cwd: string) => {
const cached = catalogs.get(cwd)
if (cached) return cached
const loaded = loadCatalog(input.client, cwd).catch((error) => {
catalogs.delete(cwd)
throw error
})
catalogs.set(cwd, loaded)
return loaded
}
const requireSession = async (sessionID: string) => {
const current = sessions.get(sessionID)
if (current) return current
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd)
const state: Attached = {
id: session.id,
cwd,
catalog: currentCatalog,
model: session.model ?? currentCatalog.defaultModel,
modeID: session.agent ?? currentCatalog.defaultModeID,
}
sessions.set(session.id, state)
await registerMcpServers(input.client, registeredMcp, state, mcpServers)
await input.connection.sessionUpdate({
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
...state.catalog.commands,
...state.catalog.skills.filter(
(skill) => !state.catalog.commands.some((command) => command.name === skill.name),
),
].map((command) => ({ name: command.name, description: command.description ?? "" })),
},
})
return state
}
const replay = async (state: Attached) => {
await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
}
const configOptions = (state: Attached) =>
buildConfigOptions({
providers: state.catalog.providers,
currentModel: { providerID: state.model.providerID, modelID: state.model.id },
currentVariant: state.model.variant,
modes: state.catalog.modes,
currentModeId: state.modeID,
})
return {
initialize: async (params) => {
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
id: AuthMethodID,
}
if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
authMethod._meta = {
"terminal-auth": { command: "opencode", args: ["auth", "login"], label: "OpenCode Login" },
}
}
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
},
authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
}
},
authenticate: async (params) => {
if (params.methodId !== AuthMethodID) throw new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
return {}
},
newSession: async (params) => {
const currentCatalog = await catalog(params.cwd)
const created = await input.client.session.create({
location: { directory: params.cwd },
agent: currentCatalog.defaultModeID,
model: currentCatalog.defaultModel,
})
const state = await attach(created, params.cwd, params.mcpServers)
return { sessionId: state.id, configOptions: configOptions(state) }
},
loadSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers)
await replay(state)
return { configOptions: configOptions(state) }
},
listSessions: async (params) => {
const page = await input.client.session.list({
...(params.cwd ? { directory: params.cwd } : {}),
order: "desc",
limit: 100,
...(params.cursor ? { cursor: params.cursor } : {}),
})
return {
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: session.title,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
sessions.delete(params.sessionId)
registeredMcp.delete(params.sessionId)
const turn = active.get(params.sessionId)
if (turn) {
turn.cancelled = true
turn.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
return {}
},
forkSession: async (params) => {
const forked = await input.client.session.fork({ sessionID: params.sessionId })
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state)
return { sessionId: state.id, configOptions: configOptions(state) }
},
setSessionConfigOption: async (params) => {
const state = await requireSession(params.sessionId)
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
}
case "effort": {
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (!model?.variants.some((variant) => variant.id === params.value))
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
break
}
case "mode":
await selectMode(input.client, state, params.value)
break
default:
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
return { configOptions: configOptions(state) }
},
setSessionMode: async (params) => {
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
return {}
},
setSessionModel: async (params) => {
const state = await requireSession(params.sessionId)
const selected = requireModel(state.catalog, params.modelId)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
return {}
},
prompt: async (params) => {
const state = await requireSession(params.sessionId)
if (active.has(state.id)) {
throw new ACPError.ServiceFailureError({
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
service: "session",
})
}
const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() }
active.set(state.id, control)
const response = await streamTurn({
client: input.client,
connection: input.connection,
sessionID: state.id,
cwd: state.cwd,
start: prepared.start,
userMessageID: params.messageId,
control,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
}).finally(() => {
if (active.get(state.id) === control) active.delete(state.id)
})
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
return response
},
cancel: async (params) => {
const current = active.get(params.sessionId)
if (current) {
current.cancelled = true
current.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
},
}
}
function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
const parts = promptContentToParts(prompt)
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
const skill = slash ? catalog.skills.find((item) => item.name === slash.name) : undefined
const start = turnStart(messageID, slash, skill)
return { start, text, files, synthetic, slash, command, skill }
}
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
if (prompt.synthetic.length > 0) {
await client.session.synthetic({
sessionID: session.id,
text: prompt.synthetic.join("\n\n"),
description: "ACP embedded context",
delivery: "steer",
resume: false,
})
}
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
if (prompt.skill) return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id })
if (prompt.command) {
return client.session.command(
{
sessionID: session.id,
id: prompt.start.id,
command: prompt.command.name,
arguments: prompt.slash?.args,
files: prompt.files,
delivery: "steer",
},
{ signal },
)
}
return client.session.prompt(
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
{ signal },
)
}
function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: SkillInfo | undefined): TurnStart {
if (slash?.name === "compact") return { type: "compaction", id: messageID }
if (skill) return { type: "skill", id: messageID }
return { type: "input", id: messageID }
}
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const location = { directory: cwd }
// Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
const [modelResult, defaultResult, agentResult, commandResult, skillResult] = await Promise.all([
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
client.skill.list({ location }),
])
const models = modelResult.data.filter((model) => model.enabled)
const defaultModel = defaultResult.data ?? models[0]
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
if (defaultModel && defaultAgent) {
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant:
defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
skills: skillResult.data.filter((skill) => skill.slash !== false),
}
}
missing = defaultModel ? "No primary agents are available" : "No models are available"
await Bun.sleep(25)
}
throw new Error(missing)
}
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
return Array.from(new Set(models.map((model) => model.providerID)))
.toSorted()
.map((providerID) => ({
id: providerID,
name: providerID,
models: models
.filter((model) => model.providerID === providerID)
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
}))
}
function requireModel(catalog: Catalog, modelID: string): 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) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
return { providerID: model.providerID, id: model.id, variant: selected.variant }
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
state.modeID = modeID
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
}
async function getSession(client: OpenCodeClient, sessionID: string) {
return client.session.get({ sessionID }).catch((error) => {
if (isSessionNotFoundError(error)) throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
throw error
})
}
async function messages(client: OpenCodeClient, sessionID: string) {
const result: SessionMessageInfo[] = []
let cursor: string | undefined
do {
const page = cursor
? await client.message.list({ sessionID, limit: 200, cursor })
: await client.message.list({ sessionID, limit: 200, order: "asc" })
result.push(...page.data)
cursor = page.cursor.next ?? undefined
} while (cursor)
return result
}
async function registerMcpServers(
client: OpenCodeClient,
registered: Map<string, Set<string>>,
session: Attached,
servers: readonly McpServer[],
) {
const current = registered.get(session.id) ?? new Set<string>()
registered.set(session.id, current)
await Promise.all(
servers.flatMap((server) => {
const config = mcpConfig(server)
const key = `${server.name}:${stableStringify(config)}`
if (current.has(key)) return []
current.add(key)
return [
client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => {
current.delete(key)
throw error
}),
]
}),
)
}
function mcpConfig(server: McpServer) {
if ("type" in server) {
return {
type: "remote" as const,
url: server.url,
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
oauth: false as const,
}
}
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(",")}}`
}
async function sendUsageUpdate(client: OpenCodeClient, connection: Connection, session: Attached, used?: number) {
if (!used) return
const model = session.catalog.models.find(
(item) => item.providerID === session.model.providerID && item.id === session.model.id,
)
if (!model?.limit.context) return
const info = await client.session.get({ sessionID: session.id })
await connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "usage_update",
used,
size: model.limit.context,
cost: { amount: info.cost, currency: "USD" },
},
})
}
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+/)
if (!name) return undefined
return { name, args: rest.join(" ").trim() }
}
export * as ACPService from "./service"
+222
View File
@@ -0,0 +1,222 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
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 }
>
export function toToolKind(toolName: string): ToolKind {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "apply_patch":
case "patch":
case "write":
return "edit"
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
case "read":
return "read"
case "task":
case "subagent":
return "think"
default:
return "other"
}
}
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell": {
const workdir = shellWorkdir(input, cwd)
return workdir ? [{ path: workdir }] : []
}
case "read":
case "edit":
case "write":
case "patch":
case "apply_patch":
return locationFrom(input.filePath ?? input.filepath)
case "external_directory":
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return locationFrom(input.path)
default:
return []
}
}
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly cwd?: string
}): ToolCall {
return {
toolCallId: input.toolCallId,
title: toolTitle(input.toolName, input.state.input, input.state.title),
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
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, 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) } : {}),
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly result?: unknown
}): ToolCallUpdate {
const normalized = toolContent(input.content)
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
const primary =
read === undefined
? normalized.filter((part) => !images.includes(part))
: [{ type: "content" as const, content: { type: "text" as const, text: read } }]
const oldText = stringValue(input.input.oldString)
const newText = stringValue(input.input.newString)
const diff: ToolCallContent[] =
oldText === undefined || newText === undefined
? []
: [
{
type: "diff",
path: stringValue(input.input.path) ?? stringValue(input.input.filePath) ?? "",
oldText,
newText,
},
]
return {
toolCallId: input.toolCallId,
status: "completed",
content: [...primary, ...diff, ...images],
rawOutput: {
structured: input.structured,
...(input.result === undefined ? {} : { result: input.result }),
},
}
}
export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
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 } }],
rawOutput: { structured: input.structured, error: input.error },
}
}
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)
if (!match?.[1]?.startsWith("image/") || match[2] === undefined) return []
return [{ type: "content", content: { type: "image", mimeType: match[1], data: match[2] } }]
})
}
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
if (typeof structured.content === "string") {
if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
}
if (!Array.isArray(structured.entries)) return undefined
return structured.entries
.flatMap((entry): string[] => {
if (typeof entry === "string") return [entry]
if (!entry || typeof entry !== "object") return []
const path = Reflect.get(entry, "path")
return typeof path === "string" ? [path] : []
})
.join("\n")
}
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
return fallback || toolName
}
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
if (!isShell(toolName) || input.cwd || input.workdir) return input
const workdir = shellWorkdir(input, cwd)
return workdir ? { ...input, cwd: workdir } : input
}
function shellWorkdir(input: ToolInput, cwd?: string) {
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
if (!explicit) return cwd
return isAbsolute(explicit) ? explicit : resolve(cwd ?? process.cwd(), explicit)
}
function isShell(toolName: string) {
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}
function locationFrom(...values: unknown[]): ToolCallLocation[] {
return Array.from(
new Set(
values.flatMap((value): string[] => {
if (Array.isArray(value))
return value.filter((item): item is string => typeof item === "string" && item.length > 0)
const path = stringValue(value)
return path ? [path] : []
}),
),
(path) => ({ path }),
)
}
export function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPTool from "./tool"
+1
View File
@@ -34,6 +34,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
),
},
commands: [
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
params: {
+36
View File
@@ -0,0 +1,36 @@
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect } from "effect"
import { ACP } from "../../acp/agent"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Standalone } from "../../services/standalone"
export default Runtime.handler(
Commands.commands.acp,
Effect.fn("cli.acp")(function* () {
process.env.OPENCODE_CLIENT = "acp"
const endpoint = yield* Standalone.start()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const input = new WritableStream<Uint8Array>({
write: (chunk) =>
new Promise<void>((resolve, reject) => {
process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()))
}),
})
const output = new ReadableStream<Uint8Array>({
start(controller) {
process.stdin.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)))
process.stdin.on("end", () => controller.close())
process.stdin.on("error", (error) => controller.error(error))
},
})
const stream = ndJsonStream(input, output)
const connection = new AgentSideConnection((connection) => ACP.create(client, connection), stream)
process.stdin.resume()
yield* Effect.promise(() => connection.closed)
// EOF owns this stdio process; exiting also closes the private server's lease pipe.
yield* Effect.sync(() => process.exit(0))
}),
)
@@ -1,7 +1,7 @@
import { Cause, Effect, Exit, Option } from "effect"
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { AppProcess } from "@opencode-ai/core/process"
import { AppProcess } from "@opencode-ai/util/process"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@@ -1,5 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -8,7 +8,8 @@ import { Context, Effect, FileSystem, Option } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -44,6 +45,7 @@ export default Runtime.handler(Commands, (input) =>
const runPromise = Effect.runPromiseWith(context)
const service = server.service
yield* run({
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
server: {
endpoint: server.endpoint,
service: service
@@ -3,7 +3,7 @@ import path from "node:path"
import { readFile, stat, writeFile } from "node:fs/promises"
import { Effect, Option } from "effect"
import { applyEdits, modify } from "jsonc-parser"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
@@ -32,6 +32,9 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
tuiConfig: resolved,
config: {
update: (update) => runServicePromise(config.update(update)),
},
}),
)
}),
+1 -1
View File
@@ -1,6 +1,6 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
+2 -2
View File
@@ -1,10 +1,10 @@
import { Effect, FileSystem, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
+13 -10
View File
@@ -4,17 +4,18 @@ import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/core/observability"
import { Observability } from "@opencode-ai/util/observability"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
connect: () => import("./commands/handlers/auth/connect"),
@@ -51,12 +52,12 @@ const Handlers = Runtime.handlers(Commands, {
})
Effect.logInfo("cli starting", {
version: InstallationVersion,
channel: InstallationChannel,
local: InstallationLocal,
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
local: OPENCODE_LOCAL,
args: process.argv.slice(2),
}).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })),
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
@@ -73,6 +74,8 @@ Effect.logInfo("cli starting", {
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
client: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
}),
),
Effect.provide(NodeServices.layer),
+1 -1
View File
@@ -1,6 +1,6 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
+2
View File
@@ -22,6 +22,7 @@ export type MiniCommandInput = {
replayLimit?: number
demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"]
config?: MiniFrontendInput["config"]
}
type Model = MiniFrontendInput["model"]
@@ -119,6 +120,7 @@ export async function runMini(input: MiniCommandInput) {
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
config: input.config,
})
})
if (result.exitCode !== 0) process.exit(result.exitCode)
+184 -19
View File
@@ -5,6 +5,7 @@ import type {
LocationRef,
OpenCodeClient,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
@@ -75,6 +76,9 @@ export async function runNonInteractivePrompt(input: Input) {
const messageID = SessionMessage.ID.create()
const starts = new Map<string, StartedPart>()
const tools = new Map<string, ToolState>()
const renderedText = new Map<string, string>()
const renderedReasoning = new Map<string, string>()
const renderedTools = new Set<string>()
let submitted = false
let promoted = false
let emittedError = false
@@ -82,6 +86,8 @@ export async function runNonInteractivePrompt(input: Input) {
let formCancelled = false
let interrupted = false
let v1InvalidOutput = false
let prePromotionError: { message: string; [key: string]: unknown } | undefined
let finalizing = false
let admission: AbortController | undefined
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
@@ -104,6 +110,17 @@ export async function runNonInteractivePrompt(input: Input) {
UI.empty()
}
const writeReasoning = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
if (emit("reasoning", timestamp, { part })) return
const text = part.text.trim()
if (!text) return
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) return void process.stdout.write(line + EOL)
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
}
const flushStep = () => {
if (!pendingStep) return
const value = pendingStep
@@ -181,6 +198,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.input.promoted") {
if (event.data.inputID === messageID) {
promoted = true
prePromotionError = undefined
continue
}
}
@@ -191,7 +209,12 @@ export async function runNonInteractivePrompt(input: Input) {
) {
return
}
if (!promoted && event.type === "session.execution.failed") {
prePromotionError = event.data.error
continue
}
if (!promoted) continue
if (finalizing) continue
if (event.type === "session.step.started") {
const part = {
@@ -219,12 +242,16 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.text.started") {
flushStep()
starts.set("text", { id: partID(event.id), timestamp: time })
starts.set(`text\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
id: partID(event.id),
timestamp: time,
})
continue
}
if (event.type === "session.text.ended") {
const started = starts.get("text")
starts.delete("text")
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
const started = starts.get(`text\u0000${key}`)
starts.delete(`text\u0000${key}`)
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@@ -233,18 +260,23 @@ export async function runNonInteractivePrompt(input: Input) {
text: event.data.text,
time: { start: started?.timestamp ?? time, end: time },
}
renderedText.set(key, event.data.text)
writeText(part, time)
continue
}
if (event.type === "session.reasoning.started") {
flushStep()
starts.set("reasoning", { id: partID(event.id), timestamp: time })
starts.set(`reasoning\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
id: partID(event.id),
timestamp: time,
})
continue
}
if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get("reasoning")
starts.delete("reasoning")
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
const started = starts.get(`reasoning\u0000${key}`)
starts.delete(`reasoning\u0000${key}`)
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@@ -254,17 +286,8 @@ export async function runNonInteractivePrompt(input: Input) {
metadata: event.data.state,
time: { start: started?.timestamp ?? time, end: time },
}
if (emit("reasoning", time, { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) {
process.stdout.write(line + EOL)
continue
}
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
renderedReasoning.set(key, event.data.text)
writeReasoning(part, time)
continue
}
@@ -360,6 +383,7 @@ export async function runNonInteractivePrompt(input: Input) {
},
}
tools.delete(key)
renderedTools.add(key)
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
continue
}
@@ -405,6 +429,7 @@ export async function runNonInteractivePrompt(input: Input) {
},
}
tools.delete(key)
renderedTools.add(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, current.content).trim())
@@ -480,6 +505,122 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const projectedMessages = async () => {
const messages: SessionMessageInfo[] = []
let cursor: string | undefined
while (true) {
const page = await input.client.message.list(
cursor
? { sessionID: input.sessionID, limit: 200, cursor }
: { sessionID: input.sessionID, limit: 200, order: "desc" },
)
for (const message of page.data) {
if (message.id === messageID) return { found: true, messages: messages.toReversed() }
messages.push(message)
}
cursor = page.cursor.next ?? undefined
if (!cursor) return { found: false, messages: [] }
}
}
const reconcile = async () => {
const projected = await projectedMessages()
for (const message of projected.messages) {
if (message.type !== "assistant") continue
const timestamp = message.time.completed ?? message.time.created
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
if (item.type === "text") {
const ordinal = textOrdinal++
const key = contentKey(message.id, ordinal)
const rendered = renderedText.get(key) ?? ""
if (rendered === item.text || !item.text.startsWith(rendered)) continue
const text = item.text.slice(rendered.length)
writeText(
{
id: projectedPartID(message.id, `text-${ordinal}`),
sessionID: input.sessionID,
messageID: message.id,
type: "text",
text,
time: { start: message.time.created, end: timestamp },
},
timestamp,
)
renderedText.set(key, item.text)
continue
}
if (item.type === "reasoning") {
const ordinal = reasoningOrdinal++
if (!input.thinking) continue
const key = contentKey(message.id, ordinal)
const rendered = renderedReasoning.get(key) ?? ""
if (rendered === item.text || !item.text.startsWith(rendered)) continue
const text = item.text.slice(rendered.length)
const part = {
id: projectedPartID(message.id, `reasoning-${ordinal}`),
sessionID: input.sessionID,
messageID: message.id,
type: "reasoning",
text,
metadata: item.state,
time: { start: message.time.created, end: timestamp },
}
renderedReasoning.set(key, item.text)
writeReasoning(part, timestamp)
continue
}
const key = toolKey(message.id, item.id)
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
const part: MiniToolPart = {
id: projectedPartID(message.id, `tool-${item.id}`),
sessionID: input.sessionID,
messageID: message.id,
type: "tool",
callID: item.id,
tool: item.name,
state:
item.state.status === "completed"
? {
status: "completed",
input: item.state.input,
output: toolOutputText(item.name, item.state.content),
title: item.name,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
}
: {
status: "error",
input: item.state.input,
error: item.state.error.message,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
},
}
renderedTools.add(key)
if (emit("tool_use", timestamp, { part })) continue
if (item.state.status === "completed") {
await input.renderTool(item)
continue
}
if (toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
}
await input.renderToolError(item)
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
}
}
return projected.found
}
const interrupt = () => {
if (interrupted) process.exit(130)
interrupted = true
@@ -559,11 +700,27 @@ export async function runNonInteractivePrompt(input: Input) {
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
: []),
])
await completed
if (input.compatibility === "v1") {
await completed
return
}
const waiting = input.client.session.wait({ sessionID: input.sessionID })
await Promise.race([waiting, completed.then(() => waiting)])
finalizing = true
controller.abort()
const found = await reconcile()
if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" }
emittedError = true
process.exitCode = 1
if (!emit("error", Date.now(), { error })) UI.error(error.message)
}
} finally {
process.off("SIGINT", interrupt)
controller.abort()
await stream.return?.(undefined).catch(() => {})
if (input.compatibility === "v1") await stream.return?.(undefined).catch(() => {})
else void stream.return?.(undefined).catch(() => {})
}
}
@@ -595,6 +752,14 @@ function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function contentKey(messageID: string, ordinal: number) {
return `${messageID}\u0000${ordinal}`
}
function projectedPartID(messageID: string, part: string) {
return `prt_${messageID.replace(/^msg_/, "")}_${part}`
}
function fallbackTool(event: {
id: string
created: number
+1 -1
View File
@@ -1,6 +1,6 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
+17 -7
View File
@@ -2,10 +2,10 @@ export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
@@ -69,13 +69,23 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const instanceID = randomUUID()
const server = yield* start(
{
client: process.env.OPENCODE_CLIENT ?? "cli",
app: {
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
hostname,
port,
password,
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path: process.env.OPENCODE_DB,
path:
process.env.OPENCODE_DB ??
(["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
@@ -167,7 +177,7 @@ const register = Effect.fnUntraced(function* (
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const info = {
id,
version: InstallationVersion,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
@@ -1,6 +1,6 @@
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OPENCODE_VERSION } from "../version"
import { Effect, Redacted } from "effect"
import { Env } from "../env"
import { ServiceConfig } from "./service-config"
@@ -32,9 +32,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== InstallationVersion)
if (health.version !== OPENCODE_VERSION)
process.stderr.write(
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`,
)
return { endpoint } satisfies Resolved
}
+36 -19
View File
@@ -1,6 +1,6 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Hash } from "@opencode-ai/core/util/hash"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../version"
import { Hash } from "@opencode-ai/util/hash"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
@@ -24,22 +24,26 @@ type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = InstallationChannel) {
if (channel === "latest") return "service.json"
if (channel === "local") return "service-local.json"
return `service-${Hash.fast(channel)}.json`
export function filename(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return "service.json"
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
export function defaultPort(channel = InstallationChannel) {
if (channel === "latest") return 0xc0de
export function defaultPort(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
}
export function legacyFilename(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "local") return
return `service-${Hash.fast(channel)}.json`
}
export function versionBelongsToChannel(
version: string | undefined,
channel = InstallationChannel,
installedVersion = InstallationVersion,
channel = OPENCODE_CHANNEL,
installedVersion = OPENCODE_VERSION,
) {
if (version === undefined) return false
if (version === installedVersion) return true
@@ -51,10 +55,9 @@ export function versionBelongsToChannel(
export const migrateRegistration = Effect.fnUntraced(function* (
legacy: string,
file: string,
channel = InstallationChannel,
installedVersion = InstallationVersion,
channel = OPENCODE_CHANNEL,
installedVersion = OPENCODE_VERSION,
) {
if (channel === "latest" || channel === "local") return
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
@@ -64,6 +67,14 @@ export const migrateRegistration = Effect.fnUntraced(function* (
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file: string) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
if (Option.isNone(yield* decodeInfo(text.value).pipe(Effect.option))) return
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password") return key
throw new Error(`Unknown service config key: ${key}`)
@@ -73,27 +84,33 @@ const paths = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const name = filename()
const legacy = legacyFilename()
const file = path.join(global.state, name)
return {
fs,
file,
legacyFile: path.join(global.state, "service.json"),
legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
legacyRegistrationFiles: [
...(legacy ? [path.join(global.state, legacy)] : []),
...(name !== "service.json" && OPENCODE_CHANNEL !== "local" ? [path.join(global.state, "service.json")] : []),
],
configFile: path.join(global.config, name),
}
})
export const options = Effect.fnUntraced(function* () {
const { file, legacyFile } = yield* paths
yield* migrateRegistration(legacyFile, file)
const { file, legacyRegistrationFiles } = yield* paths
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return {
file,
version: InstallationVersion,
version: OPENCODE_VERSION,
command: [...selfCommand(), "serve", "--service"],
}
})
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* paths
const { fs, configFile, legacyConfigFile } = yield* paths
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.catch(() => Effect.succeed({} as Info)),
+2 -2
View File
@@ -1,6 +1,6 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
@@ -3,7 +3,7 @@
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OPENCODE_VERSION } from "../version"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { go } from "@opencode-ai/tui/logo"
@@ -356,12 +356,12 @@ function UpdateFooter(props: {
] as const)
: []),
["to", colors.muted],
[InstallationVersion, colors.accent],
[OPENCODE_VERSION, colors.accent],
)
const completedHeader = phrase(
["OpenCode", colors.muted, true],
["updated to", colors.muted],
[InstallationVersion, colors.accent],
[OPENCODE_VERSION, colors.accent],
)
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
const outcomeStatus = () =>
+13 -17
View File
@@ -1,10 +1,6 @@
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
import {
InstallationChannel,
InstallationLocal,
InstallationVersion,
} from "@opencode-ai/core/installation/version"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
@@ -52,7 +48,7 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -99,8 +95,8 @@ export const layer = Layer.effect(
const response = yield* Effect.tryPromise({
try: () =>
fetch(
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`,
{ headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) },
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
@@ -138,13 +134,13 @@ export const layer = Layer.effect(
const check = Effect.fn("cli.updater.check")(function* () {
if (
InstallationLocal ||
OPENCODE_LOCAL ||
["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
)
return yield* Effect.logInfo("update check skipped", {
reason: InstallationLocal ? "local-install" : "disabled",
version: InstallationVersion,
channel: InstallationChannel,
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
})
const policy = yield* readPolicy()
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
@@ -152,15 +148,15 @@ export const layer = Layer.effect(
return yield* Effect.gen(function* () {
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: InstallationVersion,
current: OPENCODE_VERSION,
latest: version,
})
const next = action(InstallationVersion, version, policy)
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
})
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
+8
View File
@@ -0,0 +1,8 @@
declare const OPENCODE_VERSION: string
declare const OPENCODE_CHANNEL: string
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
export const OPENCODE_LOCAL = channel === "local"
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "node:path"
type Message = { readonly id?: number; readonly result?: unknown; readonly error?: unknown }
const children: Bun.Subprocess[] = []
afterEach(async () => {
await Promise.all(
children.splice(0).map(async (child) => {
child.kill("SIGKILL")
await child.exited
}),
)
})
describe("acp command", () => {
test("is registered", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
})
test("initializes over ndjson and exits on stdin eof", async () => {
const child = spawn()
const stderr = new Response(child.stderr).text()
await child.stdin.write(
new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: 1,
clientCapabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}) + "\n",
),
)
await child.stdin.flush()
const response = await readMessage(child.stdout)
expect(response.id).toBe(1)
expect(response.error).toBeUndefined()
expect(response.result).toMatchObject({
protocolVersion: 1,
agentCapabilities: { loadSession: true },
agentInfo: { name: "OpenCode" },
})
await child.stdin.end()
const exitCode = await child.exited
const errorOutput = await stderr
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`)
children.splice(children.indexOf(child), 1)
}, 30_000)
})
function spawn() {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
cwd: path.join(import.meta.dir, "../.."),
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
children.push(child)
return child
}
async function readMessage(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let output = ""
while (true) {
const result = await Promise.race([
reader.read(),
Bun.sleep(20_000).then(() => {
throw new Error("timed out waiting for ACP response")
}),
])
if (result.done) throw new Error(`ACP exited before responding: ${output}`)
output += decoder.decode(result.value, { stream: true })
const newline = output.indexOf("\n")
if (newline === -1) continue
reader.releaseLock()
const message: unknown = JSON.parse(output.slice(0, newline))
if (!isMessage(message)) throw new Error(`invalid ACP response: ${output.slice(0, newline)}`)
return message
}
}
function isMessage(value: unknown): value is Message {
return typeof value === "object" && value !== null
}
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 }
}
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, test } from "bun:test"
import {
buildConfigOptions,
buildEffortSelectOption,
buildModeSelectOption,
buildModelSelectOption,
formatVariantName,
parseModelSelection,
type ConfigOptionProvider,
} from "../../src/acp/config-option"
const providers: ConfigOptionProvider[] = [
{
id: "anthropic",
name: "Anthropic",
models: [
{ id: "claude/sonnet-4", name: "Claude Sonnet 4", variants: ["default", "high", "very-high"] },
{ id: "claude-haiku", name: "Claude Haiku" },
],
},
{ id: "openai", name: "OpenAI", models: [{ id: "gpt-5", name: "GPT-5", variants: ["minimal", "low"] }] },
]
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",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: "default",
options: [
{ value: "low", name: "Low" },
{ value: "default", name: "Default" },
{ 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("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,74 @@
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import {
alternateValue,
createAcpFixture,
expectOk,
flattenSelectOptions,
initialize,
newSession,
requireSelectOption,
selectConfigOption,
} 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("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}, 60_000)
test("effort switch updates currentValue", 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)
}, 60_000)
})
+239
View File
@@ -0,0 +1,239 @@
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 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",
},
},
},
])
})
})
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
import { ACPError } from "../../src/acp/error"
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" }),
]
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([-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")),
)
const serialized = JSON.stringify(requestError.toErrorResponse())
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Internal service failure")
expect(serialized).not.toContain("sk-ant-secret")
expect(serialized).not.toContain("oauth refresh token")
expect(serialized).not.toContain("stack")
})
})
@@ -0,0 +1,684 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { resolve } from "node:path"
import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Fixture = ReturnType<typeof createSseFixture>
describe("acp event behavior", () => {
test("subscribes before admission and isolates sessions and input IDs", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_before",
ordinal: 0,
delta: "before admission",
}),
)
send(durableEvent("session.input.promoted", { sessionID: "ses_b", inputID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_wrong_input",
ordinal: 0,
delta: "wrong input",
}),
)
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: id }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_b",
assistantMessageID: "msg_b",
ordinal: 0,
delta: "other session",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_a",
ordinal: 0,
delta: "accepted",
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_a",
assistantMessageID: "msg_a",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_b" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_a" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_a",
inputID: "input_a",
})
expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
"/api/event",
"/api/session/ses_a/prompt",
])
expect(updates).toEqual([
{
sessionId: "ses_a",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_a",
content: { type: "text", text: "accepted" },
},
},
])
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("preserves text and reasoning order before returning the terminal response", async () => {
const firstUpdate = Promise.withResolvers<void>()
const releaseUpdate = Promise.withResolvers<void>()
const allUpdates = Promise.withResolvers<void>()
const releaseSubmit = Promise.withResolvers<void>()
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
async onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_order", inputID: id }))
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: "think-1",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 1,
delta: "answer",
}),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 2,
delta: "think-2",
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_order" }))
await releaseSubmit.promise
},
})
const connection = {
sessionUpdate: async (update) => {
updates.push(update)
if (updates.length === 1) {
firstUpdate.resolve()
await releaseUpdate.promise
}
if (updates.length === 3) allUpdates.resolve()
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
const result = turn({ fixture, connection, sessionID: "ses_order", inputID: "input_order" })
try {
await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
expect(updates).toHaveLength(1)
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
releaseUpdate.resolve()
await withTimeout(allUpdates.promise, "ordered updates did not finish")
expect(await Promise.race([result.then(() => "resolved"), Promise.resolve("pending")])).toBe("pending")
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
releaseSubmit.resolve()
const response = await withTimeout(result, "turn did not resolve after admission returned")
expect(
updates.map((item) => {
if (
item.update.sessionUpdate === "agent_message_chunk" ||
item.update.sessionUpdate === "agent_thought_chunk"
) {
return [
item.update.sessionUpdate,
item.update.content.type === "text" ? item.update.content.text : undefined,
]
}
return [item.update.sessionUpdate, undefined]
}),
).toEqual([
["agent_thought_chunk", "think-1"],
["agent_message_chunk", "answer"],
["agent_thought_chunk", "think-2"],
])
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
} finally {
releaseUpdate.resolve()
releaseSubmit.resolve()
await result.catch(() => undefined)
await fixture.stop()
}
})
test("streams tool pending, progress, success, and failure updates", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_tools", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
name: "shell",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
input: { command: "printf done", workdir: "sub" },
executed: false,
}),
)
send(
durableEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { phase: 1 },
content: [{ type: "text", text: "working" }],
}),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { exit: 0 },
content: [{ type: "text", text: "done" }],
result: { code: 0 },
executed: true,
}),
)
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
name: "read",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
input: { filePath: "/workspace/missing.ts" },
executed: false,
}),
)
send(
durableEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
structured: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
}),
)
send(
durableEvent("session.tool.failed", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
error: { type: "tool.error", message: "not found" },
executed: true,
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_tools",
inputID: "input_tools",
})
expect(
updates.map((item) => [
item.update.sessionUpdate,
"status" in item.update ? item.update.status : undefined,
"toolCallId" in item.update ? item.update.toolCallId : undefined,
]),
).toEqual([
["tool_call", "pending", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "completed", "call_ok"],
["tool_call", "pending", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "failed", "call_fail"],
])
expect(updates[1]?.update).toMatchObject({
title: "printf done",
kind: "execute",
locations: [{ path: resolve("/workspace", "sub") }],
rawInput: { command: "printf done", workdir: "sub" },
})
expect(updates[2]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "working" } }],
})
expect(updates[3]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
})
expect(updates[7]?.update).toMatchObject({
kind: "read",
locations: [{ path: "/workspace/missing.ts" }],
content: [
{ type: "content", content: { type: "text", text: "opening" } },
{ type: "content", content: { type: "text", text: "not found" } },
],
rawOutput: { structured: { bytes: 0 }, error: "not found" },
})
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("replays user, text, reasoning, and tool messages in order", async () => {
const updates: SessionUpdateParams[] = []
const messages = replayFixtureMessages()
const connection = {
sessionUpdate: async (update) => {
updates.push(update)
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
await replayMessages(connection, "ses_replay", "/workspace", messages)
expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
"user_message_chunk",
"user_message_chunk",
"user_message_chunk",
"agent_message_chunk",
"agent_thought_chunk",
"tool_call",
"tool_call_update",
"tool_call",
"tool_call_update",
"tool_call",
"tool_call_update",
"tool_call",
])
expect(updates[1]?.update).toMatchObject({
content: {
type: "resource_link",
uri: "file:///workspace/note.md",
name: "note.md",
mimeType: "text/markdown",
},
})
expect(updates[2]?.update).toMatchObject({
content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
})
expect(updates[6]?.update).toMatchObject({
toolCallId: "call_done",
status: "completed",
content: [
{ type: "content", content: { type: "text", text: "done" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
})
expect(updates[8]?.update).toMatchObject({
toolCallId: "call_running",
status: "in_progress",
title: "pwd",
locations: [{ path: "/workspace" }],
})
expect(updates[10]?.update).toMatchObject({
toolCallId: "call_failed",
status: "failed",
content: [
{ type: "content", content: { type: "text", text: "partial" } },
{ type: "content", content: { type: "text", text: "failed hard" } },
],
})
})
test("continues replay after a session update callback rejects", async () => {
const attempts: Array<[string, string]> = []
const connection = {
sessionUpdate: async (params) => {
if (params.update.sessionUpdate !== "tool_call" && params.update.sessionUpdate !== "tool_call_update") return
attempts.push([params.update.toolCallId, params.update.sessionUpdate])
if (params.update.toolCallId === "call_first" && params.update.sessionUpdate === "tool_call_update") {
throw new Error("replay send failed")
}
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
await replayMessages(connection, "ses_replay_failure", "/workspace", [
replayToolMessage("call_first"),
replayToolMessage("call_after"),
])
expect(attempts).toEqual([
["call_first", "tool_call"],
["call_first", "tool_call_update"],
["call_after", "tool_call"],
["call_after", "tool_call_update"],
])
})
test("returns cancelled after an admitted turn is interrupted", async () => {
const submitted = Promise.withResolvers<void>()
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_cancel", inputID: id }))
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
},
})
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionID: "ses_cancel",
cwd: "/workspace",
start: { type: "input", id: "input_cancel" },
control,
submit: async (signal) => {
await fixture.client.session.prompt(
{ sessionID: "ses_cancel", id: "input_cancel", text: "cancel me" },
{ signal },
)
submitted.resolve()
},
})
try {
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
control.cancelled = true
control.admission.abort()
await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
const response = await withTimeout(result, "cancelled turn did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
} finally {
await fixture.stop()
}
})
test("returns cancelled when admission is aborted before promotion", async () => {
const submitted = Promise.withResolvers<void>()
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ signal }) {
submitted.resolve()
return new Promise<void>((resolve) => {
if (signal.aborted) return resolve()
signal.addEventListener("abort", () => resolve(), { once: true })
})
},
})
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionID: "ses_cancel_admission",
cwd: "/workspace",
start: { type: "input", id: "input_cancel_admission" },
control,
submit: (signal) =>
fixture.client.session.prompt(
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
{ signal },
),
})
try {
await withTimeout(submitted.promise, "cancel test prompt was not submitted")
control.cancelled = true
control.admission.abort()
const response = await withTimeout(result, "pre-admission cancellation did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
} finally {
control.cancelled = true
control.admission.abort()
await result.catch(() => undefined)
await fixture.stop()
}
})
test("cancels unsupported session forms so execution can continue", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
send(
ephemeralEvent("form.created", {
form: {
id: "frm_question",
sessionID: "ses_form",
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", title: "Choice", type: "string" }],
},
}),
)
},
onFormCancel({ sessionID, formID, send }) {
send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
send(durableEvent("session.execution.succeeded", { sessionID }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection([]),
sessionID: "ses_form",
inputID: "input_form",
})
expect(response.stopReason).toBe("end_turn")
expect(
fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
).toBe(true)
} finally {
await fixture.stop()
}
})
})
function recordingConnection(updates: SessionUpdateParams[]) {
return {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
}
function turn(input: {
readonly fixture: Fixture
readonly connection: Connection
readonly sessionID: string
readonly inputID: string
}) {
return streamTurn({
client: input.fixture.client,
connection: input.connection,
sessionID: input.sessionID,
cwd: "/workspace",
start: { type: "input", id: input.inputID },
userMessageID: `client_${input.inputID}`,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
})
}
function tokens() {
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
}
function replayFixtureMessages(): SessionMessageInfo[] {
return [
{
id: "msg_user",
type: "user",
text: "hello",
time: { created: 1 },
files: [
{
data: "",
mime: "text/markdown",
name: "note.md",
source: { type: "uri", uri: "file:///workspace/note.md" },
},
{
data: "aGVsbG8=",
mime: "text/plain",
name: "inline.txt",
source: { type: "inline" },
},
],
},
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 2, completed: 3 },
content: [
{ type: "text", text: "answer" },
{ type: "reasoning", text: "thinking" },
{
type: "tool",
id: "call_done",
name: "shell",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
content: [
{ type: "text", text: "done" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
],
result: { code: 0 },
},
},
{
type: "tool",
id: "call_running",
name: "shell",
time: { created: 2, ran: 2 },
state: {
status: "running",
input: { command: "pwd" },
structured: {},
content: [{ type: "text", text: "/workspace" }],
},
},
{
type: "tool",
id: "call_failed",
name: "read",
time: { created: 2, completed: 3 },
state: {
status: "error",
input: { filePath: "/workspace/missing.ts" },
structured: { bytes: 0 },
content: [{ type: "text", text: "partial" }],
error: { type: "tool.error", message: "failed hard" },
},
},
{
type: "tool",
id: "call_streaming",
name: "shell",
time: { created: 2 },
state: { status: "streaming", input: '{"command":' },
},
],
},
]
}
function replayToolMessage(id: string) {
return {
id: `msg_${id}`,
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 1, completed: 2 },
content: [
{
type: "tool",
id,
name: "shell",
time: { created: 1, completed: 2 },
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
content: [{ type: "text", text: "done" }],
},
},
],
} satisfies SessionMessageInfo
}
+124
View File
@@ -0,0 +1,124 @@
import { expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { streamTurn } from "../../src/acp/event"
test("acp prompt resolves after ordered turn updates", async () => {
const encoder = new TextEncoder()
let events: ReadableStreamDefaultController<Uint8Array> | undefined
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
events = controller
send(controller, { id: "evt_connected", type: "server.connected", data: {} })
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
if (url.pathname === "/api/session/ses_test/prompt") {
const body: unknown = await request.json()
if (!body || typeof body !== "object") {
return new Response(null, { status: 400 })
}
const id = Reflect.get(body, "id")
if (typeof id !== "string") return new Response(null, { status: 400 })
queueMicrotask(() => {
if (!events) return
send(events, {
id: "evt_promoted",
created: 1,
type: "session.input.promoted",
data: { sessionID: "ses_test", inputID: id },
})
send(events, {
id: "evt_text",
created: 2,
type: "session.text.delta",
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hello" },
})
send(events, {
id: "evt_step",
created: 3,
type: "session.step.ended",
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
send(events, {
id: "evt_done",
created: 4,
type: "session.execution.succeeded",
data: { sessionID: "ses_test" },
})
})
return Response.json({ data: {} })
}
if (url.pathname === "/api/session/ses_test/message/msg_assistant") {
return Response.json({
data: {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "hello" }],
finish: "stop",
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 4 },
},
})
}
return new Response(null, { status: 404 })
},
})
const client = OpenCode.make({ baseUrl: server.url.toString() })
try {
const id = "msg_prompt"
const userMessageID = "client-message"
const response = await streamTurn({
client,
connection: {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id },
userMessageID,
control: { cancelled: false, admission: new AbortController() },
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
})
expect(updates).toEqual([
{
sessionId: "ses_test",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hello" },
},
},
])
expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } })
} finally {
events?.close()
await server.stop(true)
}
function send(controller: ReadableStreamDefaultController<Uint8Array>, event: unknown) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
})
@@ -0,0 +1,49 @@
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?.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)
})
@@ -0,0 +1,85 @@
import type {
CloseSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
} from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption } from "./subprocess"
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
await using fixture = await createAcpFixture()
expect(await fixture.spawn().close()).toBe(0)
}, 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")
}, 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("resume capability advertisement", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}, 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)
})
@@ -0,0 +1,499 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { streamTurn } from "../../src/acp/event"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Fixture = ReturnType<typeof createSseFixture>
describe("acp permission behavior", () => {
test("forwards allow-once and allow-always selections to the generated client", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_allow", inputID: id }))
send(
permissionAsked("ses_allow", "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
}),
)
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
metadata: { filePath: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return {
outcome: {
outcome: "selected",
optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always",
},
}
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_allow", "input_allow")
expect(permissionRequests[0]).toMatchObject({
sessionId: "ses_allow",
toolCall: {
toolCallId: "call_once",
status: "pending",
title: "printf hello",
kind: "execute",
locations: [{ path: "/workspace" }],
rawInput: { command: "printf hello", cwd: "/workspace" },
},
options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
],
})
expect(permissionRequests[1]).toMatchObject({
sessionId: "ses_allow",
toolCall: {
toolCallId: "call_always",
status: "pending",
title: "/workspace/file.ts",
kind: "read",
locations: [{ path: "/workspace/file.ts" }],
rawInput: { filePath: "/workspace/file.ts" },
},
})
expect(permissionReplies(fixture)).toEqual([
["perm_once", "once"],
["perm_always", "always"],
])
} finally {
await fixture.stop()
}
})
test("preserves external directory permission context", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_external", inputID: id }))
send(
permissionAsked("ses_external", "perm_external", {
action: "external_directory",
metadata: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_external" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_external", "input_external")
expect(permissionRequests[0]?.toolCall).toMatchObject({
title: "Create external directory",
locations: [{ path: "/tmp/outside" }],
rawInput: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
})
} finally {
await fixture.stop()
}
})
test("previews edits during approval and syncs the completed file", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
const file = path.join(cwd, "file.ts")
await fs.writeFile(file, "before")
const permissionRequests: RequestPermissionRequest[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
name: "edit",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
input: { path: "file.ts", oldString: "before", newString: "after" },
executed: false,
}),
)
send(
permissionAsked("ses_edit", "perm_edit", {
action: "edit",
source: { type: "tool", messageID: "msg_edit", callID: "call_edit" },
}),
)
},
async onPermissionReply({ send }) {
await fs.writeFile(file, "after")
send(
durableEvent("session.tool.success", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
structured: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_edit" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
writeTextFile: async (request) => {
writes.push(request)
return {}
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_edit", "input_edit", cwd)
expect(permissionRequests[0]?.toolCall).toMatchObject({
title: "file.ts",
kind: "edit",
locations: [{ path: "file.ts" }],
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
})
expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
} finally {
await fixture.stop()
await fs.rm(cwd, { recursive: true, force: true })
}
})
test("previews and syncs each file in a patch", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
await Promise.all([
fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
fs.writeFile(path.join(cwd, "second.ts"), "alpha\n"),
])
const patchText = [
"*** Begin Patch",
"*** Update File: first.ts",
"@@",
"-one",
"+two",
"*** Update File: second.ts",
"@@",
"-alpha",
"+beta",
"*** End Patch",
].join("\n")
const permissionRequests: RequestPermissionRequest[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
name: "patch",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
input: { patchText },
executed: false,
}),
)
send(
permissionAsked("ses_patch", "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", callID: "call_patch" },
}),
)
},
async onPermissionReply({ send }) {
await Promise.all([
fs.writeFile(path.join(cwd, "first.ts"), "two\n"),
fs.writeFile(path.join(cwd, "second.ts"), "beta\n"),
])
send(
durableEvent("session.tool.success", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_patch" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
writeTextFile: async (request) => {
writes.push(request)
return {}
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_patch", "input_patch", cwd)
expect(permissionRequests[0]?.toolCall).toMatchObject({
title: "2 files",
kind: "edit",
locations: [{ path: "first.ts" }, { path: "second.ts" }],
content: [
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
],
})
expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
{ sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
{ sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
])
} finally {
await fixture.stop()
await fs.rm(cwd, { recursive: true, force: true })
}
})
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_reject", inputID: id }))
send(permissionAsked("ses_reject", "perm_selected_reject"))
send(permissionAsked("ses_reject", "perm_cancelled"))
send(permissionAsked("ses_reject", "perm_failed"))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_reject" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request): Promise<RequestPermissionResponse> => {
if (request.toolCall.toolCallId === "perm_selected_reject") {
return { outcome: { outcome: "selected", optionId: "reject" } }
}
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
throw new Error("client permission UI failed")
},
} satisfies Connection
try {
const response = await startTurn(fixture, connection, "ses_reject", "input_reject")
expect(response).toMatchObject({ stopReason: "end_turn" })
expect(permissionReplies(fixture)).toEqual([
["perm_selected_reject", "reject"],
["perm_cancelled", "reject"],
["perm_failed", "reject"],
])
} finally {
await fixture.stop()
}
})
test("serializes permission requests and replies within one session", async () => {
const firstRequested = Promise.withResolvers<void>()
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_serial", inputID: id }))
send(permissionAsked("ses_serial", "perm_1"))
send(permissionAsked("ses_serial", "perm_2"))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
if (request.toolCall.toolCallId === "perm_1") {
firstRequested.resolve()
return releaseFirst.promise
}
return { outcome: { outcome: "selected", optionId: "always" } } as const
},
} satisfies Connection
const result = startTurn(fixture, connection, "ses_serial", "input_serial")
try {
await withTimeout(firstRequested.promise, "first permission was not requested")
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
expect(permissionReplies(fixture)).toEqual([])
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
await withTimeout(result, "serialized permission turn did not finish")
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
expect(permissionReplies(fixture)).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
} finally {
releaseFirst.resolve({ outcome: { outcome: "cancelled" } })
await result.catch(() => undefined)
await fixture.stop()
}
})
test("does not let one session's blocked permission stall another session", async () => {
const blockedRequested = Promise.withResolvers<void>()
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
const promptIDs = new Map<string, string>()
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ sessionID, id, send }) {
promptIDs.set(sessionID, id)
if (promptIDs.size !== 2) return
const blockedID = promptIDs.get("ses_blocked")
const freeID = promptIDs.get("ses_free")
if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
send(durableEvent("session.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
send(permissionAsked("ses_blocked", "perm_blocked"))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_free",
assistantMessageID: "msg_free",
ordinal: 0,
delta: "session B continued",
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_free",
assistantMessageID: "msg_free",
finish: "stop",
cost: 0,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_free" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_blocked" }))
},
})
const connection = {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => {
blockedRequested.resolve()
return releaseBlocked.promise
},
} satisfies Connection
const blocked = startTurn(fixture, connection, "ses_blocked", "input_blocked")
const free = startTurn(fixture, connection, "ses_free", "input_free")
try {
await withTimeout(blockedRequested.promise, "blocked permission was not requested")
const response = await withTimeout(free, "free session was stalled by another session's permission")
expect(response).toMatchObject({ stopReason: "end_turn" })
expect(updates).toContainEqual({
sessionId: "ses_free",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_free",
content: { type: "text", text: "session B continued" },
},
})
expect(permissionReplies(fixture)).toEqual([])
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
await withTimeout(blocked, "blocked session did not resume after permission selection")
expect(permissionReplies(fixture)).toEqual([["perm_blocked", "once"]])
} finally {
releaseBlocked.resolve({ outcome: { outcome: "cancelled" } })
await Promise.all([blocked.catch(() => undefined), free.catch(() => undefined)])
await fixture.stop()
}
})
})
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
return streamTurn({
client: fixture.client,
connection,
sessionID,
cwd,
start: { type: "input", id: inputID },
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
})
}
function permissionAsked(
sessionID: string,
id: string,
input: {
readonly action?: string
readonly metadata?: Record<string, unknown>
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
} = {},
) {
return ephemeralEvent("permission.v2.asked", {
id,
sessionID,
action: input.action ?? "shell",
resources: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
...(input.source ? { source: input.source } : {}),
})
}
function permissionReplies(fixture: Fixture) {
return fixture.requests.flatMap((request): Array<[string, string]> => {
const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
if (!match?.[1] || !request.body || typeof request.body !== "object") return []
const reply = Reflect.get(request.body, "reply")
return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
})
}
@@ -0,0 +1,62 @@
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("acp prompt content subprocess", () => {
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()
await initialize(acp)
const session = await newSession(acp, fixture.home)
expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this embedded resource." },
{
type: "resource",
resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
},
],
}),
)
expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this image." },
{
type: "image",
mimeType: "image/png",
data: tinyPng,
},
],
}),
)
const linked = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this linked file." },
{
type: "resource_link",
uri: pathToFileURL(path.join(fixture.home, "README.md")).href,
name: "README.md",
mimeType: "text/markdown",
},
],
}),
)
expect(linked.stopReason).toBe("end_turn")
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(3)
}, 60_000)
})
@@ -0,0 +1,269 @@
import { describe, expect, test } from "bun:test"
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
describe("acp service directory behavior", () => {
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
let created = 0
await using fixture = makeACPFixture({
fetch(request) {
if (request.method !== "POST" || request.path !== "/api/session") return undefined
created++
return Response.json({
data: makeSession(`ses_${created}`, { cwd: created <= 2 ? "/workspace" : "/other" }),
})
},
})
const first = await Promise.all([
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
])
const other = await fixture.service.newSession({ cwd: "/other", mcpServers: [] })
expect(first.map((session) => session.sessionId).toSorted()).toEqual(["ses_1", "ses_2"])
expect(other.sessionId).toBe("ses_3")
expect(currentValue(first[0], "model")).toBe("test/test-model")
expect(currentValue(first[0], "mode")).toBe("build")
expect(
["/api/model", "/api/model/default", "/api/agent", "/api/command", "/api/skill"].map((path) =>
fixture.requests
.filter((request) => request.path === path)
.map((request) => request.query["location[directory]"]),
),
).toEqual([
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
])
expect(
fixture.requests
.filter((request) => request.method === "POST" && request.path === "/api/session")
.map((request) => request.body),
).toEqual([
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/workspace" },
},
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/workspace" },
},
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/other" },
},
])
expect(
fixture.updates.map((item) =>
item.update.sessionUpdate === "available_commands_update"
? item.update.availableCommands.map((command) => command.name)
: [],
),
).toEqual([
["review", "verify"],
["review", "verify"],
["review", "verify"],
])
})
test("does not cache a failed catalog load", async () => {
let modelCalls = 0
await using fixture = makeACPFixture({
fetch(request) {
if (request.path === "/api/model") {
modelCalls++
if (modelCalls === 1) {
return Response.json(
{ name: "ModelsNotReadyError", data: { message: "catalog is warming" } },
{ status: 503 },
)
}
}
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_retry") })
}
return undefined
},
})
const failure = await fixture.service
.newSession({ cwd: "/workspace", mcpServers: [] })
.catch((error: unknown) => error)
expect(failure).toMatchObject({ name: "ModelsNotReadyError" })
const retried = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(retried.sessionId).toBe("ses_retry")
expect(modelCalls).toBe(2)
})
test("switches model, effort, and mode against the warm catalog", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_config") })
}
if (
request.method === "POST" &&
(request.path === "/api/session/ses_config/model" || request.path === "/api/session/ses_config/agent")
) {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const selectedModel = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
const selectedEffort = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "medium",
})
const selectedMode = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "plan",
})
await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" })
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
expect(currentValue(selectedModel, "effort")).toBe("low")
expect(currentValue(selectedEffort, "effort")).toBe("medium")
expect(currentValue(selectedMode, "mode")).toBe("plan")
expect(
fixture.requests
.filter((request) => request.path === "/api/session/ses_config/model")
.map((request) => request.body),
).toEqual([
{ model: { providerID: "test", id: secondModel.id } },
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
{ model: { providerID: "test", id: testModel.id, variant: "high" } },
])
expect(
fixture.requests
.filter((request) => request.path === "/api/session/ses_config/agent")
.map((request) => request.body),
).toEqual([{ agent: "plan" }, { agent: "build" }])
expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(1)
const invalidEffort = await fixture.service
.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "maximum",
})
.catch((error: unknown) => error)
const invalidMode = await fixture.service
.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "missing",
})
.catch((error: unknown) => error)
const invalidConfig = await fixture.service
.setSessionConfigOption({
sessionId: session.sessionId,
configId: "missing",
value: "value",
})
.catch((error: unknown) => error)
expect(invalidEffort).toMatchObject({ _tag: "ACPInvalidEffortError" })
expect(invalidMode).toMatchObject({ _tag: "ACPInvalidModeError" })
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
})
test("converts MCP configs and deduplicates registrations per session and config", async () => {
const local: McpServer = {
name: "tools",
command: "bun",
args: ["server.ts"],
env: [{ name: "TOKEN", value: "x" }],
}
const changed: McpServer = { ...local, args: ["changed.ts"] }
const remote: McpServer = {
type: "http",
name: "docs",
url: "https://example.com/mcp",
headers: [{ name: "Authorization", value: "Bearer x" }],
}
let created = 0
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
created++
return Response.json({ data: makeSession(`ses_${created}`) })
}
if (request.method === "GET" && request.path === "/api/session/ses_1") {
return Response.json({ data: makeSession("ses_1") })
}
if (request.method === "PUT" && request.path.startsWith("/api/mcp/")) {
return new Response(null, { status: 204 })
}
return undefined
},
})
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, local, remote] })
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith("/api/mcp/"))
expect(adds).toHaveLength(4)
expect(adds.filter((request) => request.path === "/api/mcp/tools").map((request) => request.body)).toEqual([
{
config: {
type: "local",
command: ["bun", "server.ts"],
environment: { TOKEN: "x" },
},
},
{
config: {
type: "local",
command: ["bun", "changed.ts"],
environment: { TOKEN: "x" },
},
},
{
config: {
type: "local",
command: ["bun", "server.ts"],
environment: { TOKEN: "x" },
},
},
])
expect(adds.find((request) => request.path === "/api/mcp/docs")?.body).toEqual({
config: {
type: "remote",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer x" },
oauth: false,
},
})
expect(adds.map((request) => request.query)).toEqual([
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
])
})
})
function currentValue(
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
id: string,
) {
return result?.configOptions?.find((option) => option.id === id)?.currentValue
}
+207
View File
@@ -0,0 +1,207 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import {
OpenCode,
type AgentInfo,
type CommandInfo,
type ModelInfo,
type ModelRef,
type SessionInfo,
type SkillInfo,
type TokenUsageInfo,
} from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service"
export type FixtureRequest = {
readonly method: string
readonly path: string
readonly query: Record<string, string>
readonly body: unknown
}
export type FixtureContext = {
readonly requests: FixtureRequest[]
send(event: unknown): void
}
type FixtureHandler = (
request: FixtureRequest,
context: FixtureContext,
) => Response | undefined | Promise<Response | undefined>
type FixtureOptions = {
readonly fetch?: FixtureHandler
readonly models?: readonly ModelInfo[]
readonly defaultModel?: ModelInfo
readonly agents?: readonly AgentInfo[]
readonly commands?: readonly CommandInfo[]
readonly skills?: readonly SkillInfo[]
}
export const testModel = {
id: "test-model",
modelID: "test-model",
providerID: "test",
name: "Test Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "default" }, { id: "high" }],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 100_000, output: 10_000 },
} satisfies ModelInfo
export const secondModel = {
id: "second-model",
modelID: "second-model",
providerID: "test",
name: "Second Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "low" }, { id: "medium" }],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 200_000, output: 20_000 },
} satisfies ModelInfo
export const buildAgent = {
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
export const planAgent = {
id: "plan",
name: "Plan",
description: "Plan first",
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
export const reviewCommand = {
name: "review",
description: "Review changes",
template: "",
} satisfies CommandInfo
export const verifySkill = {
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
} satisfies SkillInfo
export function makeSession(
id: string,
input: {
readonly cwd?: string
readonly agent?: string
readonly model?: ModelRef
readonly cost?: number
readonly tokens?: TokenUsageInfo
readonly time?: SessionInfo["time"]
readonly title?: string
} = {},
): SessionInfo {
return {
id,
projectID: "global",
agent: input.agent ?? "build",
model: input.model ?? { providerID: "test", id: "test-model", variant: "default" },
cost: input.cost ?? 0,
tokens: input.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: input.time ?? { created: 0, updated: 0 },
title: input.title ?? `Session ${id}`,
location: { directory: input.cwd ?? "/workspace" },
}
}
export function makeACPFixture(options: FixtureOptions = {}) {
const requests: FixtureRequest[] = []
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const encoder = new TextEncoder()
let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
const models = options.models ?? [testModel, secondModel]
const context: FixtureContext = {
requests,
send(event) {
if (!eventController) throw new Error("ACP fixture has no active event stream")
eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
},
}
const server = Bun.serve({
port: 0,
async fetch(raw) {
const url = new URL(raw.url)
const request: FixtureRequest = {
method: raw.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
body: raw.method === "GET" || raw.method === "HEAD" ? undefined : await raw.json().catch(() => undefined),
}
requests.push(request)
const response = await options.fetch?.(request, context)
if (response) return response
const directory = request.query["location[directory]"] ?? "/workspace"
const location = { directory, project: { id: "global", directory } }
if (request.path === "/api/event") {
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
return new Response(
new ReadableStream<Uint8Array>({
start(value) {
controller = value
eventController = value
context.send({ id: "evt_connected", type: "server.connected", data: {} })
},
cancel() {
if (eventController === controller) eventController = undefined
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
if (request.path === "/api/model") return Response.json({ location, data: models })
if (request.path === "/api/model/default") {
return Response.json({ location, data: options.defaultModel ?? models[0] ?? null })
}
if (request.path === "/api/agent") {
return Response.json({ location, data: options.agents ?? [buildAgent, planAgent] })
}
if (request.path === "/api/command") {
return Response.json({ location, data: options.commands ?? [reviewCommand] })
}
if (request.path === "/api/skill") {
return Response.json({ location, data: options.skills ?? [verifySkill] })
}
return new Response(null, { status: 404 })
},
})
const service = ACPService.make({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
})
return {
service,
requests,
updates,
async [Symbol.asyncDispose]() {
eventController?.close()
await server.stop(true)
},
}
}
@@ -0,0 +1,232 @@
import { describe, expect, test } from "bun:test"
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
describe("acp service lifecycle", () => {
test("loads and forks with paginated replay while resume does not replay", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "GET" && request.path === "/api/session/ses_loaded") {
return Response.json({
data: makeSession("ses_loaded", {
cwd: "/workspace",
agent: "plan",
model: { providerID: "test", id: secondModel.id, variant: "medium" },
}),
})
}
if (request.method === "GET" && request.path === "/api/session/ses_resume") {
return Response.json({
data: makeSession("ses_resume", {
cwd: "/workspace",
agent: "plan",
model: { providerID: "test", id: secondModel.id, variant: "low" },
}),
})
}
if (request.method === "POST" && request.path === "/api/session/ses_loaded/fork") {
return Response.json({
data: makeSession("ses_fork", {
cwd: "/workspace",
agent: "plan",
model: { providerID: "test", id: secondModel.id, variant: "medium" },
}),
})
}
if (request.method === "GET" && request.path === "/api/session/ses_loaded/message") {
if (request.query.cursor === "messages-2") {
return Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
content: [{ type: "text", text: "hi there" }],
},
],
cursor: {},
})
}
return Response.json({
data: [{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } }],
cursor: { next: "messages-2" },
})
}
if (request.method === "GET" && request.path === "/api/session/ses_fork/message") {
return Response.json({
data: [{ id: "msg_fork", type: "user", text: "forked", time: { created: 2 } }],
cursor: {},
})
}
return undefined
},
})
const loaded = await fixture.service.loadSession({
cwd: "/ignored",
sessionId: "ses_loaded",
mcpServers: [],
})
const resumed = await fixture.service.resumeSession({
cwd: "/ignored",
sessionId: "ses_resume",
mcpServers: [],
})
const forked = await fixture.service.forkSession({
cwd: "/ignored",
sessionId: "ses_loaded",
mcpServers: [],
})
expect(currentValue(loaded, "model")).toBe("test/second-model")
expect(currentValue(loaded, "effort")).toBe("medium")
expect(currentValue(loaded, "mode")).toBe("plan")
expect(currentValue(resumed, "effort")).toBe("low")
expect(forked.sessionId).toBe("ses_fork")
expect(currentValue(forked, "effort")).toBe("medium")
expect(
fixture.updates.filter(
(item) =>
item.update.sessionUpdate === "user_message_chunk" || item.update.sessionUpdate === "agent_message_chunk",
),
).toEqual([
{
sessionId: "ses_loaded",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_user",
content: { type: "text", text: "hello" },
},
},
{
sessionId: "ses_loaded",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hi there" },
},
},
{
sessionId: "ses_fork",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_fork",
content: { type: "text", text: "forked" },
},
},
])
expect(
fixture.requests
.filter((request) => request.path.endsWith("/message"))
.map((request) => ({ path: request.path, query: request.query })),
).toEqual([
{
path: "/api/session/ses_loaded/message",
query: { limit: "200", order: "asc" },
},
{
path: "/api/session/ses_loaded/message",
query: { limit: "200", cursor: "messages-2" },
},
{
path: "/api/session/ses_fork/message",
query: { limit: "200", order: "asc" },
},
])
expect(fixture.requests).toContainEqual({
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: {},
})
})
test("lists server-backed pages and forwards cwd and cursor", async () => {
const firstPage = Array.from({ length: 100 }, (_, index) =>
makeSession(`ses_${100 - index}`, {
cwd: "/workspace",
time: { created: index, updated: 100_000 - index },
title: `Session ${100 - index}`,
}),
)
await using fixture = makeACPFixture({
fetch(request) {
if (request.method !== "GET" || request.path !== "/api/session") return undefined
if (request.query.cursor === "page-2") {
return Response.json({
data: [makeSession("ses_0", { cwd: "/workspace", time: { created: 0, updated: 1 } })],
cursor: {},
})
}
return Response.json({ data: firstPage, cursor: { next: "page-2" } })
},
})
const first = await fixture.service.listSessions({ cwd: "/workspace" })
const second = await fixture.service.listSessions({ cwd: "/workspace", cursor: first.nextCursor })
expect(first.sessions).toHaveLength(100)
expect(first.sessions[0]).toEqual({
sessionId: "ses_100",
cwd: "/workspace",
title: "Session 100",
updatedAt: new Date(100_000).toISOString(),
})
expect(first.nextCursor).toBe("page-2")
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
expect(second.nextCursor).toBeUndefined()
expect(
fixture.requests.filter((request) => request.path === "/api/session").map((request) => request.query),
).toEqual([
{ limit: "100", order: "desc", directory: "/workspace" },
{ limit: "100", order: "desc", directory: "/workspace", cursor: "page-2" },
])
})
test("cancel preserves the attachment while close removes it and interrupts best-effort", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_lifecycle") })
}
if (request.method === "POST" && request.path === "/api/session/ses_lifecycle/model") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path.endsWith("/interrupt")) {
return new Response(null, { status: 500 })
}
return undefined
},
})
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
await fixture.service.cancel({ sessionId: created.sessionId })
const updated = await fixture.service.setSessionConfigOption({
sessionId: created.sessionId,
configId: "effort",
value: "high",
})
expect(currentValue(updated, "effort")).toBe("high")
expect(await fixture.service.closeSession({ sessionId: created.sessionId })).toEqual({})
const missing = await fixture.service
.setSessionConfigOption({
sessionId: created.sessionId,
configId: "effort",
value: "default",
})
.catch((error: unknown) => error)
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: created.sessionId })
expect(await fixture.service.closeSession({ sessionId: "missing" })).toEqual({})
expect(
fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
).toEqual([
"/api/session/ses_lifecycle/interrupt",
"/api/session/ses_lifecycle/interrupt",
"/api/session/missing/interrupt",
])
})
})
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
return result.configOptions?.find((option) => option.id === id)?.currentValue
}
+264
View File
@@ -0,0 +1,264 @@
import { describe, expect, test } from "bun:test"
import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
describe("acp service prompt routing and usage", () => {
test("routes slash commands, skills, and compact through their session endpoints", async () => {
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "GET" && request.path === "/api/session/ses_routes") {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.input.promoted",
data: { sessionID: "ses_routes", inputID: id },
})
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: id.replace(/^msg_/, "evt_"),
type: "session.skill.activated",
data: { sessionID: "ses_routes", skill: "verify" },
})
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.compaction.admitted",
data: { sessionID: "ses_routes", inputID: id },
})
return Response.json({ data: {} })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const commandResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-command",
prompt: [{ type: "text", text: "/review now" }],
})
const skillResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-skill",
prompt: [{ type: "text", text: "/verify" }],
})
const compactResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-compact",
prompt: [{ type: "text", text: "/compact" }],
})
expect([commandResult.stopReason, skillResult.stopReason, compactResult.stopReason]).toEqual([
"end_turn",
"end_turn",
"end_turn",
])
const command = fixture.requests.find((request) => request.path === "/api/session/ses_routes/command")
const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
id: expect.any(String),
command: "review",
arguments: "now",
files: [],
delivery: "steer",
})
expect(skill?.body).toMatchObject({ id: expect.any(String), skill: "verify" })
expect(compact?.body).toMatchObject({ id: expect.any(String) })
expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
})
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 fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_usage") })
}
if (request.method === "POST" && request.path === "/api/session/ses_usage/model") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_usage/prompt") {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.input.promoted",
data: { sessionID: "ses_usage", inputID: id },
})
context.send({
id: "evt_step",
type: "session.step.ended",
data: {
sessionID: "ses_usage",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0.5,
tokens: assistantTokens,
},
})
context.send({
id: "evt_done",
type: "session.execution.succeeded",
data: { sessionID: "ses_usage" },
})
return Response.json({ data: {} })
}
if (request.method === "GET" && request.path === "/api/session/ses_usage/message/msg_assistant") {
return Response.json({
data: {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: secondModel.id },
content: [{ type: "text", text: "done" }],
finish: "stop",
tokens: assistantTokens,
time: { created: 1, completed: 2 },
},
})
}
if (request.method === "GET" && request.path === "/api/session/ses_usage") {
return Response.json({
data: makeSession("ses_usage", {
model: { providerID: "test", id: secondModel.id },
cost: 3.5,
tokens: { input: 120, output: 50, reasoning: 8, cache: { read: 30, write: 4 } },
}),
})
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
const response = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-message",
prompt: [{ type: "text", text: "hello" }],
})
expect(response).toEqual({
stopReason: "end_turn",
userMessageId: "client-message",
usage: {
inputTokens: 100,
outputTokens: 40,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
},
_meta: {},
})
expect(fixture.updates.filter((item) => item.update.sessionUpdate === "usage_update")).toEqual([
{
sessionId: "ses_usage",
update: {
sessionUpdate: "usage_update",
used: 171,
size: 200_000,
cost: { amount: 3.5, currency: "USD" },
},
},
])
})
test("does not fail a completed prompt when the usage refresh fails", async () => {
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_usage_failure") })
}
if (request.method === "POST" && request.path === "/api/session/ses_usage_failure/prompt") {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.input.promoted",
data: { sessionID: "ses_usage_failure", inputID: id },
})
context.send({
id: "evt_step_failure",
type: "session.step.ended",
data: {
sessionID: "ses_usage_failure",
assistantMessageID: "msg_usage_failure",
finish: "stop",
cost: 0,
tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
context.send({
id: "evt_done_failure",
type: "session.execution.succeeded",
data: { sessionID: "ses_usage_failure" },
})
return Response.json({ data: {} })
}
if (request.method === "GET" && request.path === "/api/session/ses_usage_failure/message/msg_usage_failure") {
return Response.json({
data: {
id: "msg_usage_failure",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "stop",
tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
},
})
}
if (request.method === "GET" && request.path === "/api/session/ses_usage_failure") {
return new Response(null, { status: 500 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const response = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello" }],
})
expect(response.stopReason).toBe("end_turn")
expect(fixture.updates.some((item) => item.update.sessionUpdate === "usage_update")).toBe(false)
})
})
function requestID(request: FixtureRequest) {
if (!request.body || typeof request.body !== "object") throw new Error(`missing body for ${request.path}`)
const id = Reflect.get(request.body, "id")
if (typeof id !== "string") throw new Error(`missing prompt id for ${request.path}`)
return id
}
function completeTurn(context: FixtureContext, sessionID: string, start: unknown) {
context.send(start)
context.send({
id: `evt_done_${sessionID}`,
type: "session.execution.succeeded",
data: { sessionID },
})
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service"
describe("acp service", () => {
test("creates a v2 session, registers mcp, and publishes commands", async () => {
const requests: Array<{ method: string; path: string; body?: unknown }> = []
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
requests.push({
method: request.method,
path: url.pathname,
body: request.method === "GET" ? undefined : await request.json().catch(() => undefined),
})
const location = { directory: "/workspace", project: { id: "global", directory: "/workspace" } }
if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
if (url.pathname === "/api/command")
return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/skill") return Response.json({ location, data: [skill] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/mcp/docs" && request.method === "PUT") return new Response(null, { status: 204 })
return new Response(null, { status: 404 })
},
})
const service = ACPService.make({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
})
try {
const result = await service.newSession({
cwd: "/workspace",
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
})
expect(result.sessionId).toBe("ses_acp")
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",
path: "/api/mcp/docs",
body: {
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
},
})
expect(updates.at(-1)).toMatchObject({
sessionId: "ses_acp",
update: {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "review" }, { name: "verify", description: "Verify work" }],
},
})
} finally {
await server.stop(true)
}
})
})
const model = {
id: "test-model",
modelID: "test-model",
providerID: "test",
name: "Test Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "default" }, { id: "high" }],
time: { released: 0 },
cost: [],
status: "active" as const,
enabled: true,
limit: { context: 100_000, output: 10_000 },
}
const agent = {
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
mode: "primary" as const,
hidden: false,
permissions: [],
}
const skill = {
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
}
const session = {
id: "ses_acp",
projectID: "global",
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "New session",
location: { directory: "/workspace" },
}
@@ -0,0 +1,24 @@
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, initialize, newSession, verifierSkill } from "./subprocess"
describe("acp skills subprocess", () => {
test("skill slash command appears through available_commands_update", async () => {
await using fixture = await createAcpFixture({ skill: verifierSkill })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const update = await acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some(
(command) => command.name === "verifier-skill" && command.description.length > 0,
),
)
expect(update.params.sessionId).toBe(session.sessionId)
}, 60_000)
})
+207
View File
@@ -0,0 +1,207 @@
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode-ai/client/promise"
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
type RequestRecord = {
readonly method: string
readonly path: string
readonly body?: unknown
}
type FixtureOptions = {
readonly onPrompt?: (input: {
readonly sessionID: string
readonly id: string
readonly body: unknown
readonly signal: AbortSignal
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onInterrupt?: (input: {
readonly sessionID: string
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onPermissionReply?: (input: {
readonly sessionID: string
readonly requestID: string
readonly reply: string
readonly body: unknown
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onFormCancel?: (input: {
readonly sessionID: string
readonly formID: string
readonly send: (event: unknown) => void
}) => void | Promise<void>
}
const ids = { next: 0 }
export function durableEvent<Type extends DurableEvent["type"]>(
type: Type,
data: Extract<DurableEvent, { type: Type }>["data"],
) {
ids.next++
return {
id: `evt_${ids.next}`,
created: ids.next,
type,
durable: { aggregateID: "test", seq: ids.next, version: 1 },
data,
}
}
export function ephemeralEvent<Type extends EphemeralEvent["type"]>(
type: Type,
data: Extract<EphemeralEvent, { type: Type }>["data"],
) {
ids.next++
return { id: `evt_${ids.next}`, created: ids.next, type, data }
}
export function createSseFixture(options: FixtureOptions = {}) {
const encoder = new TextEncoder()
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>()
const requests: RequestRecord[] = []
const messages = new Map<string, SessionMessageInfo>()
const send = (event: unknown) => {
for (const stream of streams) {
try {
stream.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
} catch {
streams.delete(stream)
}
}
}
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
const body = request.method === "GET" ? undefined : await request.json().catch(() => undefined)
requests.push({ method: request.method, path: url.pathname, ...(body === undefined ? {} : { body }) })
if (url.pathname === "/api/event") {
const state: { stream?: ReadableStreamDefaultController<Uint8Array> } = {}
return new Response(
new ReadableStream<Uint8Array>({
start(stream) {
state.stream = stream
streams.add(stream)
stream.enqueue(
encoder.encode(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`,
),
)
},
cancel() {
if (state.stream) streams.delete(state.stream)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
const prompt = /^\/api\/session\/([^/]+)\/prompt$/.exec(url.pathname)
if (prompt?.[1]) {
const id = stringField(body, "id")
if (!id) return new Response(null, { status: 400 })
await options.onPrompt?.({
sessionID: decodeURIComponent(prompt[1]),
id,
body,
signal: request.signal,
send,
})
return Response.json({ data: { text: stringField(body, "text") ?? "" } })
}
const message = /^\/api\/session\/([^/]+)\/message\/([^/]+)$/.exec(url.pathname)
if (message?.[1] && message[2]) {
const sessionID = decodeURIComponent(message[1])
const messageID = decodeURIComponent(message[2])
return Response.json({
data: messages.get(`${sessionID}/${messageID}`) ?? messages.get(messageID) ?? assistantMessage(messageID),
})
}
const permission = /^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/.exec(url.pathname)
if (permission?.[1] && permission[2]) {
const reply = stringField(body, "reply")
if (!reply) return new Response(null, { status: 400 })
await options.onPermissionReply?.({
sessionID: decodeURIComponent(permission[1]),
requestID: decodeURIComponent(permission[2]),
reply,
body,
send,
})
return new Response(null, { status: 204 })
}
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)\/cancel$/.exec(url.pathname)
if (form?.[1] && form[2]) {
await options.onFormCancel?.({
sessionID: decodeURIComponent(form[1]),
formID: decodeURIComponent(form[2]),
send,
})
return new Response(null, { status: 204 })
}
const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
if (interrupt?.[1]) {
await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
},
})
return {
client: OpenCode.make({ baseUrl: server.url.toString() }),
messages,
requests,
send,
async stop() {
for (const stream of streams) {
try {
stream.close()
} catch {}
}
streams.clear()
await server.stop(true)
},
}
}
export async function withTimeout<Value>(promise: Promise<Value>, message: string, milliseconds = 2_000) {
const timeout = Promise.withResolvers<never>()
const timer = setTimeout(() => timeout.reject(new Error(message)), milliseconds)
try {
return await Promise.race([promise, timeout.promise])
} finally {
clearTimeout(timer)
}
}
function stringField(value: unknown, key: string) {
if (!value || typeof value !== "object") return undefined
const field = Reflect.get(value, key)
return typeof field === "string" ? field : undefined
}
function assistantMessage(id: string) {
return {
id,
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "stop",
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
} satisfies SessionMessageInfo
}
+384
View File
@@ -0,0 +1,384 @@
import type {
InitializeResponse,
NewSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
} from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
type JsonRpcRequest = {
readonly jsonrpc: "2.0"
readonly id: number
readonly method: string
readonly params?: unknown
}
export type JsonRpcError = {
readonly code: number
readonly message?: string
readonly data?: unknown
}
export type JsonRpcResponse<T> = {
readonly jsonrpc: "2.0"
readonly id: number
readonly result?: T
readonly error?: JsonRpcError
}
type JsonRpcNotification<T> = {
readonly jsonrpc: "2.0"
readonly method: string
readonly params: T
}
type JsonRpcMessage = Record<string, unknown>
type Waiter = {
readonly predicate: (message: JsonRpcMessage) => boolean
readonly resolve: (message: JsonRpcMessage) => void
readonly reject: (error: Error) => void
readonly timer: ReturnType<typeof setTimeout>
}
export type AcpProcess = {
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
timeoutMs?: number,
) => Promise<JsonRpcNotification<T>>
readonly close: () => Promise<number>
readonly stderr: () => string
readonly [Symbol.asyncDispose]: () => Promise<void>
}
export const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
const skills = path.join(root, "skills")
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(config, { recursive: true })])
if (options.skill) {
await fs.mkdir(path.join(skills, "verifier-skill"), { recursive: true })
await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
}
const requests: unknown[] = []
const llm = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
return new Response("Not found", { status: 404 })
}
requests.push(await request.json().catch(() => undefined))
return new Response(completion("accepted"), {
headers: { "content-type": "text/event-stream" },
})
},
})
await Bun.write(
path.join(config, "opencode.json"),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
)
const processes = new Set<AcpProcess>()
return {
root,
home,
llm: { requests },
spawn(extraEnv: Record<string, string | undefined> = {}) {
const acp = spawnAcp({
env: {
...process.env,
HOME: root,
USERPROFILE: root,
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_DIR: config,
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_MODELS_PATH: undefined,
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
...extraEnv,
},
})
processes.add(acp)
return acp
},
async [Symbol.asyncDispose]() {
await Promise.all([...processes].map((process) => process[Symbol.asyncDispose]()))
await llm.stop(true)
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
},
}
}
export function initialize(acp: AcpProcess) {
return acp
.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
clientInfo: { name: "opencode-local-acp", version: "0.1.0" },
})
.then(expectOk)
}
export function newSession(acp: AcpProcess, cwd: string) {
return acp.request<NewSessionResponse>("session/new", { cwd, mcpServers: [] }).then(expectOk)
}
export function expectOk<T>(response: JsonRpcResponse<T>) {
if (response.error) throw new Error(`ACP request failed: ${JSON.stringify(response.error)}`)
if (response.result === undefined) throw new Error("ACP response did not include a result")
return response.result
}
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
return options?.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.id === id && option.type === "select",
)
}
export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
const option = selectConfigOption(options, id)
if (!option) throw new Error(`Missing ACP config option: ${id}`)
return option
}
export function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }>) {
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
}
export function alternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
return value
}
function verifierConfig(llmUrl: string, skills?: string) {
const model = {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: { input: 0, output: 0 },
limit: { context: 100_000, output: 10_000 },
}
return {
autoupdate: false,
model: "test/test-model",
...(skills ? { skills: [skills] } : {}),
providers: {
test: {
name: "Test",
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "test-key", baseURL: llmUrl },
models: {
"test-model": {
...model,
name: "Test Model",
variants: [{ id: "low" }, { id: "high" }],
},
"second-model": {
...model,
name: "Second Test Model",
variants: [{ id: "medium" }, { id: "max" }],
},
},
},
},
}
}
function spawnAcp(input: { readonly env: Record<string, string | undefined> }): AcpProcess {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
cwd: path.join(import.meta.dir, "../.."),
env: input.env,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const errorDecoder = new TextDecoder()
const messages: JsonRpcMessage[] = []
const waiters: Waiter[] = []
let nextID = 1
let failure: Error | undefined
let stderr = ""
let inputClosed = false
let disposed = false
const fail = (error: Error) => {
if (failure) return
failure = error
waiters.splice(0).forEach((waiter) => {
clearTimeout(waiter.timer)
waiter.reject(error)
})
}
const dispatch = (message: JsonRpcMessage) => {
const index = waiters.findIndex((waiter) => waiter.predicate(message))
if (index === -1) {
messages.push(message)
return
}
const waiter = waiters.splice(index, 1)[0]
clearTimeout(waiter.timer)
waiter.resolve(message)
}
const output = (async () => {
const reader = child.stdout.getReader()
let buffered = ""
while (true) {
const chunk = await reader.read()
if (chunk.done) break
buffered += decoder.decode(chunk.value, { stream: true })
while (true) {
const newline = buffered.indexOf("\n")
if (newline === -1) break
const line = buffered.slice(0, newline).trim()
buffered = buffered.slice(newline + 1)
if (line) dispatch(parseMessage(line))
}
}
buffered += decoder.decode()
if (buffered.trim()) dispatch(parseMessage(buffered.trim()))
fail(new Error(`ACP exited before another response${stderr ? `: ${stderr}` : ""}`))
})().catch((error) => fail(asError(error)))
const errors = (async () => {
const reader = child.stderr.getReader()
while (true) {
const chunk = await reader.read()
if (chunk.done) break
stderr += errorDecoder.decode(chunk.value, { stream: true })
}
stderr += errorDecoder.decode()
})()
const take = (predicate: (message: JsonRpcMessage) => boolean, timeoutMs: number, description: string) => {
const index = messages.findIndex(predicate)
if (index !== -1) return Promise.resolve(messages.splice(index, 1)[0])
if (failure) return Promise.reject(failure)
return new Promise<JsonRpcMessage>((resolve, reject) => {
const waiter: Waiter = {
predicate,
resolve,
reject,
timer: setTimeout(() => {
const index = waiters.indexOf(waiter)
if (index !== -1) waiters.splice(index, 1)
reject(new Error(`Timed out waiting for ${description}${stderr ? `: ${stderr}` : ""}`))
}, timeoutMs),
}
waiters.push(waiter)
})
}
return {
async request<T>(method: string, params?: unknown) {
if (inputClosed) throw new Error("ACP stdin is closed")
const id = nextID++
const request: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
await child.stdin.write(encoder.encode(`${JSON.stringify(request)}\n`))
await child.stdin.flush()
const response = await take((message) => isResponse(message) && message.id === id, 20_000, `${method} response`)
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
return response
},
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
const notification = await take(
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
timeoutMs,
`${method} notification`,
)
if (!isNotification<T>(notification)) {
throw new Error(`Invalid ACP notification: ${JSON.stringify(notification)}`)
}
return notification
},
async close() {
if (!inputClosed) {
inputClosed = true
await child.stdin.end()
}
const exitCode = await withTimeout(child.exited, 5_000, "ACP did not exit after stdin EOF")
await Promise.all([output, errors])
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${stderr}`)
return exitCode
},
stderr: () => stderr,
async [Symbol.asyncDispose]() {
if (disposed) return
disposed = true
if (child.exitCode === null) child.kill("SIGKILL")
await child.exited
await Promise.all([output, errors])
},
}
}
function parseMessage(line: string): JsonRpcMessage {
const message: unknown = JSON.parse(line)
if (!isJsonRpcMessage(message)) throw new Error(`Invalid ACP message: ${line}`)
return message
}
function isJsonRpcMessage(message: unknown): message is JsonRpcMessage {
return !!message && typeof message === "object" && !Array.isArray(message)
}
function isResponse<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcResponse<T> {
return message.jsonrpc === "2.0" && typeof message.id === "number" && !("method" in message)
}
function isNotification<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcNotification<T> {
return message.jsonrpc === "2.0" && typeof message.method === "string" && !("id" in message)
}
function asError(error: unknown) {
return error instanceof Error ? error : new Error(String(error))
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string) {
let timer: ReturnType<typeof setTimeout> | undefined
return Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
}),
]).finally(() => clearTimeout(timer))
}
function completion(text: string) {
const chunks = [
{ choices: [{ delta: { role: "assistant" }, finish_reason: null }], usage: null },
{ choices: [{ delta: { content: text }, finish_reason: null }], usage: null },
{ choices: [{ delta: {}, finish_reason: "stop" }], 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`
}
+292
View File
@@ -0,0 +1,292 @@
import { resolve } from "node:path"
import { describe, expect, test } from "bun:test"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
toLocations,
toToolKind,
} from "../../src/acp/tool"
describe("acp tools", () => {
test("maps OpenCode tool ids to ACP tool kinds", () => {
expect(toToolKind("bash")).toBe("execute")
expect(toToolKind("shell")).toBe("execute")
expect(toToolKind("webfetch")).toBe("fetch")
expect(toToolKind("edit")).toBe("edit")
expect(toToolKind("apply_patch")).toBe("edit")
expect(toToolKind("patch")).toBe("edit")
expect(toToolKind("write")).toBe("edit")
expect(toToolKind("grep")).toBe("search")
expect(toToolKind("glob")).toBe("search")
expect(toToolKind("context7_resolve_library_id")).toBe("search")
expect(toToolKind("context7_get_library_docs")).toBe("search")
expect(toToolKind("read")).toBe("read")
expect(toToolKind("task")).toBe("think")
expect(toToolKind("custom_tool")).toBe("other")
})
test("extracts file locations from tool input", () => {
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
{ path: "/tmp/outside" },
])
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "subdir") },
])
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
test("builds completed content with text and image attachments", () => {
const image = Buffer.from("image-data").toString("base64")
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "edit",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
content: [
{ type: "text", text: "edited /tmp/file.ts" },
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
],
structured: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "text", text: "edited /tmp/file.ts" },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
{
type: "content",
content: { type: "image", mimeType: "image/png", data: image },
},
])
})
test("omits edit diffs when normalized content does not contain one", () => {
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "write",
input: {
filePath: "/tmp/file.ts",
content: "created",
},
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
structured: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "text", text: "wrote /tmp/file.ts" },
},
])
})
test("uses clean structured read content instead of model-facing formatting", () => {
expect(
completedToolUpdate({
toolCallId: "tool-read",
toolName: "read",
input: { path: "/tmp/file.ts" },
content: [{ type: "text", text: "<content>1: first\n2: second</content>" }],
structured: {
type: "text-page",
content: "first\nsecond",
mime: "text/plain",
offset: 1,
truncated: false,
},
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
expect(
completedToolUpdate({
toolCallId: "tool-list",
toolName: "read",
input: { path: "/tmp" },
content: [],
structured: {
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
},
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
})
test("sends completed tool calls as partial updates", () => {
expect(
pendingToolCall({
toolCallId: "tool-1",
toolName: "edit",
state: {
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
},
}),
).toMatchObject({
toolCallId: "tool-1",
status: "pending",
kind: "edit",
locations: [{ path: "/tmp/file.ts" }],
rawInput: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
})
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "edit",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
content: [{ type: "text", text: "Edit applied successfully." }],
structured: { output: "Edit applied successfully." },
}),
).toEqual({
toolCallId: "tool-1",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "Edit applied successfully." },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
],
rawOutput: {
structured: { output: "Edit applied successfully." },
},
})
})
test("builds running tool updates with normalized content", () => {
expect(
runningToolUpdate({
toolCallId: "call",
toolName: "read",
state: { input: { filePath: "/tmp/a" } },
content: [{ type: "text", text: "done" }],
}),
).toMatchObject({
toolCallId: "call",
status: "in_progress",
content: [{ type: "content", content: { type: "text", text: "done" } }],
})
})
test("builds completed raw output with structured data and optional result", () => {
const attachments = [
{
type: "file",
mime: "image/jpeg",
name: "photo.jpg",
uri: "data:image/jpeg;base64,AAAA",
},
]
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [],
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
}).rawOutput,
).toEqual({
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
})
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [],
structured: { output: "done" },
}).rawOutput,
).toEqual({ structured: { output: "done" } })
})
test("extracts image attachments only from data URLs", () => {
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [
{ type: "file", mime: "image/webp", uri: "data:image/webp;charset=utf-8;base64,AAAA" },
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
],
structured: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
},
])
})
test("builds failed tool updates", () => {
expect(
errorToolUpdate({
toolCallId: "call",
toolName: "read",
input: { filePath: "/tmp/a" },
content: [{ type: "text", text: "partial output" }],
structured: { path: "/tmp/a" },
error: "failed",
}),
).toEqual({
toolCallId: "call",
status: "failed",
kind: "read",
title: "read",
locations: [{ path: "/tmp/a" }],
rawInput: { filePath: "/tmp/a" },
content: [
{ type: "content", content: { type: "text", text: "partial output" } },
{ type: "content", content: { type: "text", text: "failed" } },
],
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
})
})
})
+7 -2
View File
@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { expect, test } from "bun:test"
import path from "path"
@@ -131,11 +131,16 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }
})
}),
)
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true },
})
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
} finally {
await Bun.$`rm -rf ${directory}`
@@ -5,6 +5,16 @@ import path from "node:path"
const root = path.resolve(import.meta.dir, "../../..")
describe("CLI frontend import boundaries", () => {
test("does not import Core directly", async () => {
const glob = new Bun.Glob("{src,test}/**/*.{ts,tsx}")
const imports: string[] = []
for await (const file of glob.scan({ cwd: path.join(root, "packages/cli") })) {
const source = await Bun.file(path.join(root, "packages/cli", file)).text()
if (/["']@opencode-ai\/core(?:\/[^"']*)?["']/.test(source)) imports.push(file)
}
expect(imports).toEqual([])
})
test("exposes only the intentional package entrypoints", async () => {
const run = await import("@opencode-ai/cli/run")
const mini = await import("@opencode-ai/tui/mini")
+4 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OPENCODE_VERSION } from "../src/version"
import path from "node:path"
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
@@ -31,14 +31,14 @@ describe("mini command", () => {
const initial = Bun.serve({
port: 0,
fetch() {
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
},
})
const replacement = Bun.serve({
port: 0,
fetch(request) {
authorization.push(request.headers.get("authorization"))
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
},
})
const controller = new AbortController()
@@ -145,7 +145,7 @@ describe("mini command", () => {
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health")
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/model") {
+81 -1
View File
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode, type EventSubscribeOutput, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import {
OpenCode,
type EventSubscribeOutput,
type SessionMessageAssistantTool,
type SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
type V2Event = EventSubscribeOutput
@@ -162,10 +167,13 @@ async function run(input: {
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inputID: string) => SessionMessageInfo[]
wait?: () => Promise<void>
}) {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
let wake: (() => void) | undefined
const wait = Promise.withResolvers<void>()
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
while (true) {
const value = values.shift()
@@ -175,6 +183,7 @@ async function run(input: {
})
continue
}
if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0)
yield value
}
})()
@@ -193,8 +202,19 @@ async function run(input: {
}) as never,
)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: input.messages?.(promptID) ?? [
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
],
cursor: {},
}),
)
spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
promptID = messageID
values.push(...input.turn(messageID))
wake?.()
wake = undefined
@@ -244,6 +264,63 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("uses session.wait then reconciles projected output without a terminal event", async () => {
const idle = Promise.withResolvers<void>()
let done = false
const task = capture({
format: "json",
turn: (messageID) => [prompted(messageID)],
wait: () => idle.promise,
messages: (messageID) => [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "projected answer" }],
finish: "stop",
time: { created: 2, completed: 3 },
},
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
],
}).then((output) => {
done = true
return output
})
await Bun.sleep(0)
await Bun.sleep(0)
expect(done).toBe(false)
idle.resolve()
const output = await task
expect(
output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([expect.objectContaining({ type: "text", part: expect.objectContaining({ text: "projected answer" }) })])
})
test("reports an observed execution failure before prompt promotion", async () => {
const output = await capture({
format: "json",
turn: () => [executionFailed("instructions unavailable")],
messages: () => [],
})
expect(
output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([
expect.objectContaining({
type: "error",
error: { type: "provider.transport", message: "instructions unavailable" },
}),
])
})
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
const sdk = await run({
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
@@ -307,6 +384,9 @@ describe("runNonInteractivePrompt", () => {
}),
])
expect(output.stderr).toBe("")
const sdk = await run({ compatibility: "v1", turn: (messageID) => [prompted(messageID), settled()] })
expect(sdk.session.wait).not.toHaveBeenCalled()
expect(sdk.message.list).not.toHaveBeenCalled()
})
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
+4 -4
View File
@@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Scope } from "effect"
import fs from "node:fs/promises"
@@ -17,7 +17,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
fetch() {
return Response.json({
healthy: true,
version: InstallationVersion,
version: OPENCODE_VERSION,
pid: process.pid,
})
},
@@ -33,7 +33,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
registration,
JSON.stringify({
id,
version: InstallationVersion,
version: OPENCODE_VERSION,
url: server.url.toString(),
pid: process.pid,
}),
+29 -92
View File
@@ -1,18 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
import { Effect, Schedule, Schema } from "effect"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -20,6 +11,7 @@ import { ServiceConfig } from "../src/services/service-config"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
@@ -43,17 +35,35 @@ test("local channel stores service config with the local service filename", asyn
}
})
test("service filenames isolate installation channels", () => {
test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("next")).toBe("service.json")
expect(ServiceConfig.filename("local")).toBe("service-local.json")
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("preview-b"))
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("latest"))
expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json")
expect(ServiceConfig.filename("preview/a")).toBe("service-preview-a.json")
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234.2", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false)
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
})
test("service config migrates from the hashed channel filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
const target = path.join(root, ServiceConfig.filename("preview-a"))
try {
await fs.writeFile(legacy, JSON.stringify({ hostname: "127.0.0.2", port: 4098 }))
await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(target).json()).toEqual({ hostname: "127.0.0.2", port: 4098 })
await fs.writeFile(target, JSON.stringify({ port: 4099 }))
await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(target).json()).toEqual({ port: 4099 })
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("preview registration migration never moves stable discovery", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-"))
const legacy = path.join(root, "service.json")
@@ -185,31 +195,6 @@ test("concurrent service processes elect one server", async () => {
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
const sessionID = SessionV2.ID.make("ses_service_recovery")
await withDatabase(
database,
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make(root), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "recovery",
directory: root,
title: "recovery",
version: "test",
time_suspended: Date.now(),
})
.run()
.pipe(Effect.orDie)
}),
)
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const registration = path.join(root, "state", "opencode", "service-local.json")
const port = await availablePort()
@@ -262,20 +247,6 @@ test("concurrent service processes elect one server", async () => {
contender.kill("SIGTERM")
await contender.exited
}
expect(
await withDatabase(
database,
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ timeSuspended: SessionTable.time_suspended })
.from(SessionTable)
.get()
.pipe(Effect.orDie)
}),
),
).toEqual({ timeSuspended: null })
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await winner?.exited
expect(await Bun.file(registration).exists()).toBe(false)
@@ -361,7 +332,7 @@ test("unresponsive managed port occupancy reports a bounded conflict", async ()
)
const stale = {
id: "stale",
version: InstallationVersion,
version: OPENCODE_VERSION,
url: "http://127.0.0.1:1",
pid: process.pid,
password: "stale",
@@ -397,7 +368,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 })
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }, { status: 503 })
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
@@ -409,7 +380,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
registration,
JSON.stringify({
id: "stale",
version: InstallationVersion,
version: OPENCODE_VERSION,
url: "http://127.0.0.1:1",
pid: 2_147_483_647,
password: "stale",
@@ -426,7 +397,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
await Bun.sleep(8_000)
const info = {
id: "incumbent",
version: InstallationVersion,
version: OPENCODE_VERSION,
url: `http://127.0.0.1:${listener.port}`,
pid: process.pid,
password: "incumbent",
@@ -510,40 +481,6 @@ test("a failed service stays registered and owns the selected port until stopped
}
}, 30_000)
function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) {
return Effect.runPromise(effect.pipe(Effect.provide(Database.layer({ path: file })), Effect.scoped))
}
function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
return withDatabase(
file,
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ id: EventTable.id, sessionID: EventTable.aggregate_id, type: EventTable.type })
.from(EventTable)
.all()
.pipe(
Effect.orDie,
Effect.map((rows) =>
rows.filter(
(row) =>
row.sessionID === sessionID &&
row.type ===
EventV2.versionedType(
SessionEvent.Execution.Started.type,
SessionEvent.Execution.Started.durable.version,
),
),
),
Effect.filterOrFail((rows) => rows.length > 0),
Effect.map((rows) => rows.length),
Effect.retry(Schedule.max([Schedule.spaced("50 millis"), Schedule.recurs(200)])),
)
}),
)
}
async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
for (let attempt = 0; attempt < 400; attempt++) {
const value = await Bun.file(file)
+1
View File
@@ -4,6 +4,7 @@
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
- State model-visible diagnostics, logs, tool descriptions, and instructions directly. The execution context is already clear; do not repeat `Code Mode` or `CodeMode` unless the distinction is necessary.
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
## OpenAPI
+49 -17
View File
@@ -29,14 +29,16 @@ ultimate source of truth.
## Values and literals
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, and URLSearchParams.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators.
- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
`undefined` are no-ops, while arrays are rejected.
- [x] Template literals with interpolation.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Symbol primitive values and symbol-keyed properties.
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
@@ -56,7 +58,9 @@ ultimate source of truth.
- [ ] Hoist function declarations accepted directly in switch cases.
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
- [x] Object destructuring from arrays, such as `const { length } = values`.
- [x] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams.
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators, including stepwise elisions/rest and `IteratorClose` on early completion
or binding/default failure.
## Statements and control flow
@@ -64,13 +68,18 @@ ultimate source of truth.
- [x] `if`/`else` and conditional expressions.
- [x] `switch`, including default clauses and fallthrough.
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, and URLSearchParams.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, and tool references.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
- [x] Labeled statements, labeled `break`, and labeled `continue`.
- [ ] `for await...of` and async iteration.
- [x] `for await...of` over the supported synchronous collections and custom iterator objects using
`Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop
completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in
JavaScript; only their `next()` results are awaited. Confined sync and async generators are iterable here.
## Functions and callbacks
@@ -99,7 +108,27 @@ ultimate source of truth.
- [ ] User-defined constructor calls.
- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions.
- [ ] Classes and private fields.
- [ ] Generator functions and `yield`.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
`next(value)`, `return(value)`, `throw(value)`, exhaustion, promise adoption, async request ordering,
`try`/`catch`/`finally`, and sync/async iterator symbols. Async `yield*` awaits values while adapting a sync
iterator but preserves values supplied by a manually implemented async iterator. Generator values are opaque
runtime references.
- [x] Synchronous generators and custom synchronous iterators are consumed stepwise by array/argument spread, array
destructuring, `Array.from`, Map/Set/URLSearchParams construction, `Object.fromEntries`, Object/Map `groupBy`,
Promise combinators, `AggregateError`, and `Math.sumPrecise`. Mapper/grouping callbacks interleave with iterator
steps; synchronous consumers preserve yielded promise objects rather than awaiting them. Async generators are
rejected by every synchronous consumer.
- [x] Synchronous iterator acquisition and result validation follow `IteratorClose` boundaries: consumer errors and
intentional early stops invoke `return()`, acquisition/`next()` failures do not, and an original consumer error
wins over a cleanup failure. Async iterator consumption remains limited to `for await...of` and async `yield*`.
- [x] Portable generator protocol coverage is adapted from pinned Test262 cases for suspended-start, suspended-yield,
and completed states; sync and async `next`/`return`/`throw`; finally yields and completion overrides; rejected
yielded promises; mixed async request queues; sync and async `yield*` forwarding; malformed methods/results;
and declaration, expression, and object-method forms with closure and parameter behavior. The adapted suite
deliberately skips Test262 variants whose observation mechanism requires unsupported getter definitions,
proxies, prototype inspection or mutation, non-arrow `this`, classes, or arbitrary symbols. It also skips tests
asserting exact promise reaction-turn counts beyond the observable ordering guarantee documented below. These
are interpreter-surface boundaries, not claims that the corresponding full Test262 families pass unchanged.
## Expressions and operators
@@ -125,8 +154,8 @@ ultimate source of truth.
- [x] Tool calls start eagerly and return supervised, run-once CodeMode promises.
- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
- [x] `Promise.resolve` and `Promise.reject`.
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing
promises and plain values.
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over finite collections, custom synchronous
iterators, and synchronous generators containing promises and plain values.
- [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings.
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
@@ -170,14 +199,16 @@ ultimate source of truth.
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
primitive wrapper objects (`Object(1)`) are rejected explicitly.
- [x] Computed property names and object spread.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
synchronous iterator support for `fromEntries`.
- [x] `Object.keys` over arrays and tool references.
- [x] Object identity is preserved by in-CodeMode Object helpers.
- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked.
- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over supported collection iterables, with string-key coercion and null-prototype results.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
and null-prototype results.
## Arrays
@@ -185,7 +216,7 @@ ultimate source of truth.
array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, and JSON handle holes like
JavaScript, and host results normalize holes to `null`.
- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with
`(value, index)` arguments.
`(value, index)` arguments and stepwise synchronous iterator consumption.
- [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`.
- [x] Searching/tests: `find`, `findIndex`, `findLast`, `findLastIndex`, `some`, `every`, `includes`, `indexOf`, and
`lastIndexOf`.
@@ -240,7 +271,8 @@ ultimate source of truth.
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
and unknown `Promise` statics keep their descriptive error.
- [x] `Math.sumPrecise` over supported collection iterables, rejecting non-number elements without coercion.
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
elements without coercion.
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
## JSON and console
@@ -292,10 +324,10 @@ ultimate source of truth.
## Map and Set
- [x] Static `Map.groupBy` over supported collection iterables, preserving key identity.
- [x] `new Map()` from entry arrays or another Map.
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
- [x] `new Map()` from synchronous iterables of entries.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] `new Set()` from arrays, strings, or another Set.
- [x] `new Set()` from synchronous iterables.
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
@@ -311,7 +343,7 @@ ultimate source of truth.
- [x] Readable URL fields: `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`,
`pathname`, `search`, and `hash`.
- [x] Writable URL fields except `origin`.
- [x] `new URLSearchParams()` from query strings, data objects, pairs, Maps, and URLSearchParams.
- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams.
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
`entries`, `toString`, and `size`.
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
@@ -321,7 +353,7 @@ ultimate source of truth.
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
or without `new`.
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
an all-rejected `Promise.any`.
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
+26 -13
View File
@@ -1,9 +1,10 @@
import { Effect } from "effect"
import type { Diagnostic } from "../codemode.js"
import { ToolError } from "../tool-error.js"
import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
import { containsRuntimeReference } from "./references.js"
import { spreadItems } from "../stdlib/collections.js"
import { type SyncIteratorRunner } from "./iterator.js"
import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js"
export const normalizeError = (error: unknown): Diagnostic => {
@@ -79,15 +80,27 @@ export const caughtErrorValue = (thrown: unknown): unknown => {
return createErrorValue(name, normalizeError(thrown).message)
}
export const constructErrorValue = (name: string, args: Array<unknown>, node: AstNode): SafeObject => {
if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
const errors = spreadItems(args[0])
if (errors === undefined) {
throw new InterpreterRuntimeError(
"new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).",
node,
).as("TypeError")
}
// Error values must not alias caller-owned arrays.
return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1]))
}
export const constructErrorValue = (name: string, args: Array<unknown>): SafeObject =>
createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
export const constructAggregateErrorValue = <R>(
runner: SyncIteratorRunner<R>,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<SafeObject, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new AggregateError(...) expects a synchronous iterable of errors.", node).as(
"TypeError",
)
}
const errors: Array<unknown> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
return createAggregateErrorValue(errors, args[1] === undefined ? "" : coerceToString(args[1]))
}
errors.push(step.value)
}
})
@@ -0,0 +1,21 @@
import { Effect, Exit } from "effect"
import type { AstNode } from "./model.js"
export type IteratorCursor<R> = {
readonly next: Effect.Effect<{ readonly done: boolean; readonly value: unknown }, unknown, R>
readonly close: Effect.Effect<void, unknown, R>
}
export type SyncIteratorRunner<R> = {
readonly syncIterator: (value: unknown, node: AstNode) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
}
export const preserveConsumerError = <A, R>(
cursor: IteratorCursor<R>,
effect: Effect.Effect<A, unknown, R>,
): Effect.Effect<A, unknown, R> =>
Effect.flatMap(Effect.exit(effect), (exit) =>
Exit.isSuccess(exit)
? Effect.succeed(exit.value)
: Effect.andThen(Effect.exit(cursor.close), Effect.failCause(exit.cause)),
)
+65 -49
View File
@@ -2,6 +2,7 @@ import { Effect } from "effect"
import {
type AstNode,
CodeModeFunction,
CodeModeGenerator,
CoercionFunction,
ErrorConstructorReference,
GlobalMethodReference,
@@ -33,6 +34,7 @@ import { invokeRegExpMethod, invokeRegExpStatic, matchToValue, toHostRegex } fro
import { invokeStringStatic } from "../stdlib/string.js"
import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js"
export type CallbackRunner<R> = {
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@@ -126,7 +128,7 @@ export const invokeIntrinsic = <R>(
if (ref.receiver instanceof CodeModeURLSearchParams) {
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available.`, node)
}
const coerceNumericArgument = <R>(
@@ -157,8 +159,7 @@ const coerceNumericArgument = <R>(
}
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
if (ref.namespace === "console")
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available.`, node)
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
@@ -168,9 +169,9 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
if (ref.namespace === "RegExp") return invokeRegExpStatic(ref.name, args, node)
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
}
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
}
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
@@ -348,7 +349,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
}
default:
throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`String method '${name}' is not available.`, node)
}
return boundedData(result, `String.${name} result`)
}
@@ -361,19 +362,12 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
return Array.isArray(args[0])
case "of":
return [...args]
case "from":
return arrayFromItems(args[0], node)
default:
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Array.${name} is not available.`, node)
}
}
const arrayFromItems = (source: unknown, node: AstNode): Array<unknown> => {
if (source instanceof CodeModeMap) return Array.from(source.map.entries(), ([key, item]) => [key, item])
if (source instanceof CodeModeSet) return Array.from(source.set.values())
if (source instanceof CodeModeURLSearchParams) {
return Array.from(source.params.entries(), ([key, value]) => [key, value])
}
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
if (source instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
@@ -381,15 +375,16 @@ const arrayFromItems = (source: unknown, node: AstNode): Array<unknown> => {
"InvalidDataValue",
)
}
if (typeof source === "string") return Array.from(source)
if (Array.isArray(source)) return [...source]
if (
source !== null &&
typeof source === "object" &&
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
typeof (source as { length?: unknown }).length === "number"
) {
return Array.from(source as ArrayLike<unknown>)
const length = (source as { length: number }).length
const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length)
if (normalized > 4_294_967_295) throw new RangeError("Invalid array length")
return { length: normalized, source }
}
throw new InterpreterRuntimeError(
"Array.from expects an array, string, Map, Set, or array-like value.",
@@ -399,24 +394,42 @@ const arrayFromItems = (source: unknown, node: AstNode): Array<unknown> => {
}
export const invokeArrayFrom = <R>(
runner: CallbackRunner<R>,
runner: CallbackRunner<R> & SyncIteratorRunner<R>,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const items = arrayFromItems(args[0], node)
if (args.length < 2 || args[1] === undefined) return Effect.succeed(items)
const apply = applyCollectionCallback(runner, args[1], "Array.from", node)
const source = args[0]
const apply =
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from", node)
return Effect.gen(function* () {
const values: Array<unknown> = []
for (let index = 0; index < items.length; index += 1) {
values.push(yield* apply([items[index], index]))
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
if (source instanceof CodeModeGenerator) {
throw new InterpreterRuntimeError("Array.from expects a synchronous iterable or array-like value.", node).as(
"TypeError",
)
}
const arrayLike = arrayLikeSource(source, node)
const values: Array<unknown> = []
for (let index = 0; index < arrayLike.length; index += 1) {
const item = Reflect.get(arrayLike.source, index)
values.push(apply === undefined ? item : yield* apply([item, index]))
}
return values
}
const values: Array<unknown> = []
let index = 0
while (true) {
const step = yield* cursor.next
if (step.done) return values
values.push(apply === undefined ? step.value : yield* preserveConsumerError(cursor, apply([step.value, index])))
index += 1
}
return values
})
}
export const invokeGroupBy = <R>(
runner: CallbackRunner<R>,
runner: CallbackRunner<R> & SyncIteratorRunner<R>,
namespace: "Map" | "Object",
args: Array<unknown>,
node: AstNode,
@@ -426,47 +439,50 @@ export const invokeGroupBy = <R>(
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
}
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
const items = supportedIterableItems(source)
if (items === undefined) {
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
}
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
}
if (namespace === "Map") {
const result = new CodeModeMap()
let index = 0
for (const item of items) {
const key = yield* apply([item, index])
while (true) {
const step = yield* cursor.next
if (step.done) return result
const item = step.value
const key = yield* preserveConsumerError(cursor, apply([item, index]))
const group = result.map.get(key)
if (group === undefined) result.map.set(key, [item])
else (group as Array<unknown>).push(item)
index += 1
}
return result
}
const result: SafeObject = Object.create(null) as SafeObject
let index = 0
for (const item of items) {
const key = yield* coerceGroupByPropertyKey(runner, yield* apply([item, index]), node)
while (true) {
const step = yield* cursor.next
if (step.done) return result
const item = step.value
const key = yield* preserveConsumerError(
cursor,
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
)
if (isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
return yield* preserveConsumerError(
cursor,
Effect.fail(new InterpreterRuntimeError(`Property '${key}' is not available.`, node)),
)
}
const group = result[key]
if (group === undefined) result[key] = [item]
else (group as Array<unknown>).push(item)
index += 1
}
return result
})
}
const supportedIterableItems = (source: unknown): Iterable<unknown> | undefined => {
if (Array.isArray(source) || typeof source === "string") return source
if (source instanceof CodeModeMap) return source.map.entries()
if (source instanceof CodeModeSet) return source.set.values()
if (source instanceof CodeModeURLSearchParams) return source.params.entries()
}
const coerceGroupByPropertyKey = <R>(
runner: CallbackRunner<R>,
value: unknown,
@@ -619,7 +635,7 @@ const invokeMapMethod = <R>(
})
}
default:
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Map method '${name}' is not available.`, node)
}
}
@@ -666,7 +682,7 @@ const invokeSetMethod = <R>(
case "isDisjointFrom":
return invokeSetOperation(runner, target, name, args[0], node)
default:
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Set method '${name}' is not available.`, node)
}
}
@@ -861,7 +877,7 @@ const invokeURLSearchParamsMethod = <R>(
})
}
default:
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available.`, node)
}
}
@@ -1104,7 +1120,7 @@ const invokeArrayMethod = <R>(
}
return -1
}
throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Array method '${name}' is not available.`, node)
})
}
+34 -2
View File
@@ -1,3 +1,4 @@
import type { Effect } from "effect"
import type { SafeObject } from "../tool-runtime.js"
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
@@ -36,7 +37,7 @@ export type StatementResult =
export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
key: string | number
key: PropertyKey
}
export class CodeModeFunction {
@@ -45,6 +46,27 @@ export class CodeModeFunction {
readonly body: AstNode,
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
readonly async: boolean,
readonly generator: boolean,
) {}
}
export type GeneratorRequestKind = "next" | "return" | "throw"
export class CodeModeGenerator {
constructor(
readonly asynchronous: boolean,
readonly request: (
kind: GeneratorRequestKind,
value: unknown,
node: AstNode,
) => Effect.Effect<unknown, unknown, unknown>,
) {}
}
export class GeneratorMethodReference {
constructor(
readonly generator: CodeModeGenerator,
readonly kind: GeneratorRequestKind | "iterator",
) {}
}
@@ -61,6 +83,12 @@ export class ComputedValue {
export class PromiseNamespace {}
export class SymbolNamespace {}
export const AsyncIteratorSymbol: unique symbol = Symbol("codemode.async-iterator")
export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
export class PromiseMethodReference {
@@ -122,6 +150,10 @@ export class ProgramThrow {
constructor(readonly value: unknown) {}
}
export class GeneratorReturn {
constructor(readonly value: unknown) {}
}
export class ErrorConstructorReference {
constructor(readonly name: string) {}
}
@@ -166,7 +198,7 @@ export class InterpreterRuntimeError extends Error {
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`,
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
+73 -78
View File
@@ -13,9 +13,9 @@ import {
import { caughtErrorValue, normalizeError } from "./errors.js"
import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js"
import { typeofValue } from "./references.js"
import { spreadItems } from "../stdlib/collections.js"
import { createAggregateErrorValue } from "../stdlib/value.js"
import { CodeModePromise } from "../values.js"
import type { SyncIteratorRunner } from "./iterator.js"
// Observation only controls rejection reporting; program completion interrupts all promise work.
export class PromiseRuntime<R> {
@@ -64,6 +64,10 @@ export class PromiseRuntime<R> {
return Fiber.await(promise.fiber)
}
fork(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<void, never, R> {
return Effect.asVoid(Effect.forkIn(effect, this.scope, { startImmediately: true }))
}
diagnostics(): Array<Diagnostic> {
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
}
@@ -84,7 +88,7 @@ export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
export const invokePromiseMethod = <R>(
runner: CallbackRunner<R>,
runner: CallbackRunner<R> & SyncIteratorRunner<R>,
promises: PromiseRuntime<R>,
ref: PromiseMethodReference,
args: Array<unknown>,
@@ -98,79 +102,69 @@ export const invokePromiseMethod = <R>(
return promises.create(Effect.fail(new ProgramThrow(args[0])))
}
const spread = spreadItems(args[0])
if (spread === undefined) {
return promises.create(
Effect.fail(
new InterpreterRuntimeError(
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
return promises.create(
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
throw new InterpreterRuntimeError(
`Promise.${ref.name} expects an array or other synchronous iterable.`,
node,
).as("TypeError"),
),
)
}
const items = Array.from(spread)
).as("TypeError")
}
const items: Array<unknown> = []
while (true) {
const step = yield* cursor.next
if (step.done) break
items.push(step.value)
if (step.value instanceof CodeModePromise) promises.markObserved(step.value)
}
for (const item of items) {
if (item instanceof CodeModePromise) promises.markObserved(item)
}
switch (ref.name) {
case "all": {
const observations = items.map((item) =>
item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
)
return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
}
case "allSettled": {
const observations = items.map((item) =>
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
)
return promises.create(
settleAfterTurn(
Effect.gen(function* () {
const outcomes: Array<unknown> = []
for (const observation of observations) {
const exit = yield* observation
if (Exit.isSuccess(exit)) {
outcomes.push(
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
)
continue
}
if (Cause.hasInterruptsOnly(exit.cause)) {
// Teardown interruption is not a program-level rejection.
return yield* Effect.failCause(exit.cause)
}
outcomes.push(
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(Cause.squash(exit.cause)),
}),
)
}
return outcomes
}),
),
)
}
case "race": {
if (items.length === 0) {
return promises.create(
Effect.fail(
new InterpreterRuntimeError(
"Promise.race([]) would never settle; provide at least one promise or value.",
node,
if (ref.name === "all") {
return yield* settleAfterTurn(
Effect.all(
items.map((item) =>
item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
),
{ concurrency: "unbounded" },
),
)
}
if (ref.name === "allSettled") {
const outcomes: Array<unknown> = []
for (const item of items) {
const exit = item instanceof CodeModePromise ? yield* promises.await(item) : Exit.succeed(item)
if (Exit.isSuccess(exit)) {
outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }))
continue
}
if (Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
outcomes.push(
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(Cause.squash(exit.cause)),
}),
)
}
yield* Effect.yieldNow
return outcomes
}
if (ref.name === "race") {
if (items.length === 0) {
throw new InterpreterRuntimeError(
"Promise.race([]) would never settle; provide at least one promise or value.",
node,
)
}
return yield* settleAfterTurn(
Effect.flatten(
Effect.raceAll(
items.map((item) =>
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
),
),
),
)
}
const observations = items.map((item) =>
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
)
return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
}
case "any": {
const flipped = items.map((item) =>
item instanceof CodeModePromise
? Effect.flatMap(promises.await(item), (exit) => {
@@ -180,17 +174,18 @@ export const invokePromiseMethod = <R>(
})
: Effect.fail(new PromiseAnyFulfilled(item)),
)
const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe(
Effect.flatMap((reasons) =>
Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))),
),
Effect.catch((error) =>
error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error),
return yield* settleAfterTurn(
Effect.all(flipped, { concurrency: "unbounded" }).pipe(
Effect.flatMap((reasons) =>
Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))),
),
Effect.catch((error) =>
error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error),
),
),
)
return promises.create(settleAfterTurn(body))
}
}
}),
)
}
export const invokePromiseInstanceMethod = <R>(
@@ -1,10 +1,12 @@
import {
type AstNode,
CodeModeFunction,
CodeModeGenerator,
CoercionFunction,
ErrorConstructorReference,
GlobalMethodReference,
GlobalNamespace,
GeneratorMethodReference,
InterpreterRuntimeError,
IntrinsicReference,
JsonMethodReference,
@@ -13,6 +15,7 @@ import {
PromiseMethodReference,
PromiseNamespace,
SearchFunction,
SymbolNamespace,
UriFunction,
} from "./model.js"
import { ToolReference } from "../tool-runtime.js"
@@ -20,6 +23,8 @@ import { isCodeModeValue, CodeModePromise } from "../values.js"
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof CodeModeFunction ||
value instanceof CodeModeGenerator ||
value instanceof GeneratorMethodReference ||
value instanceof ToolReference ||
value instanceof IntrinsicReference ||
value instanceof GlobalNamespace ||
@@ -34,6 +39,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace ||
isCodeModeValue(value)
function* childValues(value: object): Generator<unknown> {
@@ -105,6 +111,7 @@ export const rejectCircularInsertion = (container: object, value: unknown, label
export const typeofValue = (value: unknown): string => {
if (
value instanceof CodeModeFunction ||
value instanceof GeneratorMethodReference ||
value instanceof CoercionFunction ||
value instanceof IntrinsicReference ||
value instanceof GlobalMethodReference ||
@@ -113,7 +120,8 @@ export const typeofValue = (value: unknown): string => {
value instanceof PromiseInstanceMethodReference ||
value instanceof PromiseNamespace ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace
)
return "function"
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
+807 -240
View File
@@ -1,15 +1,20 @@
import { Cause, Effect } from "effect"
import { Cause, Deferred, Effect, Exit } from "effect"
import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
import {
type AstNode,
AsyncIteratorSymbol,
asNode,
type Binding,
CodeModeFunction,
CodeModeGenerator,
CoercionFunction,
ComputedValue,
ErrorConstructorReference,
GlobalMethodReference,
GlobalNamespace,
GeneratorMethodReference,
GeneratorReturn,
type GeneratorRequestKind,
type GlobalNamespaceName,
getArray,
getBoolean,
@@ -19,6 +24,8 @@ import {
IntrinsicReference,
InterpreterRuntimeError,
isRecord,
IteratorSymbol,
IteratorSymbols,
JsonMethodReference,
type MemberReference,
OptionalShortCircuit,
@@ -30,12 +37,12 @@ import {
ProgramThrow,
type ProgramNode,
SearchFunction,
SymbolNamespace,
type StatementResult,
supportedSyntaxMessage,
unsupportedSyntax,
UriFunction,
} from "./model.js"
import { caughtErrorValue, constructErrorValue } from "./errors.js"
import { caughtErrorValue, constructAggregateErrorValue, constructErrorValue } from "./errors.js"
import {
arrayStatics,
type CallbackRunner,
@@ -44,6 +51,7 @@ import {
invokeGroupBy,
invokeIntrinsic,
} from "./methods.js"
import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js"
import {
constructPromise,
invokePromiseInstanceMethod,
@@ -53,13 +61,13 @@ import {
} from "./promises.js"
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { ScopeStack } from "./scope.js"
import { arrayMethods, mapMethods, mapStatics, setMethods, spreadItems } from "../stdlib/collections.js"
import { arrayMethods, mapMethods, mapStatics, setMethods } from "../stdlib/collections.js"
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
import { dateMethods, dateStatics } from "../stdlib/date.js"
import { invokeJsonMethod, jsonStatics, type JsonMethodName } from "../stdlib/json.js"
import { mathConstants, mathMethods } from "../stdlib/math.js"
import { invokeMathSumPrecise, mathConstants, mathMethods } from "../stdlib/math.js"
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
import { invokeObjectFromEntries, objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
import { promiseStatics } from "../stdlib/promise.js"
import {
escapeRegexHint,
@@ -166,7 +174,7 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
return false
}
throw new InterpreterRuntimeError(
"The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.",
"The right-hand side of 'instanceof' must be a supported constructor: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.",
node,
)
}
@@ -211,6 +219,53 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
}
}
type CustomIterator = {
iterator: SafeObject | CodeModeGenerator
next: unknown
asynchronous: boolean
}
type OpaqueMemberReference =
| ToolReference
| PromiseMethodReference
| PromiseInstanceMethodReference
| IntrinsicReference
| GlobalMethodReference
| JsonMethodReference
| GeneratorMethodReference
const isOpaqueMemberReference = (value: unknown): value is OpaqueMemberReference =>
value instanceof ToolReference ||
value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference ||
value instanceof IntrinsicReference ||
value instanceof GlobalMethodReference ||
value instanceof JsonMethodReference ||
value instanceof GeneratorMethodReference
const copyIteratorSymbols = (source: object, target: object, consumed?: ReadonlySet<PropertyKey>): void => {
for (const symbol of IteratorSymbols) {
if (!consumed?.has(symbol) && Object.hasOwn(source, symbol))
Reflect.set(target, symbol, Reflect.get(source, symbol))
}
}
type GeneratorRequest = {
kind: GeneratorRequestKind
value: unknown
response: Deferred.Deferred<unknown, unknown>
}
type GeneratorState = {
started: boolean
completed: boolean
draining: boolean
active?: GeneratorRequest
pending: Array<GeneratorRequest>
pendingIndex: number
available?: Deferred.Deferred<void>
}
export class Interpreter<R> {
private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@@ -218,10 +273,13 @@ export class Interpreter<R> {
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
private readonly logs: Array<string>
private readonly promises: PromiseRuntime<R>
private readonly runner: CallbackRunner<R> = {
private generatorState?: GeneratorState
private generatorAsync = false
private readonly runner: CallbackRunner<R> & SyncIteratorRunner<R> = {
invokeFunction: (fn, args) => this.invokeFunction(fn, args),
invokeCallable: (callable, args, node) => this.invokeCallable(callable, args, node),
settlePromise: (promise) => this.settlePromise(promise),
syncIterator: (value, node) => this.syncIterator(value, node),
}
constructor(
@@ -241,6 +299,7 @@ export class Interpreter<R> {
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("search", { mutable: false, value: new SearchFunction() })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("Symbol", { mutable: false, value: new SymbolNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined })
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
@@ -392,19 +451,12 @@ export class Interpreter<R> {
}
private createFunction(node: AstNode): CodeModeFunction {
if (node.generator === true) {
throw new InterpreterRuntimeError(
"Generator functions are not supported in CodeMode.",
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
}
return new CodeModeFunction(
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
getNode(node, "body"),
this.scopes.capture(),
node.async === true,
node.generator === true,
)
}
@@ -454,11 +506,7 @@ export class Interpreter<R> {
return Effect.gen(function* () {
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
if (containsOpaqueReference(discriminant)) {
throw new InterpreterRuntimeError(
"Switch discriminants must be data values in CodeMode.",
node,
"InvalidDataValue",
)
throw new InterpreterRuntimeError("Switch discriminants must be data values.", node, "InvalidDataValue")
}
self.scopes.push()
return yield* Effect.gen(function* () {
@@ -474,11 +522,7 @@ export class Interpreter<R> {
}
const candidate = yield* self.evaluateExpression(test)
if (containsOpaqueReference(candidate)) {
throw new InterpreterRuntimeError(
"Switch case values must be data values in CodeMode.",
test,
"InvalidDataValue",
)
throw new InterpreterRuntimeError("Switch case values must be data values.", test, "InvalidDataValue")
}
if (candidate === discriminant) {
selected = index
@@ -635,10 +679,7 @@ export class Interpreter<R> {
node: AstNode,
labels?: ReadonlySet<string>,
): Effect.Effect<StatementResult, unknown, R> {
if (getBoolean(node, "await")) {
throw new InterpreterRuntimeError("for await...of is not supported.", node)
}
const awaiting = getBoolean(node, "await")
const left = getNode(node, "left")
const declared = loopDeclaration(left, "for...of")
if (declared?.lexical) this.scopes.push()
@@ -649,10 +690,20 @@ export class Interpreter<R> {
const right = yield* self.evaluateExpression(getNode(node, "right"))
const body = getNode(node, "body")
const iterable = spreadItems(right)
if (iterable === undefined) {
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
const iterator = yield* self.customIterator(right, node, awaiting)
const cursor = iterator === undefined ? yield* self.syncIterator(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw new InterpreterRuntimeError(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
node,
).as("TypeError")
}
const close = () =>
iterator
? self.closeIterator(iterator, node, awaiting)
: awaiting
? Effect.andThen(cursor?.close ?? Effect.void, Effect.yieldNow)
: (cursor?.close ?? Effect.void)
let assignment: AstNode | undefined
@@ -668,8 +719,8 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
}
for (const value of iterable) {
const result = yield* Effect.gen(function* () {
const evaluateBody = (value: unknown) =>
Effect.gen(function* () {
if (declared) {
self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
@@ -686,22 +737,38 @@ export class Interpreter<R> {
),
)
while (true) {
const current = iterator
? yield* self.nextIteratorResult(iterator, node, awaiting)
: yield* cursor?.next ?? Effect.fail(new InterpreterRuntimeError("Iterator is unavailable.", node))
const step = cursor && awaiting ? { done: current.done, value: yield* self.awaitValue(current.value) } : current
if (step.done) return { kind: "none" } satisfies StatementResult
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
if (!Exit.isSuccess(bodyExit)) {
// Process interruption must remain prompt; user cleanup cannot extend a timeout.
if (!Cause.hasInterruptsOnly(bodyExit.cause)) {
yield* Effect.exit(close())
}
return yield* Effect.failCause(bodyExit.cause)
}
const result = bodyExit.value
if (result.kind === "return") {
yield* close()
return result
}
if (result.kind === "break") {
yield* close()
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue") {
if (result.label !== undefined && !labels?.has(result.label)) return result
continue
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
yield* close()
return result
}
}
return { kind: "none" } satisfies StatementResult
}).pipe(
Effect.ensuring(
Effect.sync(() => {
@@ -711,6 +778,175 @@ export class Interpreter<R> {
)
}
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value)
}
private awaitAsyncFromSyncValue(
iterator: CustomIterator,
value: unknown,
node: AstNode,
closeOnRejection: boolean,
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
const settled = yield* Effect.exit(self.awaitValue(value))
if (Exit.isSuccess(settled)) return settled.value
if (closeOnRejection && !Cause.hasInterruptsOnly(settled.cause)) {
yield* Effect.exit(self.closeIterator(iterator, node, false))
}
return yield* Effect.failCause(settled.cause)
})
}
private syncIterator(value: unknown, node: AstNode) {
const iterator = Array.isArray(value)
? value[Symbol.iterator]()
: typeof value === "string"
? value[Symbol.iterator]()
: value instanceof CodeModeMap
? value.map.entries()
: value instanceof CodeModeSet
? value.set.values()
: value instanceof CodeModeURLSearchParams
? value.params.entries()
: undefined
if (iterator !== undefined) {
return Effect.succeed({
next: Effect.sync(() => {
const step = iterator.next()
return { done: Boolean(step.done), value: step.value }
}),
close: Effect.void,
})
}
const self = this
return Effect.map(this.customIterator(value, node, false), (iterator) =>
iterator === undefined
? undefined
: {
next: self.nextIteratorResult(iterator, node, false),
close: Effect.suspend(() => self.closeIterator(iterator, node, false)),
},
)
}
private customIterator(value: unknown, node: AstNode, allowAsync = true) {
if (value instanceof CodeModeGenerator) {
if (value.asynchronous && !allowAsync) return Effect.succeed(undefined)
return Effect.succeed({
iterator: value,
next: new GeneratorMethodReference(value, "next"),
asynchronous: value.asynchronous,
})
}
if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
const asyncMethod = allowAsync ? Reflect.get(value, AsyncIteratorSymbol) : undefined
const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
if (method === undefined || method === null) return Effect.succeed(undefined)
const self = this
return Effect.map(
this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node),
(iterator) => {
const object = self.requireIterator(iterator, node)
return {
iterator: object,
next:
object instanceof CodeModeGenerator
? new GeneratorMethodReference(object, "next")
: self.requireIteratorMethod(object.next, "Iterator next", node),
asynchronous: asyncMethod !== undefined && asyncMethod !== null,
}
},
)
}
private nextIteratorResult(iterator: CustomIterator, node: AstNode, awaiting: boolean) {
const self = this
return Effect.gen(function* () {
if (iterator.asynchronous) {
const object = self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(iterator.next, [], node)),
"Iterator next() result",
node,
)
return { done: Boolean(object.done), value: object.value }
}
const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node))
if (!Exit.isSuccess(called)) {
if (awaiting) yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => {
const object = self.requireIteratorObject(called.value, "Iterator next() result", node)
return { done: Boolean(object.done), value: object.value }
}),
)
if (!Exit.isSuccess(captured)) {
if (awaiting) yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
return {
done: captured.value.done,
value: awaiting
? yield* self.awaitAsyncFromSyncValue(iterator, captured.value.value, node, !captured.value.done)
: captured.value.value,
}
})
}
private closeIterator(iterator: CustomIterator, node: AstNode, awaiting = true): Effect.Effect<void, unknown, R> {
const close =
iterator.iterator instanceof CodeModeGenerator
? new GeneratorMethodReference(iterator.iterator, "return")
: iterator.iterator.return
if (close === undefined || close === null) return iterator.asynchronous || !awaiting ? Effect.void : Effect.yieldNow
const self = this
return Effect.gen(function* () {
const method = self.requireIteratorMethod(close, "Iterator return", node)
if (iterator.asynchronous) {
self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(method, [], node)),
"Iterator return() result",
node,
)
return
}
const called = yield* Effect.exit(self.invokeCallable(method, [], node))
if (!Exit.isSuccess(called)) {
if (awaiting) yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value),
)
if (!Exit.isSuccess(captured)) {
if (awaiting) yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
if (awaiting) yield* self.awaitValue(captured.value)
})
}
private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject {
if (isRecord(value) && !isRuntimeReference(value)) return value
throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError")
}
private requireIterator(value: unknown, node: AstNode): SafeObject | CodeModeGenerator {
return value instanceof CodeModeGenerator
? value
: this.requireIteratorObject(value, "Iterator method result", node)
}
private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
if (typeofValue(value) === "function") return value
throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
}
private enumerableKeys(value: unknown): Array<string> | undefined {
if (value instanceof ToolReference) {
return [...this.toolKeys(value.path)]
@@ -741,7 +977,7 @@ export class Interpreter<R> {
const keys = self.enumerableKeys(right)
if (keys === undefined) {
throw new InterpreterRuntimeError(
"for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
"for...in requires a plain object, array, or tools reference. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
node,
)
}
@@ -844,7 +1080,7 @@ export class Interpreter<R> {
const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), {
onFailure: (cause) => {
if (cause.reasons.some(Cause.isInterruptReason) || !handler) {
if (cause.reasons.some(Cause.isInterruptReason) || Cause.squash(cause) instanceof GeneratorReturn || !handler) {
return Effect.failCause(cause)
}
@@ -928,7 +1164,7 @@ export class Interpreter<R> {
)
}
const consumed = new Set<string>()
const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
@@ -937,15 +1173,16 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
copyIteratorSymbols(value, rest, consumed)
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
continue
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(String(key))
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
getNode(property, "value"),
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
@@ -958,21 +1195,9 @@ export class Interpreter<R> {
}
if (pattern.type === "ArrayPattern") {
const items = spreadItems(value)
if (items === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
}
for (const [index, item] of getArray(pattern, "elements").entries()) {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element, initialize)
break
}
yield* self.declarePattern(element, items[index], mutable, pattern, initialize)
}
return
return yield* self.destructureArrayPattern(pattern, value, (target, item, context) =>
self.declarePattern(target, item, mutable, context, initialize),
)
}
throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
@@ -1008,7 +1233,7 @@ export class Interpreter<R> {
}
const source = value as SafeObject | Array<unknown>
const consumed = new Set<string>()
const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
if (property.type === "RestElement") {
@@ -1016,41 +1241,79 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
copyIteratorSymbols(source, rest, consumed)
yield* self.assignPattern(getNode(property, "argument"), rest, property)
continue
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(String(key))
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
}
return
}
if (pattern.type === "ArrayPattern") {
const items = spreadItems(value)
if (items === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
}
for (const [index, item] of getArray(pattern, "elements").entries()) {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
yield* self.assignPattern(getNode(element, "argument"), items.slice(index), element)
break
}
yield* self.assignPattern(element, items[index], pattern)
}
return
return yield* self.destructureArrayPattern(pattern, value, (target, item, context) =>
self.assignPattern(target, item, context),
)
}
throw new InterpreterRuntimeError(`Unsupported assignment pattern '${pattern.type}'.`, node)
})
}
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
private destructureArrayPattern(
pattern: AstNode,
value: unknown,
consume: (target: AstNode, value: unknown, context: AstNode) => Effect.Effect<void, unknown, R>,
): Effect.Effect<void, unknown, R> {
const self = this
return Effect.gen(function* () {
const cursor = yield* self.syncIterator(value, pattern)
if (cursor === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern).as(
"TypeError",
)
}
let done = false
for (const [index, item] of getArray(pattern, "elements").entries()) {
if (done) {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
yield* consume(
element.type === "RestElement" ? getNode(element, "argument") : element,
element.type === "RestElement" ? [] : undefined,
element,
)
if (element.type === "RestElement") return
continue
}
const step = yield* cursor.next
done = step.done
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
const rest: Array<unknown> = []
if (!step.done) rest.push(step.value)
while (!done) {
const next = yield* cursor.next
done = next.done
if (!done) rest.push(next.value)
}
yield* consume(getNode(element, "argument"), rest, element)
return
}
const consumed = consume(element, step.done ? undefined : step.value, pattern)
yield* step.done ? consumed : preserveConsumerError(cursor, consumed)
}
if (!done) yield* cursor.close
})
}
private destructuringPropertyKey(property: AstNode): Effect.Effect<PropertyKey, unknown, R> {
if (property.type !== "Property" || getString(property, "kind") !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
}
@@ -1061,12 +1324,12 @@ export class Interpreter<R> {
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
}
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
if (!Array.isArray(source)) return source[String(key)]
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
if (!Array.isArray(source)) return Reflect.get(source, key)
if (key === "length") return source.length
if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
if (Object.hasOwn(source, key)) return Reflect.get(source, key)
if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined
}
@@ -1129,6 +1392,8 @@ export class Interpreter<R> {
value instanceof CodeModePromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
)
}
case "YieldExpression":
return this.evaluateYieldExpression(node)
case "NewExpression":
return this.evaluateNewExpression(node)
default:
@@ -1150,7 +1415,11 @@ export class Interpreter<R> {
)
}
if (errorConstructors.has(name)) {
return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node))
return Effect.flatMap(this.evaluateCallArguments(argNodes), (args) =>
name === "AggregateError"
? constructAggregateErrorValue(self.runner, args, node)
: Effect.succeed(constructErrorValue(name, args)),
)
}
// Array and Object construct identically with or without new, like JS.
if (name === "Array") {
@@ -1168,13 +1437,13 @@ export class Interpreter<R> {
case "RegExp":
return self.constructRegExp(args, node)
case "Map":
return self.constructMap(args[0], node)
return yield* self.constructMap(args[0], node)
case "Set":
return self.constructSet(args[0], node)
return yield* self.constructSet(args[0], node)
case "URL":
return self.constructURL(args, node)
default:
return self.constructURLSearchParams(args[0], node)
return yield* self.constructURLSearchParams(args[0], node)
}
})
}
@@ -1197,7 +1466,7 @@ export class Interpreter<R> {
if (first === null || first === undefined) return {}
if (typeof first === "object") return first
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported in CodeMode; use the primitive value directly.`,
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
node,
)
}
@@ -1260,44 +1529,53 @@ export class Interpreter<R> {
}
}
private constructMap(init: unknown, node: AstNode): CodeModeMap {
private constructMap(init: unknown, node: AstNode): Effect.Effect<CodeModeMap, unknown, R> {
const target = new CodeModeMap()
if (init === undefined || init === null) return target
const entries = Array.isArray(init)
? init
: init instanceof CodeModeMap
? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item])
: undefined
if (entries === undefined) {
throw new InterpreterRuntimeError(
"new Map(...) expects an array of [key, value] pairs, a Map, or no argument.",
node,
)
}
for (const pair of entries) {
if (!Array.isArray(pair)) {
throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node)
if (init === undefined || init === null) return Effect.succeed(target)
const self = this
return Effect.gen(function* () {
const cursor = yield* self.syncIterator(init, node)
if (cursor === undefined) {
throw new InterpreterRuntimeError(
"new Map(...) expects an iterable of [key, value] pairs or no argument.",
node,
).as("TypeError")
}
target.map.set(pair[0], pair[1])
}
return target
while (true) {
const step = yield* cursor.next
if (step.done) return target
yield* preserveConsumerError(
cursor,
Effect.sync(() => {
if (!isRecord(step.value) || isRuntimeReference(step.value)) {
throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs as entry objects.", node).as(
"TypeError",
)
}
target.map.set(step.value[0], step.value[1])
}),
)
}
})
}
private constructSet(init: unknown, node: AstNode): CodeModeSet {
private constructSet(init: unknown, node: AstNode): Effect.Effect<CodeModeSet, unknown, R> {
const target = new CodeModeSet()
if (init === undefined || init === null) return target
const items = Array.isArray(init)
? init
: init instanceof CodeModeSet
? Array.from(init.set.values())
: typeof init === "string"
? Array.from(init)
: undefined
if (items === undefined) {
throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node)
}
for (const item of items) target.set.add(item)
return target
if (init === undefined || init === null) return Effect.succeed(target)
const self = this
return Effect.gen(function* () {
const cursor = yield* self.syncIterator(init, node)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new Set(...) expects a synchronous iterable or no argument.", node).as(
"TypeError",
)
}
while (true) {
const step = yield* cursor.next
if (step.done) return target
target.set.add(step.value)
}
})
}
private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
@@ -1318,47 +1596,79 @@ export class Interpreter<R> {
}
}
private constructURLSearchParams(init: unknown, node: AstNode): CodeModeURLSearchParams {
if (init === undefined) return new CodeModeURLSearchParams(new URLSearchParams())
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<CodeModeURLSearchParams, unknown, R> {
if (init === undefined) return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams()))
if (init instanceof CodeModeURLSearchParams) {
return new CodeModeURLSearchParams(new URLSearchParams(init.params))
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init.params)))
}
if (typeof init === "string") return new CodeModeURLSearchParams(new URLSearchParams(init))
if (typeof init === "string") return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init)))
if (init === null || typeof init === "number" || typeof init === "boolean") {
return new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init)))
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init))))
}
if (init instanceof CodeModeMap) {
return this.constructURLSearchParams(
Array.from(init.map.entries(), ([key, value]) => [key, value]),
node,
)
}
if (Array.isArray(init)) {
const entries = init.map((pair) => {
if (!Array.isArray(pair) || pair.length !== 2) {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects an array of [name, value] pairs.",
node,
).as("TypeError")
const self = this
return Effect.gen(function* () {
const cursor = yield* self.syncIterator(init, node)
if (cursor !== undefined) {
const entries: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (entries.some((entry) => entry.length !== 2)) {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects iterable [name, value] pairs.",
node,
).as("TypeError")
}
return new CodeModeURLSearchParams(
new URLSearchParams(entries.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])),
)
}
entries.push(yield* preserveConsumerError(cursor, self.readURLSearchParamsPair(step.value, node)))
}
return [uriArgument(pair[0], "URLSearchParams name"), uriArgument(pair[1], "URLSearchParams value")] as [
string,
string,
]
})
return new CodeModeURLSearchParams(new URLSearchParams(entries))
}
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
const data = boundedData(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, array of pairs, or URLSearchParams.",
node,
).as("TypeError")
}
return new CodeModeURLSearchParams(
new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))),
)
}
if (isRuntimeReference(init)) {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.",
node,
).as("TypeError")
}
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
const data = boundedData(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.",
node,
).as("TypeError")
}
return new CodeModeURLSearchParams(
new URLSearchParams(
Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)])),
),
)
})
}
private readURLSearchParamsPair(value: unknown, node: AstNode): Effect.Effect<Array<string>, unknown, R> {
const self = this
return Effect.gen(function* () {
const cursor = yield* self.syncIterator(value, node)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node).as(
"TypeError",
)
}
const items: Array<string> = []
while (true) {
const step = yield* cursor.next
if (step.done) return items
items.push(
yield* preserveConsumerError(
cursor,
Effect.sync(() => uriArgument(step.value, "URLSearchParams pair value")),
),
)
}
})
}
private evaluateBinaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
@@ -1373,8 +1683,10 @@ export class Interpreter<R> {
}
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
if (operator === "===") return lhs === rhs
if (operator === "!==") return lhs !== rhs
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue")
throw new InterpreterRuntimeError("Binary operators require data values.", node, "InvalidDataValue")
}
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
@@ -1402,12 +1714,8 @@ export class Interpreter<R> {
return (l as number) ** (r as number)
case "==":
return bothObjects ? lhs === rhs : l == r
case "===":
return lhs === rhs
case "!=":
return bothObjects ? lhs !== rhs : l != r
case "!==":
return lhs !== rhs
case "<":
return (l as string) < (r as string)
case "<=":
@@ -1465,7 +1773,7 @@ export class Interpreter<R> {
if (operator === "!") return !value
if (operator === "void") return undefined
if (containsOpaqueReference(value)) {
throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
}
const operand =
value instanceof CodeModeDate
@@ -1580,11 +1888,7 @@ export class Interpreter<R> {
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
const operand = (current: unknown): number => {
if (containsOpaqueReference(current)) {
throw new InterpreterRuntimeError(
`'${operator}' requires a data value in CodeMode.`,
argument,
"InvalidDataValue",
)
throw new InterpreterRuntimeError(`'${operator}' requires a data value.`, argument, "InvalidDataValue")
}
return coerceToNumber(current)
}
@@ -1647,6 +1951,11 @@ export class Interpreter<R> {
if (callable instanceof CodeModeFunction) {
return yield* self.invokeFunction(callable, args)
}
if (callable instanceof GeneratorMethodReference) {
if (callable.kind === "iterator") return callable.generator
const requested = callable.generator.request(callable.kind, args[0], node) as Effect.Effect<unknown, unknown, R>
return callable.generator.asynchronous ? yield* self.createPromise(requested) : yield* requested
}
if (callable instanceof IntrinsicReference) {
return yield* invokeIntrinsic(self.runner, callable, args, node)
}
@@ -1656,6 +1965,7 @@ export class Interpreter<R> {
return self.invokeObjectMethodOnTools(callable.name, args[0], node)
}
if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) {
if (callable.name === "fromEntries") return yield* invokeObjectFromEntries(self.runner, args[0], node)
return invokeGlobalMethod(callable, args, node)
}
if (callable.namespace === "Array" && callable.name === "from") {
@@ -1664,6 +1974,9 @@ export class Interpreter<R> {
if ((callable.namespace === "Object" || callable.namespace === "Map") && callable.name === "groupBy") {
return yield* invokeGroupBy(self.runner, callable.namespace, args, node)
}
if (callable.namespace === "Math" && callable.name === "sumPrecise") {
return yield* invokeMathSumPrecise(self.runner, args[0], node)
}
if (callable.namespace === "Array" && callable.name === "of") {
return invokeGlobalMethod(callable, args, node)
}
@@ -1682,7 +1995,8 @@ export class Interpreter<R> {
return yield* self.invokeSearch(args)
}
if (callable instanceof ErrorConstructorReference) {
return constructErrorValue(callable.name, args, node)
if (callable.name === "AggregateError") return yield* constructAggregateErrorValue(self.runner, args, node)
return constructErrorValue(callable.name, args)
}
if (callable instanceof GlobalNamespace) {
// Real JS permits calling Array, Object, Date, and RegExp without new.
@@ -1700,6 +2014,12 @@ export class Interpreter<R> {
if (callable instanceof PromiseNamespace) {
throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
}
if (callable instanceof SymbolNamespace) {
throw new InterpreterRuntimeError(
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
node,
).as("TypeError")
}
if (callable instanceof PromiseCapabilityFunction) {
callable.settle(args[0])
return undefined
@@ -1707,7 +2027,7 @@ export class Interpreter<R> {
if (callable === undefined || callable === null) {
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee).as("TypeError")
}
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
throw new InterpreterRuntimeError("Only tools are callable here.", callee)
})
}
@@ -1723,8 +2043,7 @@ export class Interpreter<R> {
}
private invokeConsole(name: string, args: Array<unknown>, node: AstNode): undefined {
if (!consoleMethods.has(name))
throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node)
if (!consoleMethods.has(name)) throw new InterpreterRuntimeError(`console.${name} is not available.`, node)
this.logs.push(formatConsoleMessage(name, args))
return undefined
}
@@ -1737,13 +2056,16 @@ export class Interpreter<R> {
const argNode = asNode(arg, `arguments[${index}]`)
if (argNode.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(getNode(argNode, "argument"))
const items = spreadItems(spread)
if (items === undefined)
throw new InterpreterRuntimeError(
"Spread arguments require an array, string, Map, or Set in CodeMode.",
argNode,
const cursor = yield* self.syncIterator(spread, argNode)
if (cursor === undefined)
throw new InterpreterRuntimeError("Spread arguments require a synchronous iterable.", argNode).as(
"TypeError",
)
args.push(...items)
while (true) {
const step = yield* cursor.next
if (step.done) break
args.push(step.value)
}
} else {
args.push(yield* self.evaluateExpression(argNode))
}
@@ -1778,6 +2100,7 @@ export class Interpreter<R> {
return yield* invocation.evaluateExpression(fn.body)
})
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
// The initial yield assigns `box.own` before the body can self-resolve.
const box: { own?: CodeModePromise } = {}
@@ -1796,6 +2119,250 @@ export class Interpreter<R> {
)
}
private createGenerator(
invocation: Interpreter<R>,
run: Effect.Effect<unknown, unknown, R>,
asynchronous: boolean,
): CodeModeGenerator {
const state: GeneratorState = { started: false, completed: false, draining: false, pending: [], pendingIndex: 0 }
invocation.generatorState = state
invocation.generatorAsync = asynchronous
const generator = new CodeModeGenerator(asynchronous, (kind, value, node) => {
const request = { kind, value, response: Deferred.makeUnsafe<unknown, unknown>() }
if (!asynchronous && state.active) {
return Effect.fail(new InterpreterRuntimeError("Generator is already running.", node).as("TypeError"))
}
if (asynchronous && (state.completed || (!state.started && kind !== "next"))) {
state.started = true
state.completed = true
state.pending.push(request)
if (state.draining) return Deferred.await(request.response)
state.draining = true
return Effect.andThen(
this.promises.fork(
invocation
.completeGeneratorRequests(state, true)
.pipe(Effect.ensuring(Effect.sync(() => (state.draining = false)))),
),
Deferred.await(request.response),
)
}
if (state.completed) {
if (kind === "throw") return Effect.fail(new ProgramThrow(value))
return Effect.succeed({ value: kind === "return" ? value : undefined, done: true })
}
if (!state.started && kind !== "next") {
state.completed = true
if (kind === "throw") return Effect.fail(new ProgramThrow(value))
return Effect.succeed({ value, done: true })
}
state.pending.push(request)
if (state.available) {
const available = state.available
state.available = undefined
Deferred.doneUnsafe(available, Exit.succeed(undefined))
}
if (!state.started) {
state.started = true
const body = Effect.gen(function* () {
state.active = yield* invocation.takeGeneratorRequest(state)
const exit = yield* Effect.exit(
run.pipe(
Effect.flatMap((result) => (asynchronous ? invocation.awaitValue(result) : Effect.succeed(result))),
Effect.catch((error) =>
error instanceof GeneratorReturn
? asynchronous
? invocation.awaitValue(error.value)
: Effect.succeed(error.value)
: Effect.fail(error),
),
),
)
const active = state.active
state.active = undefined
if (active) {
Deferred.doneUnsafe(
active.response,
Exit.isSuccess(exit) ? Exit.succeed({ value: exit.value, done: true }) : exit,
)
}
yield* invocation.completeGeneratorRequests(state, asynchronous)
state.completed = true
})
return Effect.andThen(this.promises.fork(body), Deferred.await(request.response))
}
return Deferred.await(request.response)
})
return generator
}
private completeGeneratorRequests(state: GeneratorState, asynchronous: boolean): Effect.Effect<void, never, R> {
const self = this
return Effect.gen(function* () {
while (true) {
const pending = self.dequeueGeneratorRequest(state)
if (!pending) return
if (pending.kind === "throw") {
Deferred.doneUnsafe(pending.response, Exit.fail(new ProgramThrow(pending.value)))
continue
}
if (asynchronous && pending.kind === "return") {
const resolved = yield* Effect.exit(self.awaitValue(pending.value))
Deferred.doneUnsafe(
pending.response,
Exit.isSuccess(resolved) ? Exit.succeed({ value: resolved.value, done: true }) : resolved,
)
continue
}
Deferred.doneUnsafe(
pending.response,
Exit.succeed({ value: pending.kind === "return" ? pending.value : undefined, done: true }),
)
}
})
}
private takeGeneratorRequest(state: GeneratorState): Effect.Effect<GeneratorRequest> {
const next = this.dequeueGeneratorRequest(state)
if (next) return Effect.succeed(next)
state.available = Deferred.makeUnsafe<void>()
return Effect.andThen(
Deferred.await(state.available),
Effect.sync(() => this.dequeueGeneratorRequest(state)!),
)
}
private dequeueGeneratorRequest(state: GeneratorState): GeneratorRequest | undefined {
const request = state.pending[state.pendingIndex]
if (!request) return undefined
state.pendingIndex += 1
if (state.pendingIndex === state.pending.length) {
state.pending = []
state.pendingIndex = 0
}
return request
}
private evaluateYieldExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
const argument = getOptionalNode(node, "argument")
const self = this
return Effect.gen(function* () {
if (!self.generatorState) throw new InterpreterRuntimeError("yield is only valid inside a generator.", node)
if (node.delegate === true) {
const value = argument ? yield* self.evaluateExpression(argument) : undefined
return yield* self.delegateYield(value, node)
}
const value = argument ? yield* self.evaluateExpression(argument) : undefined
const yielded = self.generatorAsync ? yield* self.awaitValue(value) : value
return yield* self.suspendGenerator(yielded, node)
})
}
private suspendGenerator(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
const state = this.generatorState
if (!state?.active) throw new InterpreterRuntimeError("Generator has no active request.", node)
Deferred.doneUnsafe(state.active.response, Exit.succeed({ value, done: false }))
state.active = undefined
return Effect.flatMap(this.takeGeneratorRequest(state), (request) => {
state.active = request
if (request.kind === "next") return Effect.succeed(request.value)
if (request.kind === "throw") return Effect.fail(new ProgramThrow(request.value))
return this.generatorAsync
? Effect.flatMap(this.awaitValue(request.value), (value) => Effect.fail(new GeneratorReturn(value)))
: Effect.fail(new GeneratorReturn(request.value))
})
}
private delegateYield(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
if (
Array.isArray(value) ||
typeof value === "string" ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURLSearchParams
) {
const cursor = yield* self.syncIterator(value, node)
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
while (true) {
const step = yield* cursor.next
if (step.done) return undefined
const resumed = yield* Effect.exit(
self.suspendGenerator(self.generatorAsync ? yield* self.awaitValue(step.value) : step.value, node),
)
if (Exit.isSuccess(resumed)) continue
const error = Cause.squash(resumed.cause)
if (error instanceof GeneratorReturn) {
yield* cursor.close
return yield* Effect.fail(error)
}
if (error instanceof ProgramThrow) {
yield* cursor.close
throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node).as(
"TypeError",
)
}
return yield* Effect.failCause(resumed.cause)
}
}
const iterator = yield* self.customIterator(value, node, self.generatorAsync)
if (!iterator)
throw new InterpreterRuntimeError("yield* requires a compatible iterable value.", node).as("TypeError")
let kind: GeneratorRequestKind = "next"
let input: unknown = undefined
while (true) {
const method =
kind === "next"
? iterator.next
: iterator.iterator instanceof CodeModeGenerator
? new GeneratorMethodReference(iterator.iterator, kind)
: iterator.iterator[kind]
if (method === undefined || method === null) {
if (kind === "return") return yield* Effect.fail(new GeneratorReturn(input))
yield* self.closeIterator(iterator, node, self.generatorAsync)
throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node).as(
"TypeError",
)
}
const called = yield* self.invokeCallable(
self.requireIteratorMethod(method, `Iterator ${kind}`, node),
[input],
node,
)
const result = self.requireIteratorObject(
iterator.asynchronous ? yield* self.awaitValue(called) : called,
`Iterator ${kind}() result`,
node,
)
const done = Boolean(result.done)
const resultValue: unknown =
self.generatorAsync && !iterator.asynchronous
? yield* self.awaitAsyncFromSyncValue(iterator, result.value, node, kind !== "return" && !done)
: result.value
if (done) {
if (kind === "return") return yield* Effect.fail(new GeneratorReturn(resultValue))
return resultValue
}
const resumed: Exit.Exit<unknown, unknown> = yield* Effect.exit(self.suspendGenerator(resultValue, node))
if (Exit.isSuccess(resumed)) {
kind = "next"
input = resumed.value
continue
}
const error: unknown = Cause.squash(resumed.cause)
if (!(error instanceof GeneratorReturn) && !(error instanceof ProgramThrow)) {
return yield* Effect.failCause(resumed.cause)
}
kind = error instanceof GeneratorReturn ? "return" : "throw"
input = error.value
}
})
}
private evaluateObjectExpression(node: AstNode): Effect.Effect<Record<string, unknown>, unknown, R> {
const objectValue: Record<string, unknown> = Object.create(null) as Record<string, unknown>
const properties = getArray(node, "properties")
@@ -1808,17 +2375,13 @@ export class Interpreter<R> {
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError(
"Object spread requires a data object in CodeMode.",
property,
"InvalidDataValue",
)
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
}
for (const [key, value] of Object.entries(spread)) {
if (isBlockedMember(key))
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property)
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
objectValue[key] = value
}
copyIteratorSymbols(spread, objectValue)
continue
}
@@ -1847,9 +2410,9 @@ export class Interpreter<R> {
}
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode)
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
}
objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
Reflect.set(objectValue, key, yield* self.evaluateExpression(valueNode))
}
return objectValue
@@ -1871,13 +2434,14 @@ export class Interpreter<R> {
const element = asNode(elementValue, "elements")
if (element.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(getNode(element, "argument"))
const items = spreadItems(spread)
if (items === undefined)
throw new InterpreterRuntimeError(
"Array spread requires an array, string, Map, or Set in CodeMode.",
element,
)
values.push(...items)
const cursor = yield* self.syncIterator(spread, element)
if (cursor === undefined)
throw new InterpreterRuntimeError("Array spread requires a synchronous iterable.", element).as("TypeError")
while (true) {
const step = yield* cursor.next
if (step.done) break
values.push(step.value)
}
} else {
values.push(yield* self.evaluateExpression(element))
}
@@ -1938,6 +2502,7 @@ export class Interpreter<R> {
| IntrinsicReference
| GlobalMethodReference
| JsonMethodReference
| GeneratorMethodReference
| ComputedValue
| typeof OptionalShortCircuit
| undefined,
@@ -1972,14 +2537,20 @@ export class Interpreter<R> {
return new PromiseMethodReference(key as PromiseMethodName)
}
throw new InterpreterRuntimeError(
`Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.any, Promise.resolve, and Promise.reject; consume promises with await.`,
`Promise.${String(key)} is not available. Available: Promise.all, Promise.allSettled, Promise.race, Promise.any, Promise.resolve, and Promise.reject; consume promises with await.`,
propertyNode,
)
}
if (objectValue instanceof SymbolNamespace) {
if (key === "asyncIterator") return new ComputedValue(AsyncIteratorSymbol)
if (key === "iterator") return new ComputedValue(IteratorSymbol)
return new ComputedValue(undefined)
}
if (objectValue instanceof GlobalNamespace) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
}
if (typeof key !== "string") return new ComputedValue(undefined)
if (objectValue.name === "Math" && mathConstants.has(key)) {
@@ -1998,7 +2569,7 @@ export class Interpreter<R> {
if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length)
const index = parseArrayIndex(key)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return new ComputedValue(objectValue[index])
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
@@ -2011,7 +2582,7 @@ export class Interpreter<R> {
if (objectValue instanceof CoercionFunction) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
}
if (typeof key !== "string") return new ComputedValue(undefined)
if (objectValue.name === "Number" && numberConstants.has(key)) {
@@ -2076,9 +2647,22 @@ export class Interpreter<R> {
)
}
if (objectValue instanceof CodeModeGenerator) {
if (key === "next" || key === "return" || key === "throw") {
return new GeneratorMethodReference(objectValue, key)
}
if (
(key === IteratorSymbol && !objectValue.asynchronous) ||
(key === AsyncIteratorSymbol && objectValue.asynchronous)
) {
return new GeneratorMethodReference(objectValue, "iterator")
}
return new ComputedValue(undefined)
}
if (isRuntimeReference(objectValue)) {
throw new InterpreterRuntimeError(
"CodeMode runtime references are opaque and do not expose properties.",
"Runtime references are opaque and do not expose properties.",
objectNode,
"InvalidDataValue",
)
@@ -2089,12 +2673,12 @@ export class Interpreter<R> {
}
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode)
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, propertyNode)
}
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
const index = parseArrayIndex(key)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
@@ -2112,26 +2696,17 @@ export class Interpreter<R> {
return Effect.map(this.getMemberReference(node), (reference) => {
if (reference === OptionalShortCircuit) return OptionalShortCircuit
if (reference instanceof ComputedValue) return reference.value
if (
reference === undefined ||
reference instanceof ToolReference ||
reference instanceof PromiseMethodReference ||
reference instanceof PromiseInstanceMethodReference ||
reference instanceof IntrinsicReference ||
reference instanceof GlobalMethodReference ||
reference instanceof JsonMethodReference
)
return reference
if (reference === undefined || isOpaqueMemberReference(reference)) return reference
if (Array.isArray(reference.target)) {
if (reference.key === "length") return reference.target.length
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return reference.target[reference.key]
return Reflect.get(reference.target, reference.key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
return Reflect.get(reference.target.url, reference.key)
}
return reference.target[String(reference.key)]
return Reflect.get(reference.target, reference.key)
})
}
@@ -2142,22 +2717,17 @@ export class Interpreter<R> {
private evaluateDeleteExpression(argument: AstNode): Effect.Effect<boolean, unknown, R> {
const target = argument.type === "ChainExpression" ? getNode(argument, "expression") : argument
if (target.type !== "MemberExpression") {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", argument)
throw new InterpreterRuntimeError("Only data fields may be deleted.", argument)
}
return Effect.map(this.getMemberReference(target, "delete"), (reference) => {
if (reference === OptionalShortCircuit) return true
if (
reference instanceof ComputedValue ||
reference === undefined ||
reference instanceof ToolReference ||
reference instanceof PromiseMethodReference ||
reference instanceof PromiseInstanceMethodReference ||
reference instanceof IntrinsicReference ||
reference instanceof GlobalMethodReference ||
reference instanceof JsonMethodReference ||
isOpaqueMemberReference(reference) ||
reference.target instanceof CodeModeURL
) {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue")
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
}
if (reference.target instanceof CodeModeRegExp) {
return Reflect.deleteProperty(reference.target.regex, reference.key)
@@ -2178,38 +2748,32 @@ export class Interpreter<R> {
reference === OptionalShortCircuit ||
reference instanceof ComputedValue ||
reference === undefined ||
reference instanceof ToolReference ||
reference instanceof PromiseMethodReference ||
reference instanceof PromiseInstanceMethodReference ||
reference instanceof IntrinsicReference ||
reference instanceof GlobalMethodReference ||
reference instanceof JsonMethodReference
isOpaqueMemberReference(reference)
) {
throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node)
throw new InterpreterRuntimeError("Only data fields may be assigned.", node)
}
if (Array.isArray(reference.target)) {
if (reference.key === "length")
throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node)
if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned.", node)
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node)
throw new InterpreterRuntimeError("Array methods cannot be assigned.", node)
}
}
const key = Array.isArray(reference.target) ? reference.key : String(reference.key)
const key = reference.key
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
if (write) self.assignToReference(reference, key, next, node)
return result
})
}
private readReferenceValue(reference: MemberReference, key: number | string): unknown {
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[key]
return Reflect.get(reference.target.url, key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return (reference.target as Record<PropertyKey, unknown>)[key]
return Reflect.get(reference.target, key)
}
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) {
const target = reference.target
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
@@ -2242,16 +2806,19 @@ export class Interpreter<R> {
return
}
const target = reference.target as SafeObject
const objectKey = key as string
rejectCircularInsertion(target, next, "Object assignment result", node)
target[objectKey] = next
Reflect.set(target, key, next)
}
private toPropertyKey(value: unknown, node: AstNode): string | number {
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
if (typeof value === "string" || typeof value === "number") {
return value
}
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw new InterpreterRuntimeError("Property key must be a string or number.", node)
throw new InterpreterRuntimeError(
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
node,
)
}
}
+2 -2
View File
@@ -58,7 +58,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
seen.delete(value)
}
}
if (isRuntimeReference(value)) return "[CodeMode reference]"
if (isRuntimeReference(value)) return "[opaque reference]"
seen.add(value)
try {
if (Array.isArray(value)) {
@@ -74,7 +74,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
if (value === undefined) return "undefined"
if (containsOpaqueReference(value)) return "[CodeMode reference]"
if (containsOpaqueReference(value)) return "[opaque reference]"
const data = boundedData(value, "console.table argument")
const columns = consoleTableColumns(columnsArgument)
const rows = consoleTableRows(data, columns)
+2 -2
View File
@@ -59,7 +59,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
case "UTC":
return Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))
default:
throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Date.${name} is not available.`, node)
}
}
@@ -170,7 +170,7 @@ export const invokeDateMethod = (
if (args.length < 3) return updateDate(value, hosted.setUTCFullYear(args[0], args[1]))
return updateDate(value, hosted.setUTCFullYear(args[0], args[1], args[2]))
default:
throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Date method '${name}' is not available.`, node)
}
}
+30 -14
View File
@@ -1,5 +1,6 @@
import { Effect } from "effect"
import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { spreadItems } from "./collections.js"
// Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types.
declare global {
@@ -51,19 +52,8 @@ export const mathMethods = new Set([
])
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available.`, node)
if (name === "random") return Math.random()
if (name === "sumPrecise") {
const items = spreadItems(args[0])
if (items === undefined) {
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable collection.", node).as("TypeError")
}
const numbers = Array.from(items)
if (!numbers.every((item): item is number => typeof item === "number")) {
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node).as("TypeError")
}
return Math.sumPrecise(numbers)
}
// Validate only the arguments the method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const num = (index: number): number => {
@@ -151,5 +141,31 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
case "imul":
return Math.imul(a, b())
}
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Math.${name} is not available.`, node)
}
export const invokeMathSumPrecise = <R>(
runner: SyncIteratorRunner<R>,
source: unknown,
node: AstNode,
): Effect.Effect<number, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
throw new InterpreterRuntimeError("Math.sumPrecise expects a synchronous iterable.", node).as("TypeError")
}
const numbers: Array<number> = []
while (true) {
const step = yield* cursor.next
if (step.done) return Math.sumPrecise(numbers)
yield* preserveConsumerError(
cursor,
Effect.sync(() => {
if (typeof step.value !== "number") {
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node).as("TypeError")
}
numbers.push(step.value)
}),
)
}
})
+2 -2
View File
@@ -45,7 +45,7 @@ export const invokeNumberMethod = (value: number, name: string, args: Array<unkn
result = value
break
default:
throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Number method '${name}' is not available.`, node)
}
return boundedData(result, `Number.${name} result`)
}
@@ -71,7 +71,7 @@ export const invokeNumberStatic = (name: string, args: Array<unknown>, node: Ast
case "parseFloat":
return parseFloat(coerceToString(value))
default:
throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Number.${name} is not available.`, node)
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+61 -37
View File
@@ -1,8 +1,16 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { Effect } from "effect"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js"
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
@@ -30,14 +38,9 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return input as Record<string, unknown>
}
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = item
}
const addEntry = (out: Record<string, unknown>, key: unknown, item: unknown): void => {
boundedData(key, "Object.fromEntries key")
boundedData(item, "Object.fromEntries value")
guardedSet(out, coerceToString(key), item)
}
switch (name) {
case "keys":
return Object.keys(requireObject())
@@ -46,10 +49,13 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
case "entries":
return Object.entries(requireObject()).map(([key, item]) => [key, item])
case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1]))
return Object.hasOwn(
requireObject(),
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
)
case "is":
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values in CodeMode.", node, "InvalidDataValue")
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
}
return Object.is(args[0], args[1])
case "assign": {
@@ -64,35 +70,53 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
}
return out
}
case "fromEntries": {
if (args[0] instanceof CodeModeMap) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, item] of args[0].map.entries()) addEntry(out, key, item)
return out
}
if (args[0] instanceof CodeModeURLSearchParams) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
return out
}
const pairs = args[0] instanceof CodeModeSet ? Array.from(args[0].set.values()) : args[0]
if (!Array.isArray(pairs)) {
boundedData(args[0], "Object.fromEntries input")
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
}
const out: Record<string, unknown> = Object.create(null)
for (const pair of pairs) {
const validated = boundedData(pair, "Object.fromEntries entry")
if (validated === null || typeof validated !== "object" || isCodeModeValue(validated))
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
const entry = pair as Record<string, unknown>
addEntry(out, entry[0], entry[1])
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
}
}
return out
}
}
throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`Object.${name} is not available.`, node)
}
export const invokeObjectFromEntries = <R>(
runner: SyncIteratorRunner<R>,
source: unknown,
node: AstNode,
): Effect.Effect<Record<string, unknown>, unknown, R> => {
const out: Record<string, unknown> = Object.create(null)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
throw new InterpreterRuntimeError("Object.fromEntries expects a synchronous iterable of entries.", node).as(
"TypeError",
)
}
while (true) {
const step = yield* cursor.next
if (step.done) return out
yield* preserveConsumerError(
cursor,
Effect.sync(() => {
if (
step.value === null ||
typeof step.value !== "object" ||
isCodeModeValue(step.value) ||
containsOpaqueReference(step.value)
) {
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node).as(
"TypeError",
)
}
const entry = step.value as Record<string, unknown>
boundedData(entry[0], "Object.fromEntries key")
boundedData(entry[1], "Object.fromEntries value")
const key = coerceToString(entry[0])
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = entry[1]
}),
)
}
})
}
+2 -2
View File
@@ -72,7 +72,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
}
export const invokeRegExpStatic = (name: string, args: Array<unknown>, node: AstNode): string => {
if (name !== "escape") throw new InterpreterRuntimeError(`RegExp.${name} is not available in CodeMode.`, node)
if (name !== "escape") throw new InterpreterRuntimeError(`RegExp.${name} is not available.`, node)
if (typeof args[0] !== "string") {
throw new InterpreterRuntimeError("RegExp.escape expects a string.", node).as("TypeError")
}
@@ -104,7 +104,7 @@ export const invokeRegExpMethod = (
case "toString":
return coerceToString(value)
default:
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available.`, node)
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const invokeStringStatic = (name: string, args: Array<unknown>, node: Ast
case "fromCodePoint":
return String.fromCodePoint(...codes)
default:
throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`String.${name} is not available.`, node)
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+2 -2
View File
@@ -69,7 +69,7 @@ export const urlArgument = (value: unknown, label: string): string =>
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available.`, node)
if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node).as("TypeError")
const input = urlArgument(args[0], `URL.${name} input`)
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
@@ -83,7 +83,7 @@ export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNod
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
throw new InterpreterRuntimeError(`URL method '${name}' is not available.`, node)
}
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
import { CodeModeURL } from "../values.js"
+6 -6
View File
@@ -363,7 +363,7 @@ const termForms = (term: string): Array<string> => {
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
_tag: "CodeModeTool",
description: "Search available Code Mode tools",
description: "Search available tools",
input: SearchInput,
output: SearchOutput,
run: (input) =>
@@ -501,8 +501,8 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
empty
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
: complete
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below; surrounding agent tools are not available.",
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed below; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed or searchable below; surrounding agent tools are not available.",
...(empty
? []
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
@@ -533,8 +533,8 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
"## Rules",
"",
complete
? "- Only Code Mode tools listed here are available; surrounding agent tools are not implicitly exposed."
: "- Only Code Mode tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
? "- Only tools listed here are available; surrounding agent tools are not implicitly exposed."
: "- Only tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
@@ -553,7 +553,7 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
"## Language",
"",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Modules/imports, classes, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.",
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
]
+6 -5
View File
@@ -308,7 +308,7 @@ describe("CodeMode console capture", () => {
)
expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}'])
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[opaque reference],"ok":1}'])
})
test("console.table renders CodeMode value cells", async () => {
@@ -696,7 +696,7 @@ describe("CodeMode public contract", () => {
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).toContain("surrounding agent tools are not available")
expect(instructions).toContain("Only Code Mode tools listed here are available")
expect(instructions).toContain("Only tools listed here are available")
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
// and no real catalog tools cherry-picked into example lines.
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
@@ -718,7 +718,7 @@ describe("CodeMode public contract", () => {
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
)
expect(partial).toContain("In the next execution, copy a returned path exactly")
expect(partial).toContain("Only Code Mode tools listed here or returned by the built-in `search` function")
expect(partial).toContain("Only tools listed here or returned by the built-in `search` function")
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
expect(partial).toContain("repeat the same search with `offset: next.offset`")
expect(partial).toContain(" limit?: number,\n offset?: number,")
@@ -732,14 +732,15 @@ describe("CodeMode public contract", () => {
expect(instructions).toContain("not a general-purpose runtime")
expect(instructions).not.toContain("Standard modern JavaScript works")
expect(instructions).not.toContain("TypeScript type annotations")
for (const missing of ["Modules/imports", "classes", "generators", "fetch"]) {
for (const missing of ["Modules/imports", "classes", "fetch"]) {
expect(instructions).toContain(missing)
}
expect(instructions).not.toContain("generators")
expect(instructions).not.toContain("new Promise(...) are unavailable")
expect(instructions).not.toContain("promise chaining")
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use Code Mode tools for external operations")
expect(instructions).toContain("Use tools for external operations")
expect(instructions).toContain(
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
)
@@ -0,0 +1,534 @@
/*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/language/statements/for-await-of/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/ticks-with-async-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/async-func-dstr-let-ary-ptrn-elem-id-iter-val.js
* - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-is-null.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-non-callable.js
* - test/language/statements/for-await-of/iterator-close-throw-get-method-non-callable.js
*
* Copyright (C) 2019 André Bargull. All rights reserved.
* Copyright (C) 2020 Alexey Shvayka. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await execute(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("Test262 for-await-of adaptations", () => {
test("awaits promise and plain values from a synchronous array", async () => {
expect(
await value(`
const values = []
for await (const item of [Promise.resolve(1), 2, new Promise((resolve) => resolve(3))]) {
values.push(item)
}
return values
`),
).toEqual([1, 2, 3])
})
test("defers the body even for an already-resolved or plain value", async () => {
expect(
await value(`
const events = []
const before = Promise.resolve().then(() => events.push("before"))
for await (const item of [Promise.resolve(1), 2]) events.push("body " + item)
await before
return events
`),
).toEqual(["before", "body 1", "body 2"])
})
test("an awaited rejection exits through normal try/catch", async () => {
const result = await execute(`
const values = []
try {
for await (const item of [Promise.resolve(1), Promise.reject("stop"), Promise.resolve(3)]) {
values.push(item)
}
} catch (error) {
return [values, error]
}
return "missed"
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toEqual([[1], "stop"])
expect(result.warnings ?? []).toEqual([])
})
test("destructures after resolving each yielded value", async () => {
expect(
await value(`
const values = []
for await (const [first, ...rest] of [Promise.resolve([1, 2, 3])]) {
values.push(first, rest)
}
return values
`),
).toEqual([1, [2, 3]])
})
test("supports assignment targets", async () => {
expect(
await value(`
let first
let rest
for await ([first, ...rest] of [Promise.resolve([1, 2, 3])]) {}
return [first, rest]
`),
).toEqual([1, [2, 3]])
})
test("preserves fresh lexical bindings per iteration", async () => {
expect(
await value(`
const reads = []
for await (const item of [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]) {
reads.push(() => item)
}
return reads.map((read) => read())
`),
).toEqual([1, 2, 3])
})
test("supports every existing collection iterable", async () => {
expect(
await value(`
const string = []
for await (const item of "ab") string.push(item)
const set = []
for await (const item of new Set([Promise.resolve(1), 2])) set.push(item)
const map = []
for await (const [key, item] of new Map([["a", 1], ["b", 2]])) map.push(key, item)
const params = []
for await (const [key, item] of new URLSearchParams("a=1&b=2")) params.push(key, item)
return { string, set, map, params }
`),
).toEqual({ string: ["a", "b"], set: [1, 2], map: ["a", 1, "b", 2], params: ["a", "1", "b", "2"] })
})
test("preserves labeled break and continue behavior", async () => {
expect(
await value(`
const values = []
outer: for await (const item of [1, 2, 3, 4]) {
if (item === 2) continue outer
if (item === 4) break outer
values.push(item)
}
return values
`),
).toEqual([1, 3])
})
test("drives a custom async iterator sequentially", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
async next() {
index += 1
if (index > 3) return { done: true }
return { done: false, value: index }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2, 3])
})
test("leaves async iterator values under the iterator's control", async () => {
expect(
await value(`
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) return [item instanceof Promise, await item]
`),
).toEqual([true, 1])
})
test("awaits synchronous results from an async iterator before the body", async () => {
expect(
await value(`
const events = ["pre"]
const ticks = Promise.resolve()
.then(() => events.push("tick 1"))
.then(() => events.push("tick 2"))
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) events.push(item instanceof Promise ? "loop" : "adopted")
events.push("post")
await ticks
return events
`),
).toEqual(["pre", "tick 1", "loop", "tick 2", "post"])
})
test("falls back to a custom synchronous iterator", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
index += 1
return index > 2 ? { done: true } : { done: false, value: Promise.resolve(index) }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("adopts terminal and close values from synchronous iterators", async () => {
expect(
await value(`
const terminal = {
[Symbol.iterator]: () => terminal,
next: () => ({ done: true, value: Promise.reject("terminal") }),
}
let terminalError
try {
for await (const item of terminal) {}
} catch (error) {
terminalError = error
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return: () => ({ done: true, value: Promise.reject("close") }),
}
let closeError
try {
for await (const item of closing) break
} catch (error) {
closeError = error
}
return [terminalError, closeError]
`),
).toEqual(["terminal", "close"])
})
test("captures the next method when acquiring the iterator", async () => {
expect(
await value(`
let count = 0
const next = () => {
count += 1
if (count === 1) iterator.next = () => ({ done: true })
return count > 2 ? { done: true } : { done: false, value: count }
}
const iterator = { [Symbol.iterator]: () => iterator, next }
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("captures synchronous iterator result fields before suspending", async () => {
expect(
await value(`
let count = 0
const result = { done: false, value: 1 }
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
count += 1
if (count > 1) return { done: true }
Promise.resolve().then(() => {
result.done = true
result.value = 2
})
return result
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1])
})
test("preserves the async close turn for a sync iterator without return", async () => {
expect(
await value(`
const events = []
const iterator = {
[Symbol.iterator]: () => iterator,
next: () => ({ done: false, value: 1 }),
}
for await (const item of iterator) {
Promise.resolve().then(() => events.push("reaction"))
break
}
events.push("after")
return events
`),
).toEqual(["reaction", "after"])
})
test("defers synchronous iterator protocol errors", async () => {
expect(
await value(`
const events = []
const throwing = {
[Symbol.iterator]: () => throwing,
next() {
Promise.resolve().then(() => events.push("next reaction"))
throw "next"
},
}
try {
for await (const item of throwing) {}
} catch (error) {
events.push("next catch")
}
const malformed = {
[Symbol.iterator]: () => malformed,
next() {
Promise.resolve().then(() => events.push("result reaction"))
return 1
},
}
try {
for await (const item of malformed) {}
} catch (error) {
events.push("result catch")
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return() {
Promise.resolve().then(() => events.push("return reaction"))
throw "return"
},
}
try {
for await (const item of closing) break
} catch (error) {
events.push("return catch")
}
return events
`),
).toEqual(["next reaction", "next catch", "result reaction", "result catch", "return reaction", "return catch"])
})
test("prefers Symbol.asyncIterator over Symbol.iterator", async () => {
expect(
await value(`
let done = false
const asyncIterator = {
next: async () => done ? { done: true } : (done = true, { done: false, value: "async" }),
}
const syncIterator = { next: () => ({ done: true }) }
const iterable = {
[Symbol.asyncIterator]: () => asyncIterator,
[Symbol.iterator]: () => syncIterator,
}
const values = []
for await (const item of iterable) values.push(item)
return values
`),
).toEqual(["async"])
})
test("closes a custom iterator on abrupt loop completion", async () => {
expect(
await value(`
let closed = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => (closed += 1, { done: true }),
}
for await (const item of iterator) break
try {
for await (const item of iterator) throw "stop"
} catch (error) {}
return closed
`),
).toBe(2)
})
test("treats a null iterator return method as absent", async () => {
expect(
await value(`
let count = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: null,
}
for await (const item of iterator) {
count += 1
break
}
return count
`),
).toBe(1)
})
test("a non-callable return method replaces break with TypeError", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("a body throw wins over a non-callable return method", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) throw "body"
} catch (error) {
return error
}
return "missed"
`),
).toBe("body")
})
test("rejects a primitive iterator return result", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => null,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("propagates iterator acquisition failures", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => { throw "acquire" },
}
try {
for await (const item of iterator) {}
} catch (error) {
return error
}
return "missed"
`),
).toBe("acquire")
})
test("rejects malformed iterator acquisition methods and results", async () => {
expect(
await value(`
const names = []
const invalid = [
{ [Symbol.asyncIterator]: true },
{ [Symbol.asyncIterator]: () => 1 },
{ [Symbol.asyncIterator]: () => ({ next: true }) },
]
for (const iterator of invalid) {
try {
for await (const item of iterator) {}
} catch (error) {
names.push(error.name)
}
}
return names
`),
).toEqual(["TypeError", "TypeError", "TypeError"])
})
test("preserves iterator protocol keys through object copies", async () => {
expect(
await value(`
let done = false
const iterable = {
plain: true,
[Symbol.asyncIterator]: () => iterable,
next: async () => done ? { done: true } : (done = true, { done: false, value: 1 }),
}
const spread = { ...iterable }
const { plain, ...rest } = iterable
const assigned = Object.assign({}, iterable)
const values = []
for await (const item of spread) values.push(item)
return [
values,
Object.hasOwn(spread, Symbol.asyncIterator),
Object.hasOwn(rest, Symbol.asyncIterator),
Object.hasOwn(assigned, Symbol.asyncIterator),
]
`),
).toEqual([[1], true, true, true])
})
test("rejects malformed iterator protocol results", async () => {
const result = await execute(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => 1,
}
for await (const item of iterator) {}
`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("Iterator next() result must be an object")
})
test("rejects objects without an iterator protocol method", async () => {
const result = await execute(`for await (const item of { values: [1, 2] }) {}`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("or custom iterator value")
})
})
@@ -0,0 +1,1271 @@
/*
* Portable portions adapted from Test262 at revision
* 250f204f23a9249ff204be2baec29600faae7b75. Exact source paths are cited
* beside the corresponding tests below.
*
* Copyright (C) 2013-2017 the V8 project authors. All rights reserved.
* Copyright (C) 2018 Valerie Young. All rights reserved.
* Copyright (C) 2020 Alexey Shvayka. All rights reserved.
* Copyright (C) 2022 Kevin Gibbons. All rights reserved.
* Copyright Ecma International. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await execute(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("confined generators", () => {
// test/built-ins/GeneratorPrototype/next/return-yield-expr.js
test("is lazy and preserves next(value), nested suspension, return, and exhaustion", async () => {
expect(
await value(`
const events = []
function* generate() {
events.push("start")
const received = yield 1 + (yield 2)
return received
}
const iterator = generate()
const before = events.slice()
const first = iterator.next(99)
const second = iterator.next(3)
const third = iterator.next(7)
const fourth = iterator.next(8)
return [before, events, first, second, third, fourth]
`),
).toEqual([
[],
["start"],
{ value: 2, done: false },
{ value: 4, done: false },
{ value: 7, done: true },
{ value: null, done: true },
])
})
test("routes throw and return through catch and finally", async () => {
expect(
await value(`
function* generate() {
try {
try { yield "try" } catch (error) { yield "caught " + error }
} finally {
yield "finally"
}
}
const iterator = generate()
return [iterator.next(), iterator.throw("boom"), iterator.return("done"), iterator.next()]
`),
).toEqual([
{ value: "try", done: false },
{ value: "caught boom", done: false },
{ value: "finally", done: false },
{ value: "done", done: true },
])
})
test("throws into a suspended generator and after exhaustion", async () => {
expect(
await value(`
function* generate() { yield 1 }
const iterator = generate()
iterator.next()
let suspended
let exhausted
try { iterator.throw("first") } catch (error) { suspended = error }
try { iterator.throw("second") } catch (error) { exhausted = error }
return [suspended, exhausted, iterator.next()]
`),
).toEqual(["first", "second", { value: null, done: true }])
})
test("rejects synchronous generator reentry", async () => {
expect(
await value(`
let iterator
function* generate() {
try { iterator.next() } catch (error) { return error.name }
}
iterator = generate()
return iterator.next()
`),
).toEqual({ value: "TypeError", done: true })
})
test("delegates yield*, forwards next values, and receives the delegate return value", async () => {
expect(
await value(`
function* inner() {
const input = yield 1
return input * 2
}
function* outer() {
const result = yield* inner()
return result + 1
}
const iterator = outer()
return [iterator.next(), iterator.next(4)]
`),
).toEqual([
{ value: 1, done: false },
{ value: 9, done: true },
])
})
test("delegates throw and return to custom iterators", async () => {
expect(
await value(`
const calls = []
let step = 0
const delegate = {
[Symbol.iterator]: () => delegate,
next(...args) {
calls.push(["next", args.length, args[0]])
step += 1
return step === 1 ? { value: "one", done: false } : { value: "end", done: true }
},
throw(value) {
calls.push(["throw", value])
return { value: "recovered", done: false }
},
return(value) {
calls.push(["return", value])
return { value: value + "!", done: true }
},
}
function* generate() { return yield* delegate }
const iterator = generate()
const first = iterator.next()
const second = iterator.throw("x")
const third = iterator.return("stop")
return [first, second, third, calls]
`),
).toEqual([
{ value: "one", done: false },
{ value: "recovered", done: false },
{ value: "stop!", done: true },
[
["next", 1, null],
["throw", "x"],
["return", "stop"],
],
])
})
test("uses the missing-throw delegation path for built-in iterables", async () => {
expect(
await value(`
function* generate() { yield* [1, 2] }
const iterator = generate()
iterator.next()
try { iterator.throw("boom") } catch (error) { return error.name }
`),
).toBe("TypeError")
})
test("exposes only the appropriate iterator symbol and works in for...of", async () => {
expect(
await value(`
function* generate() { yield 1; yield 2 }
const iterator = generate()
const symbols = [iterator[Symbol.iterator]() === iterator, iterator[Symbol.asyncIterator]]
const values = []
for (const item of iterator) values.push(item)
return [symbols, values]
`),
).toEqual([
[true, null],
[1, 2],
])
})
test("accepts generator methods as iterator acquisition results", async () => {
expect(
await value(`
const sync = {
*[Symbol.iterator]() { yield 1; yield 2 },
}
const asynchronous = {
async *[Symbol.asyncIterator]() { yield 3; yield 4 },
}
const values = []
for (const item of sync) values.push(item)
for await (const item of asynchronous) values.push(item)
return values
`),
).toEqual([1, 2, 3, 4])
})
test("closes a generator when for...of exits abruptly", async () => {
expect(
await value(`
const events = []
function* generate() {
try { yield 1; yield 2 } finally { events.push("closed") }
}
for (const item of generate()) break
return events
`),
).toEqual(["closed"])
})
test("async generator requests are promises and execute in request order", async () => {
expect(
await value(`
const events = []
async function* generate() {
events.push("start")
const input = yield Promise.resolve(1)
events.push("received " + input)
return Promise.resolve(3)
}
const iterator = generate()
const first = iterator.next()
const second = iterator.next(2)
const third = iterator.next(4)
const promiseFlags = [first instanceof Promise, second instanceof Promise, third instanceof Promise]
return [promiseFlags, await Promise.all([first, second, third]), events]
`),
).toEqual([
[true, true, true],
[
{ value: 1, done: false },
{ value: 3, done: true },
{ value: null, done: true },
],
["start", "received 2"],
])
})
test("keeps requests queued while a completed generator adopts return values", async () => {
expect(
await value(`
const events = []
let resolve
const pending = new Promise((done) => { resolve = done })
async function* generate() { return 1 }
const iterator = generate()
const first = iterator.next()
const returned = iterator.return(pending)
const later = first.then(() => iterator.next()).then(() => events.push("later"))
returned.then(() => events.push("returned"))
await first
await Promise.resolve()
const before = events.slice()
resolve(9)
await Promise.all([returned, later])
return [before, events]
`),
).toEqual([[], ["returned", "later"]])
})
test("serializes requests made after async generator exhaustion", async () => {
expect(
await value(`
const events = []
let resolve
const pending = new Promise((done) => { resolve = done })
async function* generate() { return 1 }
const iterator = generate()
await iterator.next()
const returned = iterator.return(pending).then(() => events.push("returned"))
const later = iterator.next().then(() => events.push("later"))
await Promise.resolve()
const before = events.slice()
resolve(9)
await Promise.all([returned, later])
return [before, events]
`),
).toEqual([[], ["returned", "later"]])
})
test("async generators adopt yielded, returned, and return-request promises", async () => {
expect(
await value(`
async function* yielded() { yield Promise.resolve(1) }
async function* returned() { return Promise.resolve(2) }
async function* pending() { yield 0 }
const first = yielded()
const second = returned()
const third = pending()
const exhausted = returned()
await third.next()
await exhausted.next()
return await Promise.all([
first.next(),
second.next(),
third.return(Promise.resolve(3)),
exhausted.return(Promise.resolve(4)),
])
`),
).toEqual([
{ value: 1, done: false },
{ value: 2, done: true },
{ value: 3, done: true },
{ value: 4, done: true },
])
})
test("awaits return-request promises before injecting completion", async () => {
expect(
await value(`
const events = []
async function* generate() {
try {
yield 1
} catch (error) {
events.push("caught " + error)
yield "recovered"
} finally {
events.push("finally")
}
}
const iterator = generate()
const first = await iterator.next()
const returned = await iterator.return(Promise.reject("bad"))
const beforeNext = events.slice()
const last = await iterator.next()
return [first, returned, beforeNext, last, events]
`),
).toEqual([
{ value: 1, done: false },
{ value: "recovered", done: false },
["caught bad"],
{ value: null, done: true },
["caught bad", "finally"],
])
})
test("loop consumers call iterator next with no arguments", async () => {
expect(
await value(`
const calls = []
let syncStep = 0
const sync = {
[Symbol.iterator]: () => sync,
next(...args) {
calls.push(["sync", args.length])
syncStep += 1
return { value: syncStep, done: syncStep > 1 }
},
}
let asyncStep = 0
const asynchronous = {
[Symbol.asyncIterator]: () => asynchronous,
async next(...args) {
calls.push(["async", args.length])
asyncStep += 1
return { value: asyncStep, done: asyncStep > 1 }
},
}
for (const item of sync) {}
for await (const item of asynchronous) {}
return calls
`),
).toEqual([
["sync", 0],
["sync", 0],
["async", 0],
["async", 0],
])
})
test("supports async generators in for await...of and keeps them out of for...of", async () => {
expect(
await value(`
async function* generate() { yield 1; yield await Promise.resolve(2) }
const values = []
for await (const item of generate()) values.push(item)
let name
try { for (const item of generate()) {} } catch (error) { name = error.name }
return [values, name]
`),
).toEqual([[1, 2], "TypeError"])
})
test("keeps generator references opaque at the data boundary", async () => {
const result = await execute(`function* generate() { yield 1 } return generate()`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("InvalidDataValue")
})
// test/built-ins/GeneratorPrototype/return/from-state-suspended-start.js
// test/built-ins/GeneratorPrototype/throw/from-state-suspended-start.js
// test/built-ins/GeneratorPrototype/return/from-state-completed.js
// test/built-ins/GeneratorPrototype/throw/from-state-completed.js
test("honors sync return and throw in suspended-start and completed states", async () => {
expect(
await value(`
const events = []
function* generate() { events.push("body"); yield 1 }
const returned = generate()
const startReturn = returned.return(7)
const afterReturn = returned.next()
const thrown = generate()
let startThrow
try { thrown.throw("start") } catch (error) { startThrow = error }
const afterThrow = thrown.next()
let completedThrow
try { thrown.throw("completed") } catch (error) { completedThrow = error }
return [startReturn, afterReturn, startThrow, afterThrow, completedThrow, events]
`),
).toEqual([
{ value: 7, done: true },
{ value: null, done: true },
"start",
{ value: null, done: true },
"completed",
[],
])
})
// test/built-ins/AsyncGeneratorPrototype/return/return-suspendedStart-promise.js
// test/built-ins/AsyncGeneratorPrototype/throw/throw-suspendedStart.js
// test/built-ins/AsyncGeneratorPrototype/return/return-state-completed.js
// test/built-ins/AsyncGeneratorPrototype/throw/throw-state-completed.js
test("honors async return and throw in suspended-start and completed states", async () => {
expect(
await value(`
const events = []
async function* generate() { events.push("body"); yield 1 }
const returned = generate()
const startReturn = await returned.return(Promise.resolve(7))
const afterReturn = await returned.next()
const thrown = generate()
let startThrow
try { await thrown.throw("start") } catch (error) { startThrow = error }
const afterThrow = await thrown.next()
let completedThrow
try { await thrown.throw("completed") } catch (error) { completedThrow = error }
return [startReturn, afterReturn, startThrow, afterThrow, completedThrow, events]
`),
).toEqual([
{ value: 7, done: true },
{ value: null, done: true },
"start",
{ value: null, done: true },
"completed",
[],
])
})
// test/built-ins/AsyncGeneratorPrototype/return/return-suspendedYield-try-finally.js
// test/built-ins/AsyncGeneratorPrototype/return/return-suspendedYield-try-finally-return.js
// test/built-ins/AsyncGeneratorPrototype/throw/throw-suspendedYield-try-finally-throw.js
test("runs finally yields and lets finally completions override requests", async () => {
expect(
await value(`
async function* yielding() {
try { yield 1 } finally { yield 2 }
}
async function* returning() {
try { yield 1 } finally { return "override" }
}
async function* throwing() {
try { yield 1 } finally { throw "override" }
}
const first = yielding()
await first.next()
const finallyYield = await first.return("sent")
const preservedReturn = await first.next()
const second = returning()
await second.next()
const overriddenReturn = await second.return("sent")
const third = throwing()
await third.next()
let overriddenThrow
try { await third.throw("sent") } catch (error) { overriddenThrow = error }
return [finallyYield, preservedReturn, overriddenReturn, overriddenThrow]
`),
).toEqual([{ value: 2, done: false }, { value: "sent", done: true }, { value: "override", done: true }, "override"])
})
// test/language/statements/async-generator/yield-promise-reject-next-catch.js
// test/language/statements/async-generator/yield-promise-reject-next-yield-star-sync-iterator.js
test("rejects yielded promises and closes direct and sync-delegating async generators", async () => {
expect(
await value(`
async function* direct() { yield Promise.reject("direct") }
async function* delegated() { yield* [Promise.reject("delegated"), "unreachable"] }
const results = []
for (const iterator of [direct(), delegated()]) {
try { await iterator.next() } catch (error) { results.push(error) }
results.push(await iterator.next())
}
return results
`),
).toEqual(["direct", { value: null, done: true }, "delegated", { value: null, done: true }])
})
// test/built-ins/AsyncFromSyncIteratorPrototype/next/for-await-iterator-next-rejected-promise-close.js
// test/built-ins/AsyncFromSyncIteratorPrototype/next/yield-iterator-next-rejected-promise-close.js
// test/built-ins/AsyncFromSyncIteratorPrototype/throw/iterator-result-rejected-promise-close.js
test("closes sync iterators when async-from-sync values reject", async () => {
expect(
await value(`
const events = []
function* loopSource() {
try { yield Promise.reject("loop") } finally { events.push("loop close") }
}
let loopError
try { for await (const item of loopSource()) {} } catch (error) { loopError = error }
function* yieldSource() {
try { yield Promise.reject("yield") } finally { events.push("yield close") }
}
async function* delegate() { yield* yieldSource() }
let yieldError
try { await delegate().next() } catch (error) { yieldError = error }
const throwing = {
[Symbol.iterator]: () => throwing,
next: () => ({ value: 1, done: false }),
throw: () => ({ value: Promise.reject("throw"), done: false }),
return: () => { events.push("throw close"); return {} },
}
async function* throwDelegate() { yield* throwing }
const iterator = throwDelegate()
await iterator.next()
let throwError
try { await iterator.throw("sent") } catch (error) { throwError = error }
return [loopError, yieldError, throwError, events]
`),
).toEqual(["loop", "yield", "throw", ["loop close", "yield close", "throw close"]])
})
test("does not close rejected terminal or delegated return values", async () => {
expect(
await value(`
const events = []
const terminal = {
[Symbol.iterator]: () => terminal,
next: () => ({ value: Promise.reject("terminal"), done: true }),
return: () => { events.push("terminal close"); return {} },
}
let terminalError
try { for await (const item of terminal) {} } catch (error) { terminalError = error }
let returnCount = 0
const returned = {
[Symbol.iterator]: () => returned,
next: () => ({ value: 1, done: false }),
return: () => {
returnCount += 1
return { value: Promise.reject("return"), done: false }
},
}
async function* delegate() { yield* returned }
const iterator = delegate()
await iterator.next()
let returnError
try { await iterator.return("sent") } catch (error) { returnError = error }
return [terminalError, returnError, returnCount, events]
`),
).toEqual(["terminal", "return", 1, []])
})
test("serializes a mixed async next, throw, return, and next request queue", async () => {
expect(
await value(`
async function* generate() {
try {
try { yield 1; yield 2 } catch (error) { yield "caught " + error }
} finally {
yield "finally"
}
}
const iterator = generate()
const first = iterator.next()
const thrown = iterator.throw("x")
const returned = iterator.return("done")
const last = iterator.next()
return await Promise.all([first, thrown, returned, last])
`),
).toEqual([
{ value: 1, done: false },
{ value: "caught x", done: false },
{ value: "finally", done: false },
{ value: "done", done: true },
])
})
// test/built-ins/AsyncGeneratorPrototype/next/request-queue-order.js
// test/built-ins/AsyncGeneratorPrototype/throw/request-queue-order-state-executing.js
// test/built-ins/AsyncGeneratorPrototype/return/request-queue-order-state-executing.js
test("orders async requests enqueued while generators are executing", async () => {
expect(
await value(`
const events = []
let returned, returnRequest
async function* returnWhileExecuting() {
returnRequest = returned.return(42).then((result) => events.push(["return", result]))
yield 1
}
returned = returnWhileExecuting()
const firstReturn = returned.next().then((result) => events.push(["first return", result]))
await Promise.all([firstReturn, returnRequest])
let thrown, throwRequest
async function* throwWhileExecuting() {
throwRequest = thrown.throw("boom").catch((error) => events.push(["throw", error]))
yield 2
}
thrown = throwWhileExecuting()
const firstThrow = thrown.next().then((result) => events.push(["first throw", result]))
await Promise.all([firstThrow, throwRequest])
async function* queued() { yield "first"; yield "second" }
const iterator = queued()
const first = iterator.next()
const second = iterator.next()
const third = iterator.next()
await Promise.all([
third.then(() => events.push("third")),
second.then(() => events.push("second")),
first.then(() => events.push("first")),
])
return events
`),
).toEqual([
["first return", { value: 1, done: false }],
["return", { value: 42, done: true }],
["first throw", { value: 2, done: false }],
["throw", "boom"],
"first",
"second",
"third",
])
})
// test/language/statements/async-generator/yield-star-promise-not-unwrapped.js
// test/language/statements/async-generator/yield-star-sync-next.js
test("preserves async iterator promise values but unwraps async-from-sync values", async () => {
expect(
await value(`
const asyncValue = Promise.resolve("async")
const asynchronous = {
[Symbol.asyncIterator]: () => asynchronous,
next: () => ({ value: asyncValue, done: false }),
}
const syncValue = Promise.resolve("sync")
const synchronous = {
[Symbol.iterator]: () => synchronous,
next: () => ({ value: syncValue, done: false }),
}
async function* delegate(value) { yield* value }
const asyncResult = await delegate(asynchronous).next()
const syncResult = await delegate(synchronous).next()
return [asyncResult.value === asyncValue, await asyncResult.value, syncResult]
`),
).toEqual([true, "async", { value: "sync", done: false }])
})
test("captures delegated result done before awaiting async-from-sync values", async () => {
expect(
await value(`
const result = { value: Promise.resolve(1), done: false }
result.value.then(() => { result.done = true })
const source = {
[Symbol.iterator]: () => source,
next: () => result,
}
async function* delegate() { yield* source }
return await delegate().next()
`),
).toEqual({ value: 1, done: false })
})
// test/language/statements/async-generator/yield-star-async-next.js
// test/language/statements/async-generator/yield-star-sync-return.js
// test/language/statements/async-generator/yield-star-async-throw.js
test("forwards next, return, and throw through sync and async yield delegates", async () => {
expect(
await value(`
const calls = []
const make = (symbol, label) => {
let step = 0
const iterator = {
[symbol]: () => iterator,
next(...args) {
calls.push([label, "next", args.length, args[0]])
step += 1
return { value: step, done: false }
},
return(value) {
calls.push([label, "return", value])
return { value: value + "!", done: true }
},
throw(value) {
calls.push([label, "throw", value])
return { value: "caught " + value, done: false }
},
}
return iterator
}
async function* delegate(iterator) { return yield* iterator }
const sync = delegate(make(Symbol.iterator, "sync"))
const asynchronous = delegate(make(Symbol.asyncIterator, "async"))
const results = [await sync.next(9), await sync.next(2), await sync.throw("x"), await sync.return("stop")]
results.push(await asynchronous.next(9), await asynchronous.next(3), await asynchronous.throw("y"))
return [results, calls]
`),
).toEqual([
[
{ value: 1, done: false },
{ value: 2, done: false },
{ value: "caught x", done: false },
{ value: "stop!", done: true },
{ value: 1, done: false },
{ value: 2, done: false },
{ value: "caught y", done: false },
],
[
["sync", "next", 1, null],
["sync", "next", 1, 2],
["sync", "throw", "x"],
["sync", "return", "stop"],
["async", "next", 1, null],
["async", "next", 1, 3],
["async", "throw", "y"],
],
])
})
// test/language/statements/async-generator/yield-star-sync-return.js
// test/language/statements/async-generator/yield-star-async-throw.js
test("continues delegation when return and throw report done false", async () => {
expect(
await value(`
let returns = 0
const synchronous = {
[Symbol.iterator]: () => synchronous,
next: () => ({ value: "next", done: false }),
return(value) {
returns += 1
return { value: returns === 1 ? "return pending" : value, done: returns > 1 }
},
}
let throws = 0
const asynchronous = {
[Symbol.asyncIterator]: () => asynchronous,
next: () => ({ value: "next", done: false }),
throw(value) {
throws += 1
return { value: throws === 1 ? "throw pending" : value, done: throws > 1 }
},
}
async function* delegate(iterator) { return yield* iterator }
const returned = delegate(synchronous)
const thrown = delegate(asynchronous)
return [
await returned.next(),
await returned.return("first"),
await returned.return("second"),
await thrown.next(),
await thrown.throw("first"),
await thrown.throw("second"),
]
`),
).toEqual([
{ value: "next", done: false },
{ value: "return pending", done: false },
{ value: "second", done: true },
{ value: "next", done: false },
{ value: "throw pending", done: false },
{ value: "second", done: true },
])
})
// test/language/expressions/yield/star-rhs-iter-nrml-next-call-non-obj.js
// test/language/expressions/yield/star-rhs-iter-thrw-thrw-call-non-obj.js
// test/language/expressions/yield/star-rhs-iter-rtrn-rtrn-call-non-obj.js
// test/language/statements/async-generator/yield-star-next-not-callable-number-throw.js
test("rejects malformed yield delegate methods and iterator results", async () => {
expect(
await value(`
const run = (iterator, operation) => {
function* generate() {
try { yield* iterator } catch (error) { return error.name }
}
const value = generate()
const first = value.next()
return operation === "next" ? first : value[operation]()
}
const iterable = (fields) => ({ [Symbol.iterator]: () => fields })
const nextResult = run(iterable({ next: () => 1 }), "next")
const throwResult = run(iterable({ next: () => ({ done: false }), throw: () => 1 }), "throw")
const returnResult = run(iterable({ next: () => ({ done: false }), return: () => 1 }), "return")
const badAsync = {
[Symbol.asyncIterator]: () => ({ next: 1 }),
}
async function* asynchronous() {
try { yield* badAsync } catch (error) { return error.name }
}
return [nextResult, throwResult, returnResult, await asynchronous().next()]
`),
).toEqual([
{ value: "TypeError", done: true },
{ value: "TypeError", done: true },
{ value: "TypeError", done: true },
{ value: "TypeError", done: true },
])
})
// test/language/expressions/yield/captured-free-vars.js
// test/language/statements/generators/dflt-params-ref-prior.js
// test/language/expressions/generators/dflt-params-ref-prior.js
// test/language/expressions/object/method-definition/generator-no-yield.js
test("supports declaration, expression, and method forms with closures and parameters", async () => {
expect(
await value(`
const captured = 4
function* declaration(x, y = x, ...rest) { yield captured + y + rest[0] }
const expression = function* (x, y = x) { yield captured + y }
const object = { *method({ value }, extra = 1) { return captured + value + extra } }
return [declaration(2, undefined, 3).next(), expression(5).next(), object.method({ value: 6 }).next()]
`),
).toEqual([
{ value: 9, done: false },
{ value: 9, done: false },
{ value: 11, done: true },
])
})
// test/language/expressions/assignment/dstr/array-elem-iter-nrml-close.js
test("steps holes and rest and closes array binding and assignment patterns early", async () => {
expect(
await value(`
const events = []
function* binding() {
try { events.push("b1"); yield 1; events.push("b2"); yield 2; events.push("b3"); yield 3; yield 4 }
finally { events.push("binding close") }
}
const [first, , third] = binding()
function* assignment() {
try { yield 5; yield 6; yield 7 }
finally { events.push("assignment close") }
}
let head, rest
;[head, ...rest] = assignment()
return [first, third, head, rest, events]
`),
).toEqual([1, 3, 5, [6, 7], ["b1", "b2", "b3", "binding close", "assignment close"]])
})
// test/language/statements/variable/dstr/ary-ptrn-elem-id-init-throws.js
// test/language/expressions/assignment/dstr/array-elem-iter-thrw-close-err.js
test("closes on destructuring defaults and preserves the binding error over return failure", async () => {
expect(
await value(`
const events = []
const iterator = {
[Symbol.iterator]: () => iterator,
next: () => ({ value: undefined, done: false }),
return: () => { events.push("close"); throw "close error" },
}
let caught
try {
const [value = (() => { throw "binding error" })()] = iterator
} catch (error) { caught = error }
return [caught, events]
`),
).toEqual(["binding error", ["close"]])
})
test("does not close an exhausted iterator when a destructuring default fails", async () => {
expect(
await value(`
const events = []
const iterator = {
[Symbol.iterator]: () => iterator,
next: () => ({ done: true }),
return: () => { events.push("close"); return {} },
}
try { const [item = (() => { throw "default" })()] = iterator } catch {}
return events
`),
).toEqual([])
})
test("consumes generators in array and argument spread without awaiting yielded promises", async () => {
expect(
await value(`
function* values() { yield 1; yield Promise.resolve(2); yield 3 }
const array = [...values()]
const args = ((...items) => items)(...values())
return [array[0], array[1] instanceof Promise, await array[1], args[2]]
`),
).toEqual([1, true, 2, 3])
})
test("constructs Map, Set, and URLSearchParams from generators lazily", async () => {
expect(
await value(`
const events = []
function* pairs() { events.push(1); yield ["a", 1]; events.push(2); yield ["b", 2] }
function* values() { yield 1; yield 1; yield 2 }
const map = new Map(pairs())
const set = new Set(values())
const params = new URLSearchParams(pairs())
return [map.get("b"), [...set], params.toString(), events]
`),
).toEqual([2, [1, 2], "a=1&b=2", [1, 2, 1, 2]])
})
test("keeps built-in collection iteration live during callbacks", async () => {
expect(
await value(`
const map = new Map([[1, 1], [2, 2]])
const mapped = Array.from(map, (entry, index) => {
if (index === 0) map.set(3, 3)
return entry[0]
})
const set = new Set([1, 2])
const grouped = Map.groupBy(set, (item, index) => {
if (index === 0) set.add(3)
return "items"
})
return [mapped, grouped.get("items")]
`),
).toEqual([
[1, 2, 3],
[1, 2, 3],
])
})
test("keeps built-in collection iteration live in loops and yield delegation", async () => {
expect(
await value(`
const map = new Map([[1, 1], [2, 2]])
const mapValues = []
for (const [key] of map) {
mapValues.push(key)
if (key === 1) map.set(3, 3)
}
const set = new Set([1, 2])
const setValues = []
for await (const item of set) {
setValues.push(item)
if (item === 1) set.add(3)
}
const params = new URLSearchParams("a=1&b=2")
function* delegate() { yield* params }
const iterator = delegate()
const first = iterator.next()
params.append("c", "3")
return [mapValues, setValues, first, iterator.next(), iterator.next()]
`),
).toEqual([
[1, 2, 3],
[1, 2, 3],
{ value: ["a", "1"], done: false },
{ value: ["b", "2"], done: false },
{ value: ["c", "3"], done: false },
])
})
test("preserves async-from-sync turns for built-in loop completion and close", async () => {
expect(
await value(`
const completed = []
Promise.resolve().then(() => completed.push("reaction"))
for await (const item of []) {}
completed.push("after")
const closed = []
for await (const item of [1]) {
Promise.resolve().then(() => closed.push("reaction"))
break
}
closed.push("after")
return [completed, closed]
`),
).toEqual([
["reaction", "after"],
["reaction", "after"],
])
})
test("reads iterator return only when closing", async () => {
expect(
await value(`
const events = []
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
iterator.return = () => { events.push("new"); return {} }
return { value: 1, done: false }
},
return: () => { events.push("old"); return {} },
}
const [item] = iterator
return [item, events]
`),
).toEqual([1, ["new"]])
})
test("accepts iterable URLSearchParams entry pairs", async () => {
expect(
await value(`
function* pair() { yield "a"; yield 1 }
function* entries() { yield pair() }
return new URLSearchParams(entries()).toString()
`),
).toBe("a=1")
})
test("converts URLSearchParams pair elements before requesting the next", async () => {
expect(
await value(`
const events = []
function* pair() {
try {
events.push("first")
yield (function* () {})()
events.push("second")
yield 2
} finally { events.push("pair close") }
}
function* entries() {
try { yield pair() } finally { events.push("outer close") }
}
let name
try { new URLSearchParams(entries()) } catch (error) { name = error.name }
return [events, name]
`),
).toEqual([["first", "pair close", "outer close"], "Error"])
})
test("validates URLSearchParams pair lengths after converting the outer sequence", async () => {
expect(
await value(`
const events = []
function* entries() {
try {
events.push("one")
yield ["a"]
events.push("two")
yield ["b", 2]
} finally { events.push("outer close") }
}
let name
try { new URLSearchParams(entries()) } catch (error) { name = error.name }
return [events, name]
`),
).toEqual([["one", "two", "outer close"], "TypeError"])
})
test("closes entry constructors when a generator yields a malformed entry", async () => {
expect(
await value(`
const events = []
function* mapEntries() { try { yield ["a", 1]; yield null } finally { events.push("map close") } }
function* parameterEntries() { try { yield ["a"]; yield ["b", 2] } finally { events.push("params close") } }
const names = []
try { new Map(mapEntries()) } catch (error) { names.push(error.name) }
try { new URLSearchParams(parameterEntries()) } catch (error) { names.push(error.name) }
return [names, events]
`),
).toEqual([
["TypeError", "TypeError"],
["map close", "params close"],
])
})
// test/built-ins/Array/from/iter-map-fn-args.js
// test/built-ins/Array/from/iter-map-fn-err.js
test("interleaves Array.from mapping and closes when its mapper fails", async () => {
expect(
await value(`
const events = []
function* source() {
try { events.push("next 1"); yield 1; events.push("next 2"); yield 2 }
finally { events.push("close") }
}
const mapped = Array.from(source(), (item) => { events.push("map " + item); return item * 2 })
let caught
try { Array.from(source(), (item) => { throw "mapper " + item }) } catch (error) { caught = error }
return [mapped, caught, events]
`),
).toEqual([[2, 4], "mapper 1", ["next 1", "map 1", "next 2", "map 2", "close", "next 1", "close"]])
})
test("reads array-like Array.from values immediately before mapping", async () => {
expect(
await value(`
const source = { 0: 1, 1: 2, length: 2 }
return Array.from(source, (item, index) => {
if (index === 0) source[1] = 9
return item
})
`),
).toEqual([1, 9])
})
test("interleaves Object.groupBy and Map.groupBy callbacks and closes on callback failure", async () => {
expect(
await value(`
const events = []
function* source() { try { events.push("next"); yield 1; events.push("next"); yield 2 } finally { events.push("close") } }
const object = Object.groupBy(source(), (item) => { events.push("object " + item); return item % 2 })
const map = Map.groupBy(source(), (item) => { events.push("map " + item); return item % 2 })
let caught
try { Object.groupBy(source(), () => { throw "callback" }) } catch (error) { caught = error }
return [object, Object.fromEntries(map), caught, events]
`),
).toEqual([
{ 0: [2], 1: [1] },
{ 0: [2], 1: [1] },
"callback",
["next", "object 1", "next", "object 2", "close", "next", "map 1", "next", "map 2", "close", "next", "close"],
])
})
test("consumes Promise combinator generators in order and observes rejections", async () => {
expect(
await value(`
function* items() { yield Promise.resolve(1); yield Promise.reject("bad"); yield 3 }
const settled = await Promise.allSettled(items())
let all, any
try { await Promise.all(items()) } catch (error) { all = error }
try { await Promise.any((function* () { yield Promise.reject("a"); yield Promise.reject("b") })()) }
catch (error) { any = error.errors }
const race = await Promise.race((function* () { yield 4; yield Promise.resolve(5) })())
return [settled, all, any, race]
`),
).toEqual([
[
{ status: "fulfilled", value: 1 },
{ status: "rejected", reason: "bad" },
{ status: "fulfilled", value: 3 },
],
"bad",
["a", "b"],
4,
])
})
test("finishes Promise combinator iterator consumption before returning the promise", async () => {
expect(
await value(`
const events = []
function* items() { events.push("first"); yield 1; events.push("second"); yield 2 }
const promise = Promise.all(items())
events.push("after call")
await promise
return events
`),
).toEqual(["first", "second", "after call"])
})
// test/built-ins/Object/fromEntries/iterator-closed-for-null-entry.js
test("closes Object.fromEntries on malformed entries and consumes valid generators", async () => {
expect(
await value(`
const events = []
function* valid() { yield ["a", 1]; yield ["b", 2] }
function* invalid() { try { yield ["a", 1]; yield null; yield ["c", 3] } finally { events.push("close") } }
let name
try { Object.fromEntries(invalid()) } catch (error) { name = error.name }
return [Object.fromEntries(valid()), name, events]
`),
).toEqual([{ a: 1, b: 2 }, "TypeError", ["close"]])
})
test("consumes AggregateError and Math.sumPrecise generators and closes on invalid numbers", async () => {
expect(
await value(`
const events = []
function* errors() { yield "a"; yield "b" }
function* numbers() { yield 1e30; yield 0.1; yield -1e30 }
function* invalid() { try { yield 1; yield "bad"; yield 2 } finally { events.push("close") } }
const aggregate = new AggregateError(errors(), "message")
let name
try { Math.sumPrecise(invalid()) } catch (error) { name = error.name }
return [aggregate.errors, aggregate.message, Math.sumPrecise(numbers()), name, events]
`),
).toEqual([["a", "b"], "message", 0.1, "TypeError", ["close"]])
})
test("rejects async generators in every synchronous iterable consumer", async () => {
expect(
await value(`
async function* source() { yield ["a", 1] }
const checks = [
() => [...source()],
() => ((...items) => items)(...source()),
() => { const [item] = source(); return item },
() => Array.from(source()),
() => new Map(source()),
() => new Set(source()),
() => new URLSearchParams(source()),
() => Object.fromEntries(source()),
() => Object.groupBy(source(), (item) => item),
() => Math.sumPrecise(source()),
() => new AggregateError(source()),
]
const names = []
for (const check of checks) {
try { check() } catch (error) { names.push(error.name) }
}
try { await Promise.all(source()) } catch (error) { names.push(error.name) }
return names
`),
).toEqual(Array(12).fill("TypeError"))
})
// test/built-ins/Array/from/iter-get-iter-err.js
// test/built-ins/Array/from/iter-adv-err.js
test("does not close when iterator acquisition or next-result validation fails", async () => {
expect(
await value(`
const events = []
const acquisition = { [Symbol.iterator]: () => { events.push("acquire"); throw "acquisition" } }
const malformed = {
[Symbol.iterator]: () => malformed,
next: () => { events.push("next"); return 1 },
return: () => { events.push("close"); return {} },
}
for (const source of [acquisition, malformed]) {
try { Array.from(source) } catch {}
}
return events
`),
).toEqual(["acquire", "next"])
})
test("reports synchronous iterator failures before queued promise reactions", async () => {
expect(
await value(`
const events = []
Promise.resolve().then(() => events.push("reaction"))
const iterator = {
[Symbol.iterator]: () => iterator,
next: () => { throw "next" },
}
try { Array.from(iterator) } catch { events.push("catch") }
await Promise.resolve()
return events
`),
).toEqual(["catch", "reaction"])
})
})
+12
View File
@@ -717,6 +717,18 @@ describe("destructuring assignment", () => {
).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
})
test("excludes computed numeric keys from object rest", async () => {
expect(
await value(`
const { [0]: declared, ...declarationRest } = { 0: "a", 1: "b" }
let assigned
let assignmentRest
;({ [0]: assigned, ...assignmentRest } = { 0: "c", 1: "d" })
return { declared, declarationRest, assigned, assignmentRest }
`),
).toEqual({ declared: "a", declarationRest: { 1: "b" }, assigned: "c", assignmentRest: { 1: "d" } })
})
test("rejects computed keys that are not confined property keys", async () => {
const err = await error(`const key = {}; const { [key]: value } = {}`)
expect(err.message).toContain("Property key must be a string or number")
+1
View File
@@ -358,6 +358,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "المزود {{provider}} غير مدعوم",
"zen.api.error.missingApiKey": "مفتاح API مفقود.",
"zen.api.error.invalidApiKey": "مفتاح API غير صالح.",
"zen.api.error.requestBlockedByUpstreamProvider": "حظر المزود الخارجي الطلب.",
"zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}",
+1
View File
@@ -366,6 +366,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Provedor {{provider}} não suportado",
"zen.api.error.missingApiKey": "Chave de API ausente.",
"zen.api.error.invalidApiKey": "Chave de API inválida.",
"zen.api.error.requestBlockedByUpstreamProvider": "Solicitação bloqueada pelo provedor upstream.",
"zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}",
+1
View File
@@ -362,6 +362,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Udbyder {{provider}} understøttes ikke",
"zen.api.error.missingApiKey": "Manglende API-nøgle.",
"zen.api.error.invalidApiKey": "Ugyldig API-nøgle.",
"zen.api.error.requestBlockedByUpstreamProvider": "Anmodningen blev blokeret af upstream-udbyderen.",
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}",
+1
View File
@@ -365,6 +365,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Anbieter {{provider}} wird nicht unterstützt",
"zen.api.error.missingApiKey": "Fehlender API-Key.",
"zen.api.error.invalidApiKey": "Ungültiger API-Key.",
"zen.api.error.requestBlockedByUpstreamProvider": "Anfrage vom vorgelagerten Anbieter blockiert.",
"zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}",
+1
View File
@@ -360,6 +360,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
"zen.api.error.missingApiKey": "Missing API key.",
"zen.api.error.invalidApiKey": "Invalid API key.",
"zen.api.error.requestBlockedByUpstreamProvider": "Request blocked by upstream provider.",
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
+1
View File
@@ -366,6 +366,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Proveedor {{provider}} no soportado",
"zen.api.error.missingApiKey": "Falta la clave API.",
"zen.api.error.invalidApiKey": "Clave API inválida.",
"zen.api.error.requestBlockedByUpstreamProvider": "El proveedor externo bloqueó la solicitud.",
"zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}",
+1
View File
@@ -366,6 +366,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Fournisseur {{provider}} non pris en charge",
"zen.api.error.missingApiKey": "Clé API manquante.",
"zen.api.error.invalidApiKey": "Clé API invalide.",
"zen.api.error.requestBlockedByUpstreamProvider": "Requête bloquée par le fournisseur en amont.",
"zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}",
+1
View File
@@ -362,6 +362,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Provider {{provider}} non supportato",
"zen.api.error.missingApiKey": "Chiave API mancante.",
"zen.api.error.invalidApiKey": "Chiave API non valida.",
"zen.api.error.requestBlockedByUpstreamProvider": "Richiesta bloccata dal provider upstream.",
"zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}",
+1
View File
@@ -362,6 +362,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "プロバイダー {{provider}} はサポートされていません",
"zen.api.error.missingApiKey": "APIキーがありません。",
"zen.api.error.invalidApiKey": "無効なAPIキーです。",
"zen.api.error.requestBlockedByUpstreamProvider": "上流プロバイダーによりリクエストがブロックされました。",
"zen.api.error.subscriptionQuotaExceeded":
"サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。",
"zen.api.error.goSubscriptionRollingLimitExceeded":
+1
View File
@@ -357,6 +357,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "{{provider}} 제공자는 지원되지 않습니다",
"zen.api.error.missingApiKey": "API 키가 누락되었습니다.",
"zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.",
"zen.api.error.requestBlockedByUpstreamProvider": "상위 제공자가 요청을 차단했습니다.",
"zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}",
+1
View File
@@ -363,6 +363,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Leverandør {{provider}} støttes ikke",
"zen.api.error.missingApiKey": "Mangler API-nøkkel.",
"zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.",
"zen.api.error.requestBlockedByUpstreamProvider": "Forespørselen ble blokkert av leverandøren.",
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}",
+1
View File
@@ -364,6 +364,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Dostawca {{provider}} nie jest obsługiwany",
"zen.api.error.missingApiKey": "Brak klucza API.",
"zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.",
"zen.api.error.requestBlockedByUpstreamProvider": "Żądanie zablokowane przez dostawcę zewnętrznego.",
"zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}",
+1
View File
@@ -368,6 +368,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не поддерживается",
"zen.api.error.missingApiKey": "Отсутствует API ключ.",
"zen.api.error.invalidApiKey": "Неверный API ключ.",
"zen.api.error.requestBlockedByUpstreamProvider": "Запрос заблокирован вышестоящим провайдером.",
"zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}",
+1
View File
@@ -359,6 +359,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "ไม่รองรับผู้ให้บริการ {{provider}}",
"zen.api.error.missingApiKey": "ไม่มี API key",
"zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง",
"zen.api.error.requestBlockedByUpstreamProvider": "คำขอถูกบล็อกโดยผู้ให้บริการต้นทาง",
"zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}",
+1
View File
@@ -366,6 +366,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "{{provider}} sağlayıcısı desteklenmiyor",
"zen.api.error.missingApiKey": "API anahtarı eksik.",
"zen.api.error.invalidApiKey": "Geçersiz API anahtarı.",
"zen.api.error.requestBlockedByUpstreamProvider": "İstek üst sağlayıcı tarafından engellendi.",
"zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}",
+1
View File
@@ -363,6 +363,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не підтримується",
"zen.api.error.missingApiKey": "Відсутній ключ API.",
"zen.api.error.invalidApiKey": "Недійсний ключ API.",
"zen.api.error.requestBlockedByUpstreamProvider": "Запит заблоковано зовнішнім провайдером.",
"zen.api.error.subscriptionQuotaExceeded": "Перевищено квоту підписки. Повторіть через {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded":
"Досягнуто 5-годинного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",

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