Compare commits

..
401 changed files with 5021 additions and 9872 deletions
-1
View File
@@ -184,7 +184,6 @@ const table = sqliteTable("session", {
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
+82 -39
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -87,6 +87,7 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
@@ -99,6 +100,7 @@
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
@@ -111,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.11",
"version": "2.0.8",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,10 +203,11 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -214,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -250,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -277,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -294,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -318,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -338,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,12 +409,14 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-store": "11.0.2",
"electron-updater": "6.8.9",
"electron-window-state": "^5.0.3",
"lighthouse": "13.4.1",
},
"devDependencies": {
@@ -455,7 +460,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +497,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +513,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -527,7 +532,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +545,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +559,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +574,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -608,7 +613,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +643,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +658,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +682,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +703,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -720,7 +725,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +760,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -775,7 +780,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -809,7 +814,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -828,7 +833,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -874,7 +879,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +893,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +928,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +963,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +996,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1032,7 +1037,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.11",
"version": "2.0.8",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3338,6 +3343,8 @@
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="],
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
@@ -3632,6 +3639,8 @@
"condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="],
"conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
"configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="],
@@ -3770,6 +3779,8 @@
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
"debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@@ -3896,12 +3907,16 @@
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
"electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.411", "", {}, "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg=="],
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
"electron-vite": ["electron-vite@6.0.0-beta.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^7.0.0", "esbuild": "^0.25.11", "magic-string": "^0.30.21", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-jltST77AwNxIeTTDtYhnIEA8ZM0RW9jmSJcqS8x/dQcgeeLlxlcJoYNNJ1tv7/Swor0AbXMYteBGdezoAOt+Nw=="],
"electron-window-state": ["electron-window-state@5.0.3", "", { "dependencies": { "jsonfile": "^4.0.0", "mkdirp": "^0.5.1" } }, "sha512-1mNTwCfkolXl3kMf50yW3vE2lZj0y92P/HYWFBrb+v2S/pCka5mdwN3cagKm458A7NjndSwijynXgcLWRodsVg=="],
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
@@ -4446,6 +4461,8 @@
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
@@ -4456,7 +4473,7 @@
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
@@ -4718,6 +4735,8 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
@@ -5472,6 +5491,8 @@
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@@ -5596,6 +5617,8 @@
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="],
"ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="],
@@ -6422,6 +6445,10 @@
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
"conf/dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
@@ -6462,6 +6489,8 @@
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"electron-store/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
"electron-updater/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -6492,6 +6521,8 @@
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
@@ -6894,12 +6925,18 @@
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
"@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
@@ -6976,6 +7013,8 @@
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="],
@@ -7354,6 +7393,8 @@
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"conf/dot-prop/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
@@ -7378,8 +7419,6 @@
"electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron/@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
@@ -7528,6 +7567,8 @@
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
@@ -7536,6 +7577,8 @@
"vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"workbox-build/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.11",
"version": "2.0.8",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -12,7 +12,7 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
## Tests
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.11",
"version": "2.0.8",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
const Options = Schema.Struct({
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
thinkingBudget: Schema.optional(Schema.Int),
preserveThinking: Schema.optional(Schema.Boolean),
@@ -19,7 +19,7 @@ const Options = Schema.Struct({
}),
),
toolStream: Schema.optional(Schema.Boolean),
parallelToolCalls: Schema.optional(Schema.Boolean),
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
repetitionPenalty: Schema.optional(Schema.Number),
responseFormat: Schema.optional(
Schema.Struct({
@@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const Options = Schema.Struct({
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
store: Schema.optional(Schema.Boolean),
store: OpenResponsesOptions.Options.fields.store,
previousResponseId: Schema.optional(Schema.String),
conversation: Schema.optional(Schema.String),
})
+167 -79
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -21,12 +21,11 @@ import {
type JsonSchema,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema/index.js"
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
@@ -53,10 +52,57 @@ const SSE_EVENTS = new Set([
])
export const framing = Framing.sseEvents(SSE_EVENTS)
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
export type ThinkingInput = typeof Thinking.Encoded
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export type ThinkingBlockBinding = {
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
}
export type ThinkingInput =
| {
readonly type: "adaptive"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
}
| {
readonly type: "disabled"
}
| ({
readonly type: "enabled"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
} & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
export interface OptionsInput {
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
readonly contextManagement?: ContextManagement
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
readonly service_tier?: "auto" | "standard_only"
readonly serviceTier?: "auto" | "standard_only"
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?:
| string
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
readonly outputConfig?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
}
export type ProviderOptionsInput = OptionsInput
export const ContextManagement = Schema.Struct({
@@ -83,7 +129,6 @@ const AnthropicCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
const AnthropicTextBlock = Schema.Struct({
type: Schema.tag("text"),
@@ -272,21 +317,25 @@ const AnthropicToolChoice = Schema.Union([
])
const AnthropicThinkingBlockBinding = Schema.Struct({
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
prefix_mismatch_behavior: Schema.optional(Schema.String),
})
const AnthropicThinkingFields = {
display: Schema.optional(knownString<"summarized" | "omitted">()),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}
const AnthropicThinkingEnabled = Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
...AnthropicThinkingFields,
})
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("adaptive"),
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("disabled"),
}),
])
type AnthropicThinking = typeof AnthropicThinking.Type
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
@@ -311,53 +360,6 @@ const AnthropicContainer = Schema.Union([
}),
])
// =============================================================================
// Provider Options
// =============================================================================
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
// keys are disjoint per variant so the input type requires exactly one and the
// transform can narrow on it. Decoding straight to the wire block keeps the
// alias out of the rest of the file.
const ThinkingEnabledInput = Schema.Union([
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
]).pipe(
Schema.decodeTo(AnthropicThinkingEnabled, {
decode: SchemaGetter.transform((input) => ({
type: "enabled" as const,
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
display: input.display,
block_binding: input.block_binding,
})),
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
const OutputConfigInput = Schema.Struct({
effort: optionalNull(Schema.String),
format: optionalNull(AnthropicJsonOutputFormat),
})
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
const Options = Schema.Struct({
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
contextManagement: Schema.optional(ContextManagement),
thinking: Schema.optional(Thinking),
effort: Schema.optional(Schema.String),
service_tier: Schema.optional(AnthropicServiceTier),
serviceTier: Schema.optional(AnthropicServiceTier),
metadata: Schema.optional(AnthropicMetadata),
container: Schema.optional(AnthropicContainer),
inference_geo: optionalNull(Schema.String),
inferenceGeo: optionalNull(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
cacheControl: Schema.optional(AnthropicCacheControl),
output_config: Schema.optional(OutputConfigInput),
outputConfig: Schema.optional(OutputConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const AnthropicBodyFields = {
context_management: Schema.optional(
Schema.Struct({
@@ -389,7 +391,7 @@ const AnthropicBodyFields = {
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(AnthropicMetadata),
service_tier: Schema.optional(AnthropicServiceTier),
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -999,6 +1001,64 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier =
(input as Record<string, unknown> | undefined)?.service_tier ??
(input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as
| string
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
(input as Record<string, unknown> | undefined)?.inferenceGeo
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
const rawCacheControl =
(input as Record<string, unknown> | undefined)?.cache_control ??
(input as Record<string, unknown> | undefined)?.cacheControl
const cache_control =
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
: undefined
const rawOutputConfig =
(input as Record<string, unknown> | undefined)?.output_config ??
(input as Record<string, unknown> | undefined)?.outputConfig
const outputConfigEffort =
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
? ((input as Record<string, unknown>).effort as string)
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
? (rawOutputConfig.effort as string)
: undefined
const outputConfigFormat =
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const thinking = yield* resolveThinking(input?.thinking)
return {
thinking: applyThinkingBindingDefault(request.model, thinking),
effort: outputConfigEffort,
format: outputConfigFormat,
service_tier,
metadata,
container,
inference_geo,
cache_control,
}
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
@@ -1037,12 +1097,35 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
}
}
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
const block_binding = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
)(input.block_binding)
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
const budget =
typeof input.budgetTokens === "number"
? input.budgetTokens
: typeof input.budget_tokens === "number"
? input.budget_tokens
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const options = yield* decodeOptions(request.providerOptions ?? {})
const management = options.contextManagement
const outputConfig = options.output_config ?? options.outputConfig
const format = outputConfig?.format ?? undefined
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = yield* resolveOptions(request)
const updates = resolveEffortUpdates(request, options.effort)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
@@ -1078,7 +1161,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
const output_config =
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
updates.effort === undefined && options.format === undefined
? undefined
: {
...(updates.effort === undefined ? {} : { effort: updates.effort }),
...(options.format === undefined ? {} : { format: options.format }),
}
const body = {
model: request.model.id,
system,
@@ -1091,14 +1179,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: applyThinkingBindingDefault(request.model, options.thinking),
thinking: options.thinking,
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control ?? options.cacheControl,
cache_control: options.cache_control,
container: options.container,
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
inference_geo: options.inference_geo,
metadata: options.metadata,
service_tier: options.service_tier ?? options.serviceTier,
service_tier: options.service_tier,
}
if (!management) return body
return {
+68 -45
View File
@@ -14,13 +14,12 @@ import {
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type TextPart,
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -51,8 +50,35 @@ const omitsFunctionCallIds = (modelID: string) => {
return match !== null && Number(match[1]) < 3
}
/** Caller-facing provider options; unknown keys are accepted and ignored. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
}
}
export type ProviderOptionsInput = OptionsInput
// =============================================================================
@@ -135,50 +161,17 @@ const GeminiToolConfig = Schema.Struct({
}),
})
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(GeminiThinkingLevel),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: knownString<
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
>(),
threshold: knownString<
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
>(),
category: Schema.String,
threshold: Schema.String,
})
// =============================================================================
// Provider Options
// =============================================================================
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
// object that omits `includeThoughts` asks for thoughts.
const GeminiThinkingConfigInput = Schema.Struct({
thinkingBudget: lenient(Schema.Number),
includeThoughts: lenient(Schema.Boolean),
thinkingLevel: lenient(GeminiThinkingLevel),
})
const Options = Schema.Struct({
cachedContent: lenient(Schema.String),
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
thinkingConfig: lenient(GeminiThinkingConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const GeminiGenerationConfig = Schema.Struct({
maxOutputTokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -438,11 +431,44 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
return contents
})
const resolveOptions = (request: LLMRequest) => {
const input = request.providerOptions
const value = input?.thinkingConfig
const thinkingConfig = {
thinkingBudget:
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
}
return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
}
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const flattened = ProviderShared.flattenToolRequest(request)
const hasTools = flattened.tools.length > 0
const generation = request.generation
const options = yield* decodeOptions(request.providerOptions ?? {})
const options = resolveOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
maxOutputTokens: generation?.maxTokens,
@@ -453,10 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
thinkingConfig:
options.thinkingConfig === undefined
? undefined
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
thinkingConfig: options.thinkingConfig,
}
return {
@@ -73,6 +73,11 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
)
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
@@ -108,7 +108,7 @@ const incremental = (
return input.slice(baseline.length)
}
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const rejected = (
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
+94 -28
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -333,13 +333,53 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put error details at the top level, under `error`, or under
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
// fields decode as opaque values and `errorDetail` reads them defensively, so an error frame can
// only fail on invalid JSON and otherwise always classifies with the raw body as the fallback.
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const asText = (value: unknown) =>
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
const OpenResponsesErrorObject = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("error"),
status: Schema.optional(Schema.Number),
status_code: Schema.optional(Schema.Number),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
export const decodeKnownErrorEvent = (event: Event) =>
decodeWebSocketErrorEvent({
...event,
status: typeof event.status === "number" ? event.status : undefined,
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
headers: ProviderShared.isRecord(event.headers)
? Object.fromEntries(
Object.entries(event.headers).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
),
)
: undefined,
})
export const Event = Schema.StructWithRest(
Schema.Struct({
@@ -360,18 +400,31 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: Schema.optional(Schema.Unknown),
error: optionalNull(OpenResponsesErrorPayload),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
code: Schema.optional(Schema.Unknown),
message: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
status: Schema.optional(Schema.Unknown),
status_code: Schema.optional(Schema.Unknown),
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
@@ -380,15 +433,16 @@ const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. Some providers and gateways answer a rejected `response.create` with a bare
* `{ "error": ... }` envelope and no event type; that reads as an error event so it classifies instead of
* failing decoding.
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
? { ...value, type: "error" }
: value,
),
@@ -1368,21 +1422,22 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...current, lifecycle }, events] satisfies StepResult
})
/** Error code and message from wherever the frame put them; top-level fields win over nested ones. */
export const errorDetail = (event: Event) => {
const raw = event.error ?? event.response?.error
const nested = typeof raw === "string" ? { message: raw } : ProviderShared.isRecord(raw) ? raw : undefined
return {
message: asText(event.message) ?? asText(nested?.message),
code: asText(event.code) ?? asText(nested?.code),
}
// Build the prettiest summary available from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code
}
// Prefix the code when both are present (`rate_limit_exceeded: Slow down`) so the failure mode is
// visible; fall back to the raw frame rather than a generic message when neither decodes.
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
const detail = errorDetail(event)
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
const nested = event.error ?? event.response?.error ?? undefined
const summary = providerErrorMessage(event, nested)
const message = summary ?? (body === "{}" ? fallback : body)
const status =
typeof event.status === "number"
@@ -1465,7 +1520,18 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
state.id,
`${state.name} returned a malformed error event`,
ProviderShared.encodeJson(event),
cause,
),
),
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
+1 -11
View File
@@ -1,6 +1,6 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode/schema/tool"
import { Effect, Option, Schema, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
@@ -29,16 +29,6 @@ const isJson = Schema.is(Schema.Json)
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
export const lenient = <const S extends Schema.Top>(schema: S) =>
Schema.optionalKey(
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
)
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
export const knownString = <Known extends string>() =>
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
expected: "string",
})
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
@@ -1,6 +1,5 @@
import { Schema } from "effect"
import { Option, Schema } from "effect"
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
import { lenient } from "../shared.js"
export { ReasoningEffort, ReasoningEfforts }
@@ -50,22 +49,21 @@ export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
export const Options = Schema.Struct({
store: lenient(Schema.Boolean),
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: lenient(Schema.String),
streamOptions: lenient(StreamOptions),
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: lenient(ReasoningEffort),
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
include: lenient(Schema.Array(ResponseIncludableSchema)),
textVerbosity: lenient(TextVerbositySchema),
serviceTier: lenient(ServiceTierSchema),
truncation: lenient(TruncationSchema),
allowedTools: lenient(AllowedTools),
maxToolCalls: lenient(Schema.Int),
parallelToolCalls: lenient(Schema.Boolean),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -73,10 +71,11 @@ export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
}
const decodeOptions = Schema.decodeUnknownSync(Options)
const decodeOptions = Schema.decodeUnknownOption(Options)
export const resolve = (request: LLMRequest): Resolved => {
const input = decodeOptions(request.providerOptions ?? {})
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
if (!input) return {}
return {
...input,
include: input.include?.length ? input.include : undefined,
+14 -4
View File
@@ -215,9 +215,7 @@ export const fromWebSocket = (
): Effect.Effect<WebSocketConnection, AIError> =>
Effect.gen(function* () {
yield* waitOpen(ws, input)
// The socket pushes frames synchronously and cannot be paused, so the hand-off to the consumer
// fiber must absorb whole read buffers. Bun delivers over a thousand small frames in one tick.
const messages = yield* Queue.unbounded<string | Uint8Array, AIError | Cause.Done<void>>()
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const oversized = (message: string | Uint8Array) =>
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
@@ -240,7 +238,19 @@ export const fromWebSocket = (
}
const offer = (message: string | Uint8Array) => {
if (rejectOversized(message)) return
Queue.offerUnsafe(messages, message)
if (Queue.offerUnsafe(messages, message)) return
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("WebSocket inbound queue overflow", {
body: typeof message === "string" ? message : new TextDecoder().decode(message),
url: input.url,
operation: "read",
code: "queue-overflow",
phase: "receive",
}),
),
)
}
const onMessage = (event: MessageEvent) => {
@@ -4,22 +4,6 @@ import { Anthropic } from "../../src/providers.js"
const model = Anthropic.provider.model("claude-sonnet-4-5")
LLM.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
serviceTier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
// @ts-expect-error Anthropic cache TTL values are protocol constraints.
cacheControl: { type: "ephemeral", ttl: "future-ttl" },
},
})
LLM.request({
model,
@@ -166,89 +166,7 @@ describe("Anthropic Messages route", () => {
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("budgetTokens")
}),
)
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
Effect.gen(function* () {
const snake = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
},
}),
)
const camel = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
serviceTier: "standard_only",
container: "container_2",
inferenceGeo: "eu",
cacheControl: { type: "ephemeral" },
outputConfig: { effort: "low" },
},
}),
)
expect(snake.body).toMatchObject({
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
})
expect(camel.body).toMatchObject({
service_tier: "standard_only",
container: "container_2",
inference_geo: "eu",
cache_control: { type: "ephemeral" },
output_config: { effort: "low" },
})
}),
)
it.effect("forwards unknown values for pass-through string enums", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
service_tier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
},
}),
)
expect(prepared.body).toMatchObject({
service_tier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
})
}),
)
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
const malformed = [
{ service_tier: 1 },
{ metadata: { user_id: 42 } },
{ cache_control: { type: "ephemeral", ttl: "future-ttl" } },
{ output_config: { format: { type: "text" } } },
{ thinking: { type: "automatic" } },
]
const errors = yield* Effect.forEach(malformed, (providerOptions) =>
compileRequest(LLMRequest.update(request, { providerOptions })).pipe(Effect.flip),
)
expect(prepared.body).not.toHaveProperty("unknownOption")
expect(errors.map((error) => error.reason._tag)).toEqual(malformed.map(() => "InvalidRequest"))
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
}),
)
@@ -11,78 +11,66 @@ import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("decodes error frames verbatim in shared SSE and WebSocket decoding", () =>
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
for (const frame of [
{ type: "error", sequence_number: 4, code: "server_shutting_down", message: "Shutting down", param: null },
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{ type: "error", error: "Gateway failed" },
{ type: "error", error: { code: 429, message: "slow down" } },
{ type: "error", error: 42 },
{ type: "error", code: 500, message: ["not", "a", "string"] },
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(frame))).toEqual(frame)
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("reads bare WebSocket error envelopes as error events", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message, 42]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({ type: "error", error })
}
for (const frame of [{ error: null }, { message }]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame)).pipe(Effect.flip)).toBeDefined()
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
}
}),
)
it.effect("extracts error details from every shape and falls back to the raw frame", () =>
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const cases: Array<[frame: Record<string, unknown>, message: string, tag: string]> = [
[
{ type: "error", code: "server_shutting_down", message: "Shutting down" },
"server_shutting_down: Shutting down",
"UnknownProvider",
],
[{ type: "error", error: "Gateway failed" }, "Gateway failed", "UnknownProvider"],
[{ type: "error", error: { code: 429, message: "slow down" } }, "429: slow down", "UnknownProvider"],
[{ type: "error", error: { message: "slow down" }, status: 429 }, "slow down", "RateLimit"],
[{ type: "error", code: 500, message: ["not", "a", "string"] }, "500", "UnknownProvider"],
[
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
"Gateway failed",
"UnknownProvider",
],
]
for (const [frame, message, tag] of cases) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(message)
expect(error.reason._tag).toBe(tag)
expect(error.reason.body).toBe(JSON.stringify(frame))
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
}
for (const frame of [
{ type: "error", error: 42 },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
]) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(JSON.stringify(frame))
expect(error.reason._tag).toBe("UnknownProvider")
}
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").message).toBe("fallback")
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").reason._tag).toBe("ProviderInternal")
}),
)
@@ -248,21 +248,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
}),
)
expect(prepared.body.store).toBe(true)
expect(prepared.body.reasoning_effort).toBe("max")
}),
)
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1945,30 +1945,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("drops a malformed provider option without discarding its siblings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
providerOptions: {
topLogprobs: 25,
metadata: { tenant: 7 },
reasoningEffort: "high",
serviceTier: "priority",
maxToolCalls: 4,
},
}),
)
expect(prepared.body.top_logprobs).toBeUndefined()
expect(prepared.body.metadata).toBeUndefined()
expect(prepared.body.reasoning).toEqual({ effort: "high" })
expect(prepared.body.service_tier).toBe("priority")
expect(prepared.body.max_tool_calls).toBe(4)
}),
)
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -19,13 +19,6 @@ export function mountBrowserPane() {
session: "Alpha",
mounted: true,
visible: true,
url: undefined as string | undefined,
loading: false,
generation: 0,
delayNavigation: false,
pendingURL: undefined as string | undefined,
loadErrors: {} as Record<string, string | undefined>,
error: undefined as string | undefined,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
})
const tabs = ["Alpha", "Beta"].map((name) => ({
@@ -54,33 +47,13 @@ export function mountBrowserPane() {
opened: () => !!registrations.get(store.session),
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
tabs: () => tabs.filter((tab) => tab.title === store.session),
active: () => {
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
return {
...tab,
url: store.url ?? tab.url,
loading: store.loading,
generation: store.generation,
loadError: store.loadErrors[store.session],
}
},
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
registration: () => registrations.get(store.session),
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
error: () => undefined,
suspended: () => false,
close: () => undefined,
open: () => undefined,
command: (command) => {
setStore("error", undefined)
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
if (command.type === "navigate") {
if (store.delayNavigation) {
setStore("pendingURL", command.url)
return
}
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
}
if (command.type === "stop") setStore("loading", false)
},
command: () => undefined,
}
return (
<>
@@ -91,32 +64,6 @@ export function mountBrowserPane() {
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
<button
onClick={() =>
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
}
>
Load current page
</button>
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
Block navigation
</button>
<button
onClick={() =>
setStore({
url: store.pendingURL,
pendingURL: undefined,
loading: false,
generation: store.generation + 1,
})
}
>
Complete navigation
</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
@@ -57,153 +57,3 @@ story("hides the native view immediately while the pane stays mounted", async ({
await toggle.click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Loading page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Stop", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("keeps the submitted URL visible until the browser reports navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await address.fill("https://unsubmitted.example/")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
await expect(address).toHaveValue("https://example.com/")
})
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://blocked.example/")
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
"https://alpha.example/",
)
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Reload", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("returns a failed tab to the empty state when an empty URL is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("selects the full URL when the address field gains focus", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
await address.pressSequentially("https://example.com/")
await expect(address).toHaveValue("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await address.focus()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
await address.press("ArrowRight")
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
})
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
})
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
@@ -1,110 +0,0 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
)
},
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
)
await page.goto("/")
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"]')).toBeVisible()
const drawer = page.locator('[data-slot="mobile-drawer-content"]')
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
})
test("reorders session tabs with touch", async ({ page }) => {
const tabs = page.locator('[data-slot="vertical-tabs"] a')
await expect(tabs).toContainText([fixture.expected.sourceTitle, fixture.expected.targetTitle])
const target = tabs.filter({ hasText: fixture.expected.targetTitle })
const source = tabs.filter({ hasText: fixture.expected.sourceTitle })
const targetBox = await target.boundingBox()
const sourceBox = await source.boundingBox()
expect(targetBox).not.toBeNull()
expect(sourceBox).not.toBeNull()
await touchDrag(page, source, {
from: { x: sourceBox!.x + sourceBox!.width / 2, y: sourceBox!.y + sourceBox!.height / 2 },
to: { x: targetBox!.x + targetBox!.width / 2, y: targetBox!.y + targetBox!.height / 2 },
})
await expect(tabs).toContainText([fixture.expected.targetTitle, fixture.expected.sourceTitle])
})
test("dismisses a touch context menu by tapping outside", async ({ page }) => {
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle })
const home = drawer.getByRole("button", { name: "Home", exact: true })
const homeBox = await home.boundingBox()
const box = await tab.boundingBox()
expect(homeBox).not.toBeNull()
expect(box).not.toBeNull()
const point = { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
await tab.dispatchEvent("pointerdown", {
pointerType: "touch",
pointerId: 1,
isPrimary: true,
clientX: point.x,
clientY: point.y,
})
const rename = page.getByRole("menuitem", { name: "Rename", exact: true })
await expect(rename).toBeVisible()
await expect(drawer).toBeVisible()
await tab.filter({ visible: true }).dispatchEvent("pointerup", {
pointerType: "touch",
pointerId: 1,
isPrimary: true,
clientX: point.x,
clientY: point.y,
})
await page.touchscreen.tap(homeBox!.x + homeBox!.width / 2, homeBox!.y + homeBox!.height / 2)
await expect(rename).toBeHidden()
await expect(drawer).toBeVisible()
})
async function touchDrag(
page: Page,
source: Locator,
input: { from: { x: number; y: number }; to: { x: number; y: number } },
) {
const client = await page.context().newCDPSession(page)
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ ...input.from, id: 1 }],
})
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: (input.from.x + input.to.x) / 2, y: (input.from.y + input.to.y) / 2, id: 1 }],
})
await expect(source).toHaveCount(2)
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ ...input.to, id: 1 }],
})
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
}
@@ -166,45 +166,6 @@ test.describe("session timeline projection", () => {
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("does not repeat a review comment file as an attachment", async ({ page }) => {
const message = userMessage([
userText("what's goin on here", { id: "prt_user_review_comment" }),
{
id: "prt_user_review_file",
type: "file",
mime: "text/plain",
filename: "LiveRuntime.ts",
url: "file:///repo/LiveRuntime.ts?start=14&end=32",
},
{
id: "prt_user_unrelated_file",
type: "file",
mime: "text/plain",
filename: "notes.txt",
url: "data:text/plain;base64,bm90ZXM=",
},
])
message.metadata = {
displayText: "what's goin on here",
comments: [
{
path: "LiveRuntime.ts",
comment: "what's goin on here",
selection: { startLine: 14, startChar: 0, endLine: 32, endChar: 0 },
origin: "review",
},
],
}
await setupTimeline(page, { messages: [message, assistantMessage()] })
const user = page.locator('[data-component="user-message"]')
await expect(user.getByText("LiveRuntime.ts:14-32", { exact: true })).toBeVisible()
const attachments = user.locator('[data-slot="user-message-attachments"]')
await expect(attachments.getByText("LiveRuntime.ts", { exact: true })).toHaveCount(0)
await expect(attachments.getByText("notes.txt", { exact: true })).toBeVisible()
})
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.11",
"version": "2.0.8",
"description": "",
"type": "module",
"exports": {
@@ -46,6 +46,7 @@
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
@@ -87,6 +88,7 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { Schema } from "effect"
import { PromptHistoryState } from "../schema"
import { Persistence } from "@/runtime/persistence/schema"
@@ -36,24 +36,6 @@ describe("Composer history", () => {
expect(dedupedComments).toBe(commentsOnly)
})
test("removeHistoryEntry drops the entry recorded for a prompt and leaves others alone", () => {
const image: Prompt = [
{ type: "text", content: "look", start: 0, end: 4 },
{ type: "image", id: "img", filename: "big.png", mime: "image/png", blob: { id: "hash", url: "" } },
]
const entries = prependHistoryEntry(prependHistoryEntry([], text("earlier")), image, [comment("c1")])
expect(entries).toHaveLength(2)
const untouched = removeHistoryEntry(entries, text("never sent"))
expect(untouched).toBe(entries)
const withoutComments = removeHistoryEntry(entries, image)
expect(withoutComments).toBe(entries)
const removed = removeHistoryEntry(entries, image, [comment("c1")])
expect(removed).toEqual(prependHistoryEntry([], text("earlier")))
})
test("insertion isolates canonical entries from source mutations", () => {
const prompt: Prompt = [
{
@@ -48,18 +48,6 @@ export function prependHistoryEntry(
return [entry, ...entries].slice(0, max)
}
// A send that failed puts its prompt back in the composer, so the entry recorded for it would
// only duplicate the draft and keep its attachments referenced for as long as history holds it.
export function removeHistoryEntry(
entries: PromptHistoryStoredEntry[],
prompt: Prompt,
comments: PromptHistoryComment[] = [],
) {
const entry = { prompt, comments } satisfies PromptHistoryEntry
const next = entries.filter((item) => !isPromptEqual(item, entry))
return next.length === entries.length ? entries : next
}
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
return (
commentA.path === commentB.path &&
@@ -4,7 +4,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import {
clonePromptHistoryComments,
prependHistoryEntry,
removeHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./entry"
@@ -14,7 +13,6 @@ import { PromptHistoryState } from "../schema"
export type ComposerHistoryStore = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
remove: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
type PromptHistoryState = typeof PromptHistoryState.Type
@@ -34,13 +32,6 @@ function createComposerHistoryStore(
if (next === current.entries) return
setCurrent("entries", next)
},
remove(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = removeHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
@@ -65,12 +56,5 @@ export function createComposerHistory() {
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
remove(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.remove(prompt, mode, comments)
const saved = clonePrompt(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.remove(saved, mode, metadata))
},
}
}
-1
View File
@@ -264,7 +264,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
removeFromHistory: (value, mode, comments) => history.remove(value, mode, mode === "shell" ? [] : comments),
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
+7 -8
View File
@@ -53,7 +53,6 @@ function submitInput(
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
) {
return createComposerSubmit({
adapter,
@@ -61,9 +60,7 @@ function submitInput(
commands,
editor: () => undefined,
queueScroll() {},
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
removeFromHistory: (prompt) =>
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
addToHistory() {},
resetHistory() {},
setMode() {},
closePopover() {},
@@ -536,8 +533,12 @@ describe("Composer submission", () => {
missingSelection() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const history: string[] = []
const submission = submitInput(adapter, notify, "normal", () => [], history)
const submission = submitInput(
adapter,
notify,
"normal",
() => [],
)
await submission.submit(new Event("submit"))
await first.promise
@@ -548,8 +549,6 @@ describe("Composer submission", () => {
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: text }])
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
})
test("forwards structured mentions to custom commands", async () => {
-3
View File
@@ -32,7 +32,6 @@ type ComposerSubmitInput = {
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
removeFromHistory: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
@@ -251,8 +250,6 @@ function restoreSubmission(
) {
const restored = submission.restore()
if (!restored) return false
// The prompt is back in the composer; its history entry would only keep attachments referenced.
input.removeFromHistory(value.prompt, value.mode, comments)
restored.target.set(restored.prompt, promptLength(restored.prompt))
restored.target.mode.set(value.mode)
restored.target.context.replaceComments(
+13 -12
View File
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { Button } from "@opencode/ui/button"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
@@ -326,19 +327,19 @@ export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.location.directory)}:${record.session.id}`
}
// Calendar day in the local time zone, comparable as a number.
function localDay(date: Date) {
return date.getFullYear() * 10_000 + date.getMonth() * 100 + date.getDate()
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
const now = new Date()
const today = localDay(now)
const yesterday = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const day = (record: HomeSessionRecord) => localDay(new Date(record.session.time.updated ?? record.session.time.created))
const todaySessions = records.filter((record) => day(record) === today)
const yesterdaySessions = records.filter((record) => day(record) === yesterday)
const olderSessions = records.filter((record) => day(record) !== today && day(record) !== yesterday)
const now = DateTime.local()
const yesterday = now.minus({ days: 1 })
const todaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
)
const yesterdaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
)
const olderSessions = records.filter((record) => {
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
})
const olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
+4 -4
View File
@@ -65,14 +65,14 @@
}
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
When they overlap, center the conversation in the space that remains beside the summary. */
@container session-chat (min-width: 1320px) and (width < 1640px) {
Move only as far as needed; wider panels already have enough space in the margin. */
@container session-chat (min-width: 1320px) {
[data-slot="session-chat-panel"][data-summary-open="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
--session-summary-translate: -160px;
--session-summary-translate: min(0px, calc(50cqi - 820px));
&:dir(rtl) {
--session-summary-translate: 160px;
--session-summary-translate: max(0px, calc(820px - 50cqi));
}
}
}
+16 -12
View File
@@ -1,4 +1,5 @@
import { type Accessor, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode/ui/context"
import { useProviders } from "@/providers/catalog/providers"
@@ -8,8 +9,6 @@ export type ModelKey = { providerID: string; modelID: string }
type Visibility = "show" | "hide"
const RECENT_LIMIT = 5
// luxon's diffNow().as("months") used an average month; keep the same window.
const sixMonths = 6 * 30.436875 * 24 * 60 * 60 * 1000
function modelKey(model: ModelKey) {
return `${model.providerID}:${model.modelID}`
@@ -30,23 +29,27 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
),
)
// Release dates as epoch ms; an unparseable date is NaN and never counts as recent.
const release = createMemo(
() =>
new Map(
available().map(
(model) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
),
available().map((model) => {
const parsed = DateTime.fromISO(model.release_date)
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
}),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter((x) => {
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
return Math.abs(Date.now() - released) < sixMonths
}),
filter(
(x) =>
Math.abs(
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
.diffNow()
.as("months"),
) < 6,
),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
@@ -98,8 +101,9 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
// Models without a parseable release date stay visible.
return !Number.isFinite(release().get(key) ?? NaN)
const date = release().get(key)
if (!date?.isValid) return true
return false
}
const setVisibility = (model: ModelKey, state: boolean) => {
-4
View File
@@ -906,10 +906,6 @@ export const dict = {
"session.browser.replaced": "Browser control moved to another desktop window.",
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
"session.browser.address.placeholder": "Enter URL",
"session.browser.empty.title": "Enter URL",
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
"session.browser.failed.title": "URL can't be reached",
"session.browser.failed.description": "Check the URL and your connection, then try again.",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
+10 -63
View File
@@ -20,18 +20,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const command = useCommand()
const state = props.browser.active
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
const failed = () => !!state()?.loadError
const registration = props.browser.registration
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
submitted: false,
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
let addressDisplay: HTMLDivElement | undefined
let frame: number | undefined
@@ -48,7 +43,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
title: language.t("command.browser.reload"),
category: language.t("command.category.view"),
keybind: "f5",
disabled: !props.visible || !address(),
disabled: !props.visible || !state(),
onSelect: () => {
const tab = state()
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
@@ -76,9 +71,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -114,25 +107,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
createEffect(
on(
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
() => setStore("navigating", false),
{ defer: true },
),
)
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
createEffect(
on(
() => props.browser.error(),
(error) => {
if (error && !store.editing) setStore("address", address())
},
{ defer: true },
),
)
createEffect(() => !store.editing && setStore("address", address()))
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
@@ -148,8 +123,6 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
() => store.visible,
() => props.visible,
() => state()?.id,
empty,
failed,
registration,
],
() => {
@@ -184,7 +157,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
<For each={["back", "forward"] as const}>
{(direction) => (
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
@@ -220,7 +193,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
>
<IconButton
{...button}
disabled={!state()?.loading && !address()}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
@@ -239,13 +212,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
onSubmit={(event) => {
event.preventDefault()
const tab = state()
const url = store.address.trim()
if (!tab) return
if (url || failed()) {
setStore({ submitted: true, address: url, navigating: true })
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
}
event.currentTarget.querySelector("input")?.blur()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
}}
>
<input
@@ -256,14 +224,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={(event) => {
setStore("editing", true)
event.currentTarget.select()
}}
onClick={(event) => event.currentTarget.select()}
onBlur={() =>
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: address() })}
onInput={(event) => setStore("address", event.currentTarget.value)}
onScroll={(event) => {
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
@@ -281,27 +243,12 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
</div>
</form>
</div>
<Show when={props.browser.error() && !failed()}>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
dir="auto"
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
>
<Icon name="globe" size="large" class="mb-2 shrink-0" />
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
</div>
<div class="text-13-regular leading-[var(--line-height-base)]">
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
</div>
</div>
</Show>
<Show when={props.browser.suspended()}>
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
{language.t("session.browser.suspended")}
@@ -409,6 +409,7 @@ export function SessionFileView(props: SessionFileViewProps) {
}}
enableLineSelection
enableGutterUtility
textSelectionAction={{ label: language.t("ui.lineComment.add") }}
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
@@ -1,12 +1,6 @@
import { DateTime } from "luxon"
export function createSessionContextFormatter(locale: string) {
// The fields luxon's DATETIME_MED preset passed to Intl; output is identical.
const dateTime = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
})
return {
number(value: number | null | undefined) {
if (value === undefined) return "—"
@@ -20,7 +14,7 @@ export function createSessionContextFormatter(locale: string) {
},
time(value: number | undefined) {
if (!value) return "—"
return dateTime.format(value)
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
},
}
}
+3 -15
View File
@@ -1,8 +1,6 @@
import { children, createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Tabs } from "@opencode/ui/tabs"
@@ -71,19 +69,9 @@ export function SortableTab(props: {
placement="bottom"
gutter={10}
>
<IconButton
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onTabClose(props.tab)
}}
icon={<Icon name="xmark-small" />}
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
@@ -1,8 +1,7 @@
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
@@ -18,6 +17,8 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SettingsList } from "@/settings/list"
const Icon = lazy(() => import("@opencode/ui/icon").then((module) => ({ default: module.Icon })))
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { canOpenTabRename, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
@@ -24,4 +24,10 @@ describe("titlebar tab gestures", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -4,6 +4,10 @@ export function isTabCloseTarget(target: EventTarget | null) {
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
}
export function canStartTabDrag(pointerType: string) {
return pointerType !== "touch"
}
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
if (typeof ref === "function") ref(element)
}
+1 -10
View File
@@ -9,8 +9,7 @@
justify-content: center;
}
[data-titlebar-tab],
[data-titlebar-tab-action] {
[data-titlebar-tab] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
@@ -69,14 +68,6 @@
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
@@ -328,7 +328,6 @@ export function TabNavItem(props: {
return (
<Menu.Context
modal={false}
onOpenChange={(open) => {
setMenu("open", open)
if (open) setPopoverOpen(false)
@@ -16,7 +16,7 @@ import { useTabs } from "@/shell/tabs/tabs"
import { createTabComposerState } from "@/composer/persistence"
import { base64Encode } from "@opencode/util/encode"
import { showToast } from "@/shell/notifications/toast"
import { isTabCloseTarget } from "./tab-gesture"
import { canStartTabDrag, isTabCloseTarget } from "./tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
import type { SessionInfo } from "@opencode/client/promise"
@@ -292,11 +292,9 @@ export function TitlebarTabStrip(props: {
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: (event) =>
event.pointerType === "touch"
? [new PointerActivationConstraints.Distance({ value: 8 })]
: [new PointerActivationConstraints.Distance({ value: 4 })],
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
!canStartTabDrag(event.pointerType) ||
isTabCloseTarget(event.target) ||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
}),
@@ -315,7 +313,6 @@ export function TitlebarTabStrip(props: {
if (!source) return
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
if (!tab) return
if (vertical()) return
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
props.onNavigate(tab, tabEl ?? undefined)
}}
+2 -4
View File
@@ -351,10 +351,9 @@ export function Titlebar(props: {
>
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
@@ -651,9 +650,8 @@ export function Titlebar(props: {
{homeButton(true)}
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-new-session"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
+5 -5
View File
@@ -9,7 +9,7 @@ import { Keymap } from "../../tui/src/context/keymap"
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const theme = useTheme().surface("dialog")
const theme = useTheme("elevated")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
@@ -36,17 +36,17 @@ export function ErrorOverlay(props: { component: string; error: unknown; onClose
<Dialog centered onClose={props.onClose}>
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.muted} onMouseUp={props.onClose}>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
esc
</text>
</box>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.base}>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
{props.error instanceof Error ? props.error.message : String(props.error)}
</text>
<text flexShrink={0} fg={theme.text.muted}>
<text flexShrink={0} fg={theme.text.subdued}>
{props.component} · Fix the component and save to retry.
</text>
</box>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.11",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"bin": {
-120
View File
@@ -1,120 +0,0 @@
#!/usr/bin/env bun
import { Script } from "@opencode/script"
import { $ } from "bun"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { UpdateArtifact } from "../../../script/update-artifact"
if (Script.channel !== "beta" && Script.channel !== "latest") {
throw new Error("Homebrew publishing requires the beta or latest channel")
}
const valid =
Script.channel === "beta"
? /^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)
: /^\d+\.\d+\.\d+$/.test(Script.version)
if (!valid) throw new Error(`Expected a ${Script.channel} release version`)
const dir = fileURLToPath(new URL("..", import.meta.url))
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
const outdir = path.join(root, "homebrew-tap")
const dryRun = process.argv.includes("--dry-run")
const name = Script.channel === "beta" ? "opencode-beta" : "opencode-v2"
const formulaClass = Script.channel === "beta" ? "OpencodeBeta" : "OpencodeV2"
const targets = await Promise.all(
[
{ name: "darwin-arm64", archive: "zip" },
{ name: "darwin-x64-baseline", archive: "zip" },
{ name: "linux-arm64", archive: "tar.gz" },
{ name: "linux-x64-baseline", archive: "tar.gz" },
].map(async (target) => {
const filename = `opencode-${target.name}.${target.archive}`
const file = Bun.file(path.join(root, filename))
if (!(await file.exists()) || !file.size) throw new Error(`Missing Homebrew archive: ${filename}`)
const sha256 = new Bun.CryptoHasher("sha256")
for await (const chunk of file.stream()) sha256.update(chunk)
return {
...target,
url: `https://opencode.ai/files/bin/${encodeURIComponent(Script.version)}/${filename}`,
sha256: sha256.digest("hex"),
}
}),
)
await rm(outdir, { recursive: true, force: true })
if (dryRun) await mkdir(outdir, { recursive: true })
if (!dryRun) {
const token = process.env.GITHUB_TOKEN
if (!token) throw new Error("GITHUB_TOKEN is required to update the Homebrew tap")
await $`git clone ${`https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git`} ${outdir}`
await $`git checkout -B master`.cwd(outdir)
}
const target = (name: string) => {
const result = targets.find((item) => item.name === name)
if (!result) throw new Error(`Missing Homebrew target: ${name}`)
return result
}
const macArm = target("darwin-arm64")
const macIntel = target("darwin-x64-baseline")
const linuxArm = target("linux-arm64")
const linuxIntel = target("linux-x64-baseline")
await Bun.write(
path.join(outdir, `${name}.rb`),
[
"# typed: false",
"# frozen_string_literal: true",
"",
`class ${formulaClass} < Formula`,
` desc "OpenCode V2${Script.channel === "beta" ? " beta" : ""} - the AI coding agent for the terminal"`,
' homepage "https://github.com/anomalyco/opencode"',
` version "${Script.version}"`,
' license "MIT"',
"",
' depends_on "ripgrep"',
' conflicts_with "opencode", because: "both install an opencode binary"',
"",
" on_macos do",
" if Hardware::CPU.arm?",
` url "${macArm.url}"`,
` sha256 "${macArm.sha256}"`,
" else",
` url "${macIntel.url}"`,
` sha256 "${macIntel.sha256}"`,
" end",
" end",
"",
" on_linux do",
" if Hardware::CPU.arm?",
` url "${linuxArm.url}"`,
` sha256 "${linuxArm.sha256}"`,
" else",
` url "${linuxIntel.url}"`,
` sha256 "${linuxIntel.sha256}"`,
" end",
" end",
"",
" def install",
' bin.install "opencode"',
" end",
"end",
"",
].join("\n"),
)
console.log(`Prepared ${name} ${Script.version} in ${outdir}`)
if (dryRun) process.exit(0)
await $`git add ${name + ".rb"}`.cwd(outdir)
if ((await $`git diff --cached --quiet`.cwd(outdir).nothrow()).exitCode !== 0) {
await $`git commit -m ${`chore: update ${name} to ${Script.version}`}`.cwd(outdir)
await $`git push origin master`.cwd(outdir)
}
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
distribution: "homebrew",
version: Script.version,
metadata: { package: `anomalyco/tap/${name}` },
})
-4
View File
@@ -149,10 +149,6 @@ if (Script.channel === "latest" && Script.release && !dryRun) {
if ((Script.channel === "beta" || Script.channel === "latest") && Script.release) {
await $`bun ./script/publish-aur.ts ${dryRun ? ["--dry-run"] : []}`.env({ ...process.env, OPENCODE_CLI_DIST: root })
await $`bun ./script/publish-homebrew.ts ${dryRun ? ["--dry-run"] : []}`.env({
...process.env,
OPENCODE_CLI_DIST: root,
})
}
async function archive(bin: string, target: string, binary: string, directory: string) {
+9 -39
View File
@@ -1,22 +1,17 @@
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
const decodeVpPackages = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
)
export interface Interface {
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
@@ -101,13 +96,6 @@ const make = Effect.gen(function* () {
process.platform === "win32" ? "opencode.exe" : "opencode",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
if (
["opencode-beta", "opencode-v2"].some((name) =>
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
)
)
return "brew"
if (!installedPackage) return
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
@@ -115,30 +103,22 @@ const make = Effect.gen(function* () {
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
{ method: "vp", command: ["vp", "list", "-g", "--json", installedPackage] },
]
const results = yield* Effect.forEach(
checks,
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => {
if (result.check.method !== "vp") return result.result.stdout.includes(installedPackage)
// Vite+ repeats the filter in its successful no-match message, so substring detection would be a false positive.
return Option.exists(decodeVpPackages(result.result.stdout), (packages) =>
packages.some((item) => item.name === installedPackage),
)
})?.check.method
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
})
const removal = (method: Method) => {
if (method === "curl" || method === "brew" || !installedPackage) return undefined
if (method === "curl" || !installedPackage) return undefined
const commands = {
npm: ["npm", "uninstall", "--global", installedPackage],
pnpm: ["pnpm", "remove", "--global", installedPackage],
bun: ["bun", "remove", "--global", installedPackage],
yarn: ["yarn", "global", "remove", installedPackage],
vp: ["vp", "uninstall", "-g", installedPackage],
}
const command = commands[method]
return {
@@ -153,12 +133,11 @@ const make = Effect.gen(function* () {
}
}
const release = Effect.fnUntraced(function* (method?: Method) {
const distribution = method === "brew" ? "homebrew" : "npm"
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: (signal) =>
fetch(
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm?current=${encodeURIComponent(OPENCODE_VERSION)}`,
{
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
@@ -174,11 +153,7 @@ const make = Effect.gen(function* () {
return { package: data.metadata.package, version: data.version }
})
const latest = () =>
method().pipe(
Effect.flatMap(release),
Effect.map((data) => data.version),
)
const latest = () => release().pipe(Effect.map((data) => data.version))
const temporaryDirectory = (prefix: string) =>
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
@@ -188,12 +163,12 @@ const make = Effect.gen(function* () {
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const packageName = (yield* release(method)).package
const packageName = (yield* release()).package
const target = `${packageName}@${version}`
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
}
const commands: Record<Exclude<Method, "bun" | "curl" | "brew">, string[]> = {
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
// Keep the old package: uninstalling it can unlink the replacement command.
npm: [
"npm",
@@ -207,10 +182,6 @@ const make = Effect.gen(function* () {
],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
vp:
installedPackage && packageName !== installedPackage
? ["vp", "install", "-g", "--force", target]
: ["vp", "update", "-g", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
@@ -231,7 +202,6 @@ const make = Effect.gen(function* () {
if (download.code !== 0) return download
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
return yield* exec(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
+4 -46
View File
@@ -19,7 +19,6 @@ function fixture(
} = () => ({}),
name = "@opencode/cli",
failCleanup = false,
releasePackage = name,
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -35,7 +34,7 @@ function fixture(
yield* Effect.acquireRelease(
Effect.sync(() =>
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: releasePackage } }), {
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
preconnect: fetch.preconnect,
}),
),
@@ -107,7 +106,6 @@ const installs = [
command: ["pnpm", "add", "--global", "--allow-build=@opencode/cli", "@opencode/cli@2.3.4-beta.1"],
},
{ method: "yarn", command: ["yarn", "global", "add", "@opencode/cli@2.3.4-beta.1"] },
{ method: "vp", command: ["vp", "update", "-g", "@opencode/cli@2.3.4-beta.1"] },
] as const
installs.forEach(({ method, command }) => {
@@ -119,25 +117,6 @@ installs.forEach(({ method, command }) => {
}),
)
})
it.live("vp force-installs a renamed V2 package that replaces the existing binary owner", () =>
Effect.gen(function* () {
const test = yield* fixture(() => ({}), "@opencode/cli", false, "@opencode/cli-node")
yield* test.updater.upgrade("vp", "v2.3.4-beta.1")
expect(test.commands).toEqual([["vp", "install", "-g", "--force", "@opencode/cli-node@2.3.4-beta.1"]])
}),
)
it.live("vp removes the package from its managed global store", () =>
Effect.gen(function* () {
const test = yield* fixture()
const removal = test.updater.removal("vp")
if (!removal) return yield* Effect.die("Expected vp removal command")
expect(removal.command).toEqual(["vp", "uninstall", "-g", "@opencode/cli"])
yield* removal.run
expect(test.commands).toEqual([["vp", "uninstall", "-g", "@opencode/cli"]])
}),
)
;[0, 1].forEach((exitCode) => {
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
Effect.gen(function* () {
@@ -221,19 +200,11 @@ it.live("install failures expose stderr and process errors do not report success
expect(missing.commands).toHaveLength(1)
}),
)
;(["npm", "pnpm", "bun", "yarn", "vp", undefined] as const).forEach((method) => {
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(
command.command === method
? method === "vp"
? JSON.stringify([{ name: "@opencode/cli", version: "2.3.4" }])
: "@opencode/cli@2.3.4"
: command.command === "vp"
? "[]"
: "opencode-ai@1.0.0",
),
stdout: Buffer.from(command.command === method ? "@opencode/cli@2.3.4" : "opencode-ai@1.0.0"),
}))
expect(yield* test.updater.method()).toBe(method)
expect(test.commands).toEqual([
@@ -241,7 +212,6 @@ it.live("install failures expose stderr and process errors do not report success
["pnpm", "list", "-g", "--depth=0", "@opencode/cli"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["vp", "list", "-g", "--json", "@opencode/cli"],
])
}),
)
@@ -255,16 +225,7 @@ it.live("method detection tolerates unavailable package managers", () =>
: { error: new AppProcess.AppProcessError({ command: command.command }) },
)
expect(yield* test.updater.method()).toBe("yarn")
expect(test.commands).toHaveLength(5)
}),
)
it.live("vp detection ignores no-match output that repeats the package name", () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "vp" ? "No global packages matching '@opencode/cli'." : ""),
}))
expect(yield* test.updater.method()).toBeUndefined()
expect(test.commands).toHaveLength(4)
}),
)
@@ -304,16 +265,13 @@ if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-no
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
yield* test.updater.upgrade("vp", "v2.3.4")
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode/cli-node"],
["pnpm", "list", "-g", "--depth=0", "@opencode/cli-node"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["vp", "list", "-g", "--json", "@opencode/cli-node"],
["npm", "install", "--global", "@opencode/cli-node@2.3.4"],
["pnpm", "add", "--global", "--allow-build=@opencode/cli-node", "@opencode/cli-node@2.3.4"],
["vp", "update", "-g", "@opencode/cli-node@2.3.4"],
])
}),
)
+1 -7
View File
@@ -35,12 +35,6 @@ describe("upgrade command", () => {
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
})
test("accepts vp as an explicit installation method", async () => {
const result = await cli(["2.0.0", "--method", "vp"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "vp", version: "2.0.0" }])
})
test("skips the already installed version", async () => {
const result = await cli(["v0.0.0-beta-old"])
expect(result.exitCode).toBe(0)
@@ -56,7 +50,7 @@ describe("upgrade command", () => {
})
test("rejects unsupported methods before attempting an upgrade", async () => {
const result = await cli(["--method", "apt"])
const result = await cli(["--method", "brew"])
expect(result.exitCode).not.toBe(0)
expect(result.events).toEqual([])
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.11",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
-18
View File
@@ -1,27 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
const root = path.resolve("src")
const result = await Bun.build({
entrypoints: await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })).then((files) =>
files.filter((file) => !file.endsWith(".d.ts")),
),
root,
outdir: "dist",
target: "node",
format: "esm",
packages: "external",
splitting: true,
naming: {
entry: "[dir]/[name].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Client")
+1 -3
View File
@@ -131,9 +131,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}).pipe(
Effect.repeat({
until: Option.isSome,
// Probes run sequentially, so a slow probe stretches each iteration; bound the loop by wall clock
// like the Promise variant rather than by attempt count.
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
}),
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
)
+61 -69
View File
@@ -8,9 +8,7 @@ export type LocationPublicRef = { directory: string }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderCompaction = { type: "summary" } | { type: "native" }
export type ProviderTransport = "http" | "websocket"
export type ProviderSettings = { [x: string]: any }
export type AgentColor = string
@@ -220,8 +218,19 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ProviderTransport = "http" | "websocket"
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
id: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
@@ -456,23 +465,11 @@ export type V2EventServerConnected = {
data: {}
}
export type ModelSettings = { compaction?: ProviderCompaction } & { [x: string]: any }
export type ConfigModelSettings = { compaction?: ProviderCompaction } & { [x: string]: JsonValue | null }
export type ProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: any }
export type ConfigProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: JsonValue | null }
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
@@ -1446,6 +1443,20 @@ export type ModelCompatibility = {
supportsPromptCacheKey?: boolean
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1660,31 +1671,6 @@ export type SessionInboxMove = {
payload: SessionInboxMovePayload
}
export type ModelVariant = {
id: string
settings?: ModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type PermissionRuleset = Array<PermissionRule>
export type SessionRevertStaged = {
@@ -1868,6 +1854,29 @@ export type FormField =
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type FormField1 =
| FormStringField1
| FormNumberField1
@@ -1893,27 +1902,6 @@ export type ReferenceInfo = {
source: ReferenceSource
}
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type AgentInfo = {
id: string
name: string
@@ -2097,27 +2085,31 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
canonical?: string
name?: string
env?: Array<string>
package?: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
modelID?: string
family?: string
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: ConfigModelSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: ConfigModelSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
+3 -4
View File
@@ -1,5 +1,6 @@
export type EnsureTiming = {
readonly pollInterval: number
readonly attempts: number
readonly requestTimeout: number
readonly spawnDelay: number
readonly maxSpawnDelay: number
@@ -10,11 +11,9 @@ export type EnsureTiming = {
const timings = new WeakMap<object, EnsureTiming>()
// A freshly spawned service registers in ~250 ms, so the poll cadence is a large share of the
// time a client waits for it. Probes are sequential, so the wait between them only matters while
// the connection is refused; both variants give up after promiseTimeout of wall-clock time.
export const defaultEnsureTiming: EnsureTiming = {
pollInterval: 25,
pollInterval: 100,
attempts: 1_200,
requestTimeout: 2_000,
spawnDelay: 5_000,
maxSpawnDelay: 30_000,
@@ -2,6 +2,7 @@ import { withEnsureTiming } from "../../src/service-timing"
const timing = {
pollInterval: 20,
attempts: 120,
requestTimeout: 100,
spawnDelay: 200,
maxSpawnDelay: 1_200,
+6 -14
View File
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
## How it differs from JavaScript
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
@@ -94,19 +94,11 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
failure as a thrown error.
### `Extension.make`
### `Values`
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
```ts
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
const runtime = CodeMode.make({ tools, extensions: [web] })
```
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
extension throws at `CodeMode.make`.
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
### OpenAPI tools
+49 -72
View File
@@ -15,7 +15,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Source and execution model
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
TypeScript-only syntax is rejected rather than stripped before execution.
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
@@ -24,11 +25,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own enumerable }`, a
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
shadowable by program declarations like other globals.
@@ -38,17 +39,16 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
10,000,000 elements (`Array(n)`, `length =`, `Array.from`, `split`, `matchAll`, `concat`, `flat`; below the JS
maximum of 2^32 - 1), and 10,000 pending promises at once. Exceeding one throws a `RangeError`. A single regular
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
parse time. Without it, `yield` is an ordinary binding.
- [ ] Duplicate parameter names in non-strict code throw when the function is called, instead of binding the last
parameter as JavaScript does.
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
rest parameter are accepted unless the program itself begins with `"use strict"`.
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
default in a `for...of` head and Unicode-escaped keywords.
## 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, URLSearchParams, Headers, custom
synchronous iterators, and synchronous generators.
- [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 following ToObject: data objects and
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
- [x] Template literals with interpolation.
@@ -56,7 +56,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for the iterator protocols.
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
@@ -95,10 +95,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [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, URLSearchParams, Headers, Uint8Arrays, built-in iterators, 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, strings, and tool references. `null`, `undefined`, and other
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
- [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, strings, and tool references; other values iterate nothing.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -112,8 +111,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Functions and callbacks
- [x] Function declarations, function expressions, and arrow functions.
- [x] Synchronous and `async` functions. A line break between `function` and the name is allowed, as in JavaScript;
a line break between `async` and `function` is not an async function.
- [x] Synchronous and `async` functions.
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
@@ -129,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
because `includes` is called without a string `this`.
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
@@ -148,6 +146,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`Array.prototype.push.name === "push"`).
- [ ] A named function expression's name is not bound inside its own body.
- [ ] Redeclaring a function in the same scope is rejected; in JavaScript the last declaration wins.
- [ ] A line terminator between `async function` and the function name.
- [ ] Generator and async generator functions evaluate parameter defaults and destructuring at the first `next()`
rather than at the call, so their errors are not thrown synchronously.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
@@ -180,12 +179,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Sequence expressions (the comma operator).
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
`await` still defers its continuation one reaction turn.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present. `Error.isError`
is true for every Error value.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -275,8 +273,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
and plain-object results.
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString` (calls the value's
`toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString`, `valueOf`,
`hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
## Arrays
@@ -290,14 +288,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`lastIndexOf`.
- [x] Aggregation: `reduce` and `reduceRight`.
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, `join`, and `toLocaleString` (each element's
`toLocaleString`, holes and nullish elements as empty strings).
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
shares one prototype. JavaScript gives each collection its own; the difference is not observable here because
`Object.getPrototypeOf` is not exposed.
- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`.
@@ -310,13 +303,13 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
form, like `JSON.stringify`.
- [ ] Argument coercion for `indexOf`, `lastIndexOf`, `includes`, `fill`, `flat`, `copyWithin`, and the `join`
separator: JavaScript applies ToIntegerOrInfinity/ToString (including `valueOf`, strings, and `undefined`), the
interpreter requires numbers and strings. `indexOf()` and `lastIndexOf()` with no argument already search for
`undefined`; `includes()` still requires a value.
interpreter requires numbers and strings; `includes()`/`indexOf()` with no argument should search for
`undefined`.
- [ ] Iterator objects from `keys`, `values`, and `entries` with a live `next()`.
## Strings
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`, and the `toLocaleLowerCase`/`toLocaleUpperCase`
aliases, which ignore their locale argument and apply the default Unicode casing.
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
@@ -324,7 +317,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
- [x] `localeCompare`; locale and options arguments are currently ignored.
- [x] `isWellFormed` and `toWellFormed`.
- [x] `toString`, `length`, numeric indexing, spread, `for...of`, and `[Symbol.iterator]` by Unicode code point.
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
@@ -340,8 +333,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`.
- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`,
`Number.parseInt`, and `Number.parseFloat`.
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, `valueOf`, and `toLocaleString`, which
always formats as `en-US` (`"1,234.5"`) so output does not depend on the host.
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`.
- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`,
`POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`.
- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`.
@@ -354,8 +346,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
use their epoch time) and reject opaque runtime references as data errors.
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
example `Math.sum is not a function.` or `search(...).catch is not a function.` Unknown `Promise` statics keep
their descriptive error.
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
- [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(...)`.
@@ -389,8 +380,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`TimeClip` behavior.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] `toDateString` and `toTimeString` in the host's local timezone.
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
their throws.
@@ -416,11 +405,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [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`, `forEach`, `getOrInsert`, and `getOrInsertComputed`.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] `new Set()` from synchronous iterables.
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
may return a built-in iterator or an array.
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
@@ -436,9 +424,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Writable URL fields except `origin`.
- [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`, `[Symbol.iterator]`, `toString`, and `size`.
- [x] URL values are their href in `JSON.stringify` and at the host boundary. URLSearchParams are `{}` in
`JSON.stringify` and their query string at the host boundary.
`entries`, `toString`, and `size`.
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
## Uint8Array
@@ -450,8 +437,7 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] Index reads and writes with JS byte semantics: values wrap modulo 256, out-of-range writes are ignored, indexes
cannot be deleted. `length` is a prototype accessor, so `Object.keys` lists only indexes.
- [x] `at`, `slice`, `subarray` (a view on the same bytes), `set`, `fill`, `reverse`, `indexOf`, `lastIndexOf`,
`includes`, `join`, `toString`, `toBase64`, `toHex`, and live `keys`, `values`, `entries`, and `[Symbol.iterator]`
iterators.
`includes`, `join`, `toString`, `toBase64`, `toHex`, and materialized `keys`, `values`, and `entries` arrays.
- [x] Spread, destructuring, `for...of`, `yield*`, `Array.from`, and `new Set(bytes)`. `Array.isArray` is false.
- [x] String coercion joins with commas; `JSON.stringify` gives `{"0":1,...}`; `console.log` prints
`Uint8Array(n) [...]`.
@@ -465,13 +451,7 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, `entries`, and
`[Symbol.iterator]`; iteration is live and sorted by name, with `set-cookie` values kept apart.
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
- [ ] `Request`, `Response`, and `Blob`.
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
## Extensions
@@ -481,10 +461,9 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
a built-in or another extension throws at `make`.
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name, message, `cause`, and own enumerable data, and a `__proto__` key is
dropped. Functions, generators,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
@@ -509,21 +488,19 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
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. `message` is an own
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` for a plain error. The
result boundary still emits `{ name, message, ...own enumerable }`, so a field such as `code` crosses. `cause` is
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
carries a 1-based line and column in the submitted source instead.
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` while the host still
receives `{ name, message }`. Errors have no `stack`; the diagnostic carries the source location instead.
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
short orientation to the supported subset; this matrix is the full reference.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
Error-shaped value in `catch`, rejection handlers, and `Promise.allSettled` reasons. It always has `name` and
`message`, plus `cause` and own data when the failure carried them. This is deliberate: the program should
handle a failure the same way regardless of where it originated.
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
This is deliberate: the program should handle a failure the same way regardless of where it originated.
- [x] Failures raised by the interpreter are `TypeError`s unless JavaScript names them otherwise (`RangeError`,
`ReferenceError`, `SyntaxError`, `URIError`), so `e instanceof TypeError` and `e.constructor === TypeError`
hold. Unsupported syntax reached at runtime is a `SyntaxError`; awaited tool failures stay plain `Error`.
+9 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.11",
"version": "2.0.8",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
@@ -19,6 +19,12 @@
"exports": {
".": "./src/index.ts"
},
"imports": {
"#transpile": {
"workerd": "./src/interpreter/transpile.workerd.ts",
"default": "./src/interpreter/transpile.node.ts"
}
},
"scripts": {
"build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit",
@@ -26,7 +32,8 @@
},
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:"
"effect": "catalog:",
"typescript": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+7 -9
View File
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports?: Record<string, Record<string, string>>
imports: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
@@ -41,14 +41,12 @@ try {
]
}),
)
if (pkg.imports) {
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
}
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
-2
View File
@@ -17,7 +17,6 @@ import {
record,
SetObj,
URLSearchParamsObj,
HeadersObj,
} from "./interpreter/objects.js"
import { typeofValue } from "./interpreter/references.js"
@@ -70,7 +69,6 @@ const walk = <R>(
)
}
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
stack.add(target)
@@ -1,7 +1,6 @@
import { Effect, Exit } from "effect"
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
import type { Interpreter } from "./interpreter.js"
import { primitivePrototype } from "./intrinsics.js"
import { typeError } from "./model.js"
import { Callable, get, Native, DateObj, Obj } from "./objects.js"
import { typeofValue } from "./references.js"
@@ -44,13 +43,6 @@ export const toPrimitive = <R>(
})
}
/** Invoke(value, name): calls the method the value would find through its prototype. */
export const invoke = <R>(ctx: Interpreter<R>, value: unknown, name: string, label: string) => {
const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value)
if (target === undefined) throw typeError(`${label} called on null or undefined.`)
return ctx.call(get(target, name), value, [])
}
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: unknown) =>
Effect.map(toPrimitive(ctx, value, "string"), coerceToString)
@@ -168,7 +168,6 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
methods(builtins, prototype, [
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
])
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
}
return ctor
}
+14 -1
View File
@@ -1,10 +1,14 @@
import { parse, type Program } from "acorn"
import { Cause, Effect, Scope } from "effect"
// #transpile: conditional import — full typescript on node/bun, an identity
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
import { transpile } from "#transpile"
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
import { toBoundary } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { createBuiltins } from "./intrinsics.js"
import { PendingThrow } from "./model.js"
import { Pending } from "./promises.js"
import { Interpreter } from "./interpreter.js"
@@ -106,7 +110,16 @@ export const executeProgram = <R>(
}
const parseProgram = (code: string): Program => {
return parse(code, {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
if (transpiled.error !== undefined) {
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
const bodyEnd = transpiled.outputText.lastIndexOf("}")
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
return parse(executableCode, {
ecmaVersion: "latest",
sourceType: "script",
allowReturnOutsideFunction: true,
@@ -20,7 +20,6 @@ import {
DateObj,
ErrorObj,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -28,7 +27,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
import { describeValue } from "./references.js"
@@ -54,7 +52,6 @@ export const extensionGlobals = <R>(
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
if (value instanceof URLObj) return new URL(value.url.href)
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
if (value instanceof HeadersObj) return new Headers(value.headers)
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
if (value instanceof SetObj) return new Set([...value.set].map(next))
@@ -62,7 +59,6 @@ export const extensionGlobals = <R>(
!(value instanceof Obj) ||
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof IteratorObj ||
value instanceof PromiseObj
) {
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
@@ -129,7 +125,6 @@ export const extensionGlobals = <R>(
if (value instanceof URLSearchParams) {
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
@@ -11,8 +11,6 @@ import { objectGlobal } from "../stdlib/object.js"
import { regexpGlobal } from "../stdlib/regexp.js"
import { stringGlobal } from "../stdlib/string.js"
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { headersGlobal } from "../stdlib/headers.js"
import { iteratorGlobals } from "../stdlib/iterator.js"
import { coercion } from "../stdlib/value.js"
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
@@ -82,7 +80,6 @@ const table: Record<string, Factory> = {
Set: (ctx) => setGlobal(ctx),
URL: (ctx) => urlGlobal(ctx),
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
Headers: (ctx) => headersGlobal(ctx),
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
TextEncoder: (ctx) => textEncoderGlobal(ctx),
TextDecoder: (ctx) => textDecoderGlobal(ctx),
@@ -102,6 +99,5 @@ export const globalNames: ReadonlySet<string> = new Set(Object.keys(table))
/** The immutable global bindings of every program, in declaration order. */
export const globals = <R>(ctx: Interpreter<R>): ReadonlyArray<readonly [string, unknown]> => {
generatorGlobals(ctx)
iteratorGlobals(ctx)
return Object.entries(table).map(([name, factory]) => [name, factory(ctx)] as const)
}
@@ -62,7 +62,7 @@ import {
} from "./model.js"
import { checkStringLength } from "./limits.js"
import { locate, materialize } from "./errors.js"
import { type Builtins, primitivePrototype } from "./intrinsics.js"
import type { Builtins } from "./intrinsics.js"
import { globals } from "./globals.js"
import {
assign,
@@ -79,13 +79,11 @@ import {
DateObj,
Fn,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
SetObj,
URLSearchParamsObj,
HeadersObj,
record,
remove,
set,
@@ -110,29 +108,20 @@ const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefin
return undefined
}
const calleeDescription = (callee: Expression | Super | undefined): string =>
describeCallee(callee) ?? "The called value"
// Native engines name the callee (`search(...).catch is not a function`), including call chains.
const describeCallee = (node: Expression | Super | undefined): string | undefined => {
if (node?.type === "Identifier") return node.name
if (node?.type === "CallExpression") {
const target = describeCallee(node.callee)
if (target === undefined) return undefined
return `${target}(...)`
const calleeDescription = (callee: Expression | Super | undefined): string => {
if (callee?.type === "Identifier") return callee.name
if (callee?.type === "MemberExpression") {
const object = callee.object
const property = callee.property
const key =
!callee.computed && property.type === "Identifier"
? property.name
: property.type === "Literal" && typeof property.value === "string"
? property.value
: undefined
if (object.type === "Identifier" && key !== undefined) return `${object.name}.${key}`
}
if (node?.type !== "MemberExpression") return undefined
const property = node.property
const key =
!node.computed && property.type === "Identifier"
? property.name
: property.type === "Literal" && typeof property.value === "string"
? property.value
: undefined
if (key === undefined) return undefined
const object = describeCallee(node.object)
if (object === undefined) return undefined
return `${object}.${key}`
return "The called value"
}
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
@@ -660,11 +649,11 @@ class Frame<R> {
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
const right = yield* self.evaluateExpression(node.right)
const cursor = self.hostCursor(right)
const iterator = cursor === undefined ? yield* self.customIterator(right, node, awaiting) : undefined
const iterator = yield* self.customIterator(right, node, awaiting)
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
node,
)
}
@@ -756,20 +745,6 @@ class Frame<R> {
}
iterate(value: unknown, node?: AstNode) {
const cursor = this.hostCursor(value)
if (cursor !== undefined) return Effect.succeed(cursor)
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 hostCursor(value: unknown) {
const iterator =
value instanceof Arr
? value.items[Symbol.iterator]()
@@ -781,25 +756,31 @@ class Frame<R> {
? value.set.values()
: value instanceof URLSearchParamsObj
? value.params.entries()
: value instanceof HeadersObj
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: value instanceof IteratorObj
? value.iterator
: undefined
if (iterator === undefined) return undefined
const proto = this.ctx.builtins.Array
return {
next: Effect.sync(() => {
const step = iterator.next()
return {
done: Boolean(step.done),
value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value,
}
}),
close: Effect.void,
: value instanceof Bytes
? value.bytes.values()
: undefined
if (iterator !== undefined) {
const proto = this.ctx.builtins.Array
return Effect.succeed({
next: Effect.sync(() => {
const step = iterator.next()
return {
done: Boolean(step.done),
value: Array.isArray(step.value) ? new Arr(proto, step.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 | undefined, allowAsync = true) {
@@ -1867,7 +1848,6 @@ class Frame<R> {
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
) {
const cursor = yield* self.iterate(value, node)
@@ -2080,14 +2060,16 @@ class Frame<R> {
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
// Primitives read through their wrapper prototype without being boxed; strings own length and indexes.
const builtins = self.ctx.builtins
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
return { target: builtins.String, key, receiver: objectValue }
}
const proto = primitivePrototype(self.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
if (typeof objectValue === "number") return { target: builtins.Number, key, receiver: objectValue }
if (typeof objectValue === "boolean") return { target: builtins.Boolean, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
@@ -29,7 +29,6 @@ const builtins = [
"Set",
"URL",
"URLSearchParams",
"Headers",
"Uint8Array",
"TextEncoder",
"TextDecoder",
@@ -52,14 +51,6 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E
return value
}
/** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */
export const primitivePrototype = (builtins: Builtins, value: unknown): Obj | undefined => {
if (typeof value === "string") return builtins.String
if (typeof value === "number") return builtins.Number
if (typeof value === "boolean") return builtins.Boolean
return undefined
}
export const createBuiltins = (): Builtins => {
const object = new Obj(null)
// Function.prototype is itself callable and returns undefined.
@@ -89,7 +80,6 @@ export const createBuiltins = (): Builtins => {
Set: plain(),
URL: plain(),
URLSearchParams: plain(),
Headers: plain(),
Uint8Array: plain(),
TextEncoder: plain(),
TextDecoder: plain(),
+2 -3
View File
@@ -86,10 +86,9 @@ export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null
// Acorn lines are 1-based and its columns are 0-based. Diagnostics use 1-based columns of the submitted source.
export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
line: node.loc?.start.line ?? 1,
column: (node.loc?.start.column ?? 0) + 1,
line: Math.max(1, (node.loc?.start.line ?? 2) - 1),
column: Math.max(1, (node.loc?.start.column ?? 4) - 3),
})
export const formatLocation = (node?: AstNode): string => {
+1 -21
View File
@@ -122,16 +122,6 @@ export class GeneratorObj extends Obj {
}
}
/** A built-in collection iterator: live over the host collection, yielding program values. */
export class IteratorObj extends Obj {
constructor(
proto: Obj,
readonly iterator: IteratorObject<unknown>,
) {
super(proto)
}
}
export class DateObj extends Obj {
constructor(
proto: Obj,
@@ -166,15 +156,6 @@ export class URLSearchParamsObj extends Obj {
}
}
export class HeadersObj extends Obj {
constructor(
proto: Obj,
readonly headers: Headers,
) {
super(proto)
}
}
export class URLObj extends Obj {
readonly searchParams: URLSearchParamsObj
constructor(
@@ -200,14 +181,13 @@ export class Bytes extends Obj {
/** Built-in objects that wrap a host value; data-like, but never plain data. */
export const isWrapper = (
value: unknown,
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
value instanceof DateObj ||
value instanceof RegExpObj ||
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
const MAX_ARRAY_INDEX = 4_294_967_295
@@ -9,7 +9,6 @@ import {
Bytes,
DateObj,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -17,14 +16,12 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
/** Values that cannot cross the data boundary. */
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof IteratorObj ||
value instanceof ToolReference ||
value instanceof PromiseObj ||
isWrapper(value)
@@ -88,10 +85,8 @@ export const describeValue = (value: unknown): string => {
if (value instanceof SetObj) return "a Set"
if (value instanceof URLObj) return "a URL"
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
if (value instanceof HeadersObj) return "a Headers"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (value instanceof IteratorObj) return "an iterator"
if (isRuntimeReference(value)) return "a function"
if (typeof value === "object") return "a data object"
return `a ${typeof value}`
@@ -0,0 +1,25 @@
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// Full TypeScript transpilation on node/bun runtimes.
export const transpile = (source: string): TranspileResult => {
const transpiled = transpileModule(source, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (diagnostic) {
return {
outputText: transpiled.outputText,
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
}
}
return { outputText: transpiled.outputText }
}
@@ -0,0 +1,11 @@
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// workerd profile: the typescript compiler is ~11 MiB and probes node
// internals at module init, so codemode programs are passed through
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
// error from the interpreter instead of a transpile diagnostic.
export const transpile = (source: string): TranspileResult => ({ outputText: source })
+6 -25
View File
@@ -1,10 +1,10 @@
import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interpreter/limits.js"
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { get, Arr, GeneratorObj, Obj } from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { compareText } from "../tool-runtime.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -340,31 +340,13 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
return target
},
],
[
"toLocaleString",
0,
(thisValue) =>
Effect.map(
Effect.forEach(self(thisValue, "toLocaleString").items, (item) =>
item === null || item === undefined
? Effect.succeed("")
: Effect.map(invoke(ctx, item, "toLocaleString", "Array.prototype.toLocaleString"), coerceToString),
),
(parts) => parts.join(","),
),
],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").items.keys()))],
["values", 0, (thisValue) => wrap([...self(thisValue, "values").items])],
[
"entries",
0,
(thisValue) =>
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.items.entries()
.map(([index, item]) => wrap([index, item])),
),
wrap(Array.from(self(thisValue, "entries").items.entries(), ([index, item]) => wrap([index, item]))),
],
iterate("map", 1, (target, receiver, apply) =>
Effect.gen(function* () {
@@ -508,6 +490,5 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
}),
),
])
define(proto, IteratorSymbol, get(proto, "values"), hidden)
return array
}
+5 -11
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { IteratorSymbol, rangeError, syntaxError, typeError } from "../interpreter/model.js"
import { define, defineAccessor, get, hidden, Arr, Bytes, IteratorObj, Obj } from "../interpreter/objects.js"
import { rangeError, syntaxError, typeError } from "../interpreter/model.js"
import { defineAccessor, get, Arr, Bytes, Obj } from "../interpreter/objects.js"
import { describeValue } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -173,21 +173,15 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
["toString", 0, (thisValue) => self(thisValue, "toString").bytes.join(",")],
["toBase64", 0, (thisValue) => self(thisValue, "toBase64").bytes.toBase64()],
["toHex", 0, (thisValue) => self(thisValue, "toHex").bytes.toHex()],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").bytes.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").bytes.values())],
["keys", 0, (thisValue) => wrapAll(Array.from(self(thisValue, "keys").bytes.keys()))],
["values", 0, (thisValue) => wrapAll(Array.from(self(thisValue, "values").bytes.values()))],
[
"entries",
0,
(thisValue) =>
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.bytes.entries()
.map(([index, byte]) => wrapAll([index, byte])),
),
wrapAll(Array.from(self(thisValue, "entries").bytes.entries(), ([index, byte]) => wrapAll([index, byte]))),
],
])
define(proto, IteratorSymbol, get(proto, "values"), hidden)
return uint8Array
}
+8 -47
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { invalidData, IteratorSymbol, typeError } from "../interpreter/model.js"
import { invalidData, typeError } from "../interpreter/model.js"
import {
define,
defineAccessor,
@@ -9,7 +9,6 @@ import {
hidden,
isWrapper,
Arr,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -145,29 +144,6 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
return target
},
],
[
"getOrInsert",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsert").map
if (!target.has(args[0])) target.set(args[0], args[1])
return target.get(args[0])
},
],
[
"getOrInsertComputed",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsertComputed").map
const apply = applyCollectionCallback(ctx, args[1], "Map.getOrInsertComputed")
if (target.has(args[0])) return target.get(args[0])
// The callback sees the stored key (-0 is +0) and its result wins over anything it inserted itself.
return Effect.map(apply([args[0] === 0 ? 0 : args[0]]), (value) => {
target.set(args[0], value)
return value
})
},
],
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
[
"clear",
@@ -177,18 +153,12 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
return undefined
},
],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").map.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").map.values())],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").map.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").map.values()))],
[
"entries",
0,
(thisValue) =>
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.map.entries()
.map(([key, item]) => wrap([key, item])),
),
(thisValue) => wrap(Array.from(self(thisValue, "entries").map.entries(), ([key, item]) => wrap([key, item]))),
],
[
"forEach",
@@ -203,7 +173,6 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
},
],
])
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
return map
}
@@ -249,8 +218,7 @@ const loadSetRecord = <R>(
size: Math.max(Math.trunc(size), 0),
has: (item: unknown) => Effect.map(ctx.call(has, source, [item]), Boolean),
keys: () =>
Effect.flatMap(ctx.call(keys, source, []), (result): Effect.Effect<Iterable<unknown>> => {
if (result instanceof IteratorObj) return Effect.succeed(result.iterator)
Effect.flatMap(ctx.call(keys, source, []), (result) => {
if (result instanceof Arr) return Effect.succeed(result.items)
throw typeError(`Set.${name} expected 'keys' to return an iterator.`)
}),
@@ -370,18 +338,12 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
return undefined
},
],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").set.values())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").set.values())],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").set.values()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").set.values()))],
[
"entries",
0,
(thisValue) =>
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.set.values()
.map((item) => wrap([item, item])),
),
(thisValue) => wrap(Array.from(self(thisValue, "entries").set.values(), (item) => wrap([item, item]))),
],
[
"forEach",
@@ -403,6 +365,5 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
operation("isSupersetOf"),
operation("isDisjointFrom"),
])
define(proto, IteratorSymbol, get(proto, "values"), hidden)
return set
}
-2
View File
@@ -12,7 +12,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -67,7 +66,6 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (value instanceof RegExpObj) return coerceToString(value)
if (value instanceof URLObj) return coerceToString(value)
if (value instanceof URLSearchParamsObj) return coerceToString(value)
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
-7
View File
@@ -105,13 +105,6 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
...(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"] as const).map(
(name): Method => [
name,
0,
(thisValue) => new Date(self(thisValue, name).time)[name]("en-US", { timeZone: "UTC" }),
],
),
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
...setters.map(
([name, length]): Method => [
-132
View File
@@ -1,132 +0,0 @@
import { Effect } from "effect"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { IteratorSymbol, typeError } from "../interpreter/model.js"
import { define, entries, get, hidden, Arr, HeadersObj, IteratorObj, Obj } from "../interpreter/objects.js"
import { applyCollectionCallback } from "../interpreter/callback.js"
import { isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
import { readPairs } from "./url.js"
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
const attempt = <T>(run: () => T): T => {
try {
return run()
} catch (error) {
throw typeError(error instanceof Error ? error.message : String(error))
}
}
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
if (init === undefined) return Effect.succeed(wrap(new Headers()))
return Effect.gen(function* () {
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
if (!(init instanceof Obj) || isRuntimeReference(init)) {
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
}
return wrap(
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
)
})
}
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.Headers
const headers = constructor<R>(builtins, proto, {
name: "Headers",
call: requiresNew("Headers"),
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
}
methods(builtins, proto, [
[
"append",
2,
(thisValue, args) => {
requireArgs("append", args, 2)
const target = self(thisValue, "append").headers
return attempt(() => target.append(arg(args, 0), arg(args, 1)))
},
],
[
"delete",
1,
(thisValue, args) => {
requireArgs("delete", args, 1)
const target = self(thisValue, "delete").headers
return attempt(() => target.delete(arg(args, 0)))
},
],
[
"get",
1,
(thisValue, args) => {
requireArgs("get", args, 1)
const target = self(thisValue, "get").headers
return attempt(() => target.get(arg(args, 0)))
},
],
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
[
"has",
1,
(thisValue, args) => {
requireArgs("has", args, 1)
const target = self(thisValue, "has").headers
return attempt(() => target.has(arg(args, 0)))
},
],
[
"set",
2,
(thisValue, args) => {
requireArgs("set", args, 2)
const target = self(thisValue, "set").headers
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
},
],
// Iterator.from because Bun's Headers typings predate iterator helpers; the runtime iterators already have them.
[
"keys",
0,
(thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "keys").headers.keys())),
],
[
"values",
0,
(thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "values").headers.values())),
],
[
"entries",
0,
(thisValue) =>
new IteratorObj(
builtins.Iterator,
Iterator.from(self(thisValue, "entries").headers.entries()).map(([key, value]) => wrap([key, value])),
),
],
[
"forEach",
1,
(thisValue, args) => {
requireArgs("forEach", args, 1)
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
return undefined
})
},
],
])
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
return headers
}
-19
View File
@@ -1,19 +0,0 @@
import { methods, receiver } from "../interpreter/native.js"
import { IteratorObj, record } from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
// Every built-in collection iterator shares the Iterator prototype; JS gives each collection its own, which is only
// observable through getPrototypeOf.
export const iteratorGlobals = <R>(ctx: Interpreter<R>): void => {
const builtins = ctx.builtins
methods(builtins, builtins.Iterator, [
[
"next",
0,
(thisValue) => {
const step = receiver(IteratorObj, thisValue, "Iterator.prototype.next").iterator.next()
return record(builtins.Object, { value: step.value, done: Boolean(step.done) })
},
],
])
}
-1
View File
@@ -50,7 +50,6 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
}
methods(builtins, builtins.Number, [
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
[
"toExponential",
1,
+2 -2
View File
@@ -29,7 +29,7 @@ import {
set,
} from "../interpreter/objects.js"
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
import { preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { ToolReference } from "../tool-runtime.js"
import { groupBy } from "./collections.js"
@@ -196,7 +196,7 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
true,
],
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
["toLocaleString", 0, (thisValue) => invoke(ctx, thisValue, "toString", "Object.prototype.toLocaleString")],
["toLocaleString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
[
"valueOf",
0,
+3 -16
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { constructor, fn, type Method, methods } from "../interpreter/native.js"
import { constructor, type Method, methods } from "../interpreter/native.js"
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
import { define, hidden, Arr, IteratorObj, PromiseObj, RegExpObj, record } from "../interpreter/objects.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { Arr, PromiseObj, RegExpObj, record } from "../interpreter/objects.js"
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
import { applyCollectionCallback, isSupportedCallback } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -150,8 +150,6 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
simple("valueOf", 0, (value) => value),
simple("toLowerCase", 0, (value) => value.toLowerCase()),
simple("toUpperCase", 0, (value) => value.toUpperCase()),
simple("toLocaleLowerCase", 0, (value) => value.toLowerCase()),
simple("toLocaleUpperCase", 0, (value) => value.toUpperCase()),
simple("trim", 0, (value) => value.trim()),
simple("trimStart", 0, (value) => value.trimStart()),
simple("trimLeft", 0, (value) => value.trimStart()),
@@ -260,16 +258,5 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
return joined
}),
])
define(
builtins.String,
IteratorSymbol,
fn(
builtins,
"[Symbol.iterator]",
0,
(thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "[Symbol.iterator]")[Symbol.iterator]()),
),
hidden,
)
return string
}
+23 -50
View File
@@ -1,19 +1,7 @@
import { Effect } from "effect"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { IteratorSymbol, PendingThrow, typeError, uriError } from "../interpreter/model.js"
import {
define,
defineAccessor,
entries,
get,
hidden,
isWrapper,
Arr,
IteratorObj,
Obj,
URLObj,
URLSearchParamsObj,
} from "../interpreter/objects.js"
import { PendingThrow, typeError, uriError } from "../interpreter/model.js"
import { defineAccessor, entries, isWrapper, Arr, Obj, URLObj, URLSearchParamsObj } from "../interpreter/objects.js"
import { isRuntimeReference } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -119,10 +107,12 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
return url
}
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(value)
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
if (cursor === undefined) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
const items: Array<string> = []
while (true) {
const step = yield* cursor.next
@@ -136,29 +126,6 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect
}
})
/**
* Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. As in
* WebIDL, the whole sequence is converted before any pair's length is checked.
*/
export const readPairs = <R>(
ctx: Interpreter<R>,
init: unknown,
label: string,
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(init)
if (cursor === undefined) return undefined
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
return pairs as Array<[string, string]>
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
}
})
const constructURLSearchParams = <R>(
ctx: Interpreter<R>,
init: unknown,
@@ -172,8 +139,20 @@ const constructURLSearchParams = <R>(
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
}
return Effect.gen(function* () {
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
const cursor = yield* ctx.iterate(init)
if (cursor !== undefined) {
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
}
}
if (isRuntimeReference(init)) {
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
}
@@ -270,18 +249,13 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
return undefined
},
],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").params.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").params.values())],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").params.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").params.values()))],
[
"entries",
0,
(thisValue) =>
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.params.entries()
.map(([key, value]) => wrap([key, value])),
),
wrap(Array.from(self(thisValue, "entries").params.entries(), ([key, value]) => wrap([key, value]))),
],
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
[
@@ -298,6 +272,5 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
},
],
])
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
return searchParams
}
-2
View File
@@ -13,7 +13,6 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -29,7 +28,6 @@ export const coerceToString = (value: unknown): string => {
if (value instanceof SetObj) return "[object Set]"
if (value instanceof URLObj) return value.url.href
if (value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return "[object Headers]"
if (value instanceof Bytes) return value.bytes.join(",")
if (value instanceof ErrorObj) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
@@ -298,18 +298,21 @@ const cases = [
},
{
path: "test/built-ins/Array/prototype/keys/iteration.js",
code: `const it = ["a", "b", "c"].keys(); return [it.next(), it.next(), it.next(), it.next()]`,
expected: [{ value: 0, done: false }, { value: 1, done: false }, { value: 2, done: false }, { done: true }],
code: `return ["a", "b", "c"].keys()`,
expected: [0, 1, 2],
},
{
path: "test/built-ins/Array/prototype/values/iteration.js",
code: `const it = ["a", "b", "c"].values(); return [it.next(), it.next(), it.next(), it.next()]`,
expected: [{ value: "a", done: false }, { value: "b", done: false }, { value: "c", done: false }, { done: true }],
code: `return ["a", "b", "c"].values()`,
expected: ["a", "b", "c"],
},
{
path: "test/built-ins/Array/prototype/entries/iteration.js",
code: `const it = ["a", "b"].entries(); return [it.next(), it.next(), it.next()]`,
expected: [{ value: [0, "a"], done: false }, { value: [1, "b"], done: false }, { done: true }],
code: `return ["a", "b"].entries()`,
expected: [
[0, "a"],
[1, "b"],
],
},
{
path: "test/built-ins/Array/isArray/15.4.3.2-0-3.js",
+1 -35
View File
@@ -15,23 +15,7 @@ const error = async (code: string) => {
return result.error
}
describe("source syntax", () => {
test("rejects TypeScript-only syntax", async () => {
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
})
})
describe("error identity", () => {
test("Error.isError is true for every Error value and nothing else", async () => {
expect(
await value(`
const caught = (() => { try { null.foo } catch (error) { return error } })()
return [Error.isError(new Error("x")), Error.isError(new RangeError("x")), Error.isError(caught),
Error.isError({ name: "Error", message: "x" }), Error.isError("Error"), Error.isError(null)]
`),
).toEqual([true, true, true, false, false, false])
})
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
await value(`
@@ -105,24 +89,6 @@ describe("uncaught program throws", () => {
})
})
describe("source locations", () => {
test("uses the submitted line and 1-based column", async () => {
const failure = await error("const value = 1\nreturn value()")
expect(failure.location).toEqual({ line: 2, column: 8 })
expect(failure.message).toBe("TypeError: value is not a function. (line 2, col 8)")
})
test("names a missing method on a call instead of the previous line", async () => {
const failure = await error(`// Try search with different namespaces
for (const ns of ["github", "tools.github", "tools", ""]) {
const s = await search({query: "star", namespace: ns, limit: 100}).catch(e=>({items:[],error:String(e)}));
return s
}`)
expect(failure.location).toEqual({ line: 3, column: 19 })
expect(failure.message).toBe("TypeError: search(...).catch is not a function. (line 3, col 19)")
})
})
describe("host errors escaping built-ins", () => {
test("become the same-named program error", async () => {
expect(
@@ -149,7 +115,7 @@ describe("host errors escaping built-ins", () => {
"(line 1, col 14)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 1, col 12)",
"(line 2, col 5)",
)
})
+1 -30
View File
@@ -128,34 +128,6 @@ describe("values are converted at the boundary, never shared", () => {
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
})
test("Headers cross as copies in both directions", async () => {
const stored = new Headers({ "X-A": "1" })
const target = CodeMode.make({
extensions: [
Extension.make({
name: "http",
globals: {
headers: () => stored,
keep: (value: Headers) => {
held.push(value)
return value
},
},
}),
],
})
held.length = 0
expect(
await value(
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
target,
),
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
expect(stored.get("x-a")).toBe("1")
expect(held[0]).toBeInstanceOf(Headers)
expect((held[0] as Headers).get("x-a")).toBe("2")
})
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
const stored = new Uint8Array([1, 2, 3])
const target = CodeMode.make({
@@ -220,9 +192,8 @@ describe("values are converted at the boundary, never shared", () => {
expect(held[1]).toBeInstanceOf(Error)
})
test("functions, promises, iterators, and symbols cannot be passed in", async () => {
test("functions, promises, and symbols cannot be passed in", async () => {
expect((await failure(`keep(() => 1)`)).message).toContain("Argument 1 to keep contains a function")
expect((await failure(`keep([1].keys())`)).message).toContain("Argument 1 to keep contains an iterator")
expect((await failure(`keep(later(1))`)).message).toContain("un-awaited Promise")
expect((await failure(`keep(Symbol.iterator)`)).message).toContain("Argument 1 to keep contains a symbol")
})
@@ -122,10 +122,10 @@ describe("Map.groupBy Test262 parity", () => {
grouped.get(1),
grouped.get("1"),
grouped.has(stringable),
[...grouped.keys()].length,
grouped.keys().length,
parity.get("even"),
parity.get("odd"),
[...lengths.keys()],
lengths.keys(),
lengths.get(5),
lengths.get(4),
]
@@ -162,7 +162,7 @@ describe("Map.groupBy Test262 parity", () => {
await value(`
const grouped = Map.groupBy("🥰💩🙏😈", (char) => char < "🙏" ? "before" : "after")
const empty = Map.groupBy([], () => { throw new Error("not called") })
return [[...grouped.keys()], grouped.get("before"), grouped.get("after"), empty.size]
return [grouped.keys(), grouped.get("before"), grouped.get("after"), empty.size]
`),
).toEqual([["after", "before"], ["💩", "😈"], ["🥰", "🙏"], 0])
})
+2 -25
View File
@@ -502,15 +502,9 @@ describe("CodeMode-specific array behavior", () => {
expect(err.message).toContain("circular")
})
test("indexOf and lastIndexOf with no argument search for undefined", async () => {
expect(await value(`return [1, undefined, 3].indexOf()`)).toBe(1)
expect(await value(`return [1, undefined, 3].lastIndexOf()`)).toBe(1)
expect(await value(`return [1, 2, 3].indexOf()`)).toBe(-1)
})
test("keys/values/entries return iterators usable with for...of and spread", async () => {
test("keys/values/entries return arrays usable with for...of and spread", async () => {
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
expect(await value(`return [...["x","y"].values()]`)).toEqual(["x", "y"])
expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
expect(
await value(`
const out = []
@@ -946,12 +940,6 @@ describe("coercion parity: unknown static members read as undefined", () => {
expect(await value(`try { JSON.rawJSON("1") } catch (e) { return e.message }`)).toBe(
"JSON.rawJSON is not a function.",
)
expect(await value(`try { search({ query: "star" }).catch(() => 1) } catch (e) { return e.message }`)).toBe(
"search(...).catch is not a function.",
)
expect(
await value(`const foo = () => ({ bar: () => ({}) }); try { foo().bar().baz() } catch (e) { return e.message }`),
).toBe("foo(...).bar(...).baz is not a function.")
})
test("built-ins are objects on a real prototype chain", async () => {
@@ -973,17 +961,6 @@ describe("coercion parity: unknown static members read as undefined", () => {
})
})
describe("async function line breaks", () => {
test("a line break between function and the name is an async function", async () => {
expect(await value(`async function\nfoo() { return 1 }\nreturn await foo()`)).toBe(1)
})
test("a line break between async and function is not an async function", async () => {
const failure = await error(`async\nfunction foo() { return 1 }\nreturn foo()`)
expect(failure.message).toContain("Unknown identifier 'async'")
})
})
describe("functions are objects", () => {
test("name follows NamedEvaluation and length counts required parameters", async () => {
expect(
@@ -198,10 +198,10 @@ describe("Set composition Test262 parity", () => {
keys: () => [-0],
}
return [
1 / [...new Set([1]).union(setlike)][1] === Infinity,
1 / [...new Set([0, 1, 2]).intersection(setlike)][0] === Infinity,
1 / new Set([1]).union(setlike).values()[1] === Infinity,
1 / new Set([0, 1, 2]).intersection(setlike).values()[0] === Infinity,
[...new Set([0, 1]).difference(setlike)],
1 / [...new Set([1, 2]).symmetricDifference(setlike)][2] === Infinity,
1 / new Set([1, 2]).symmetricDifference(setlike).values()[2] === Infinity,
]
`),
).toEqual([true, true, [1], true])
+8 -347
View File
@@ -20,9 +20,9 @@ import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values. JSON.stringify keeps Date -> ISO string (invalid -> null), URL -> href, and
// RegExp/Map/Set/URLSearchParams -> {}. The host boundary matches that except URLSearchParams,
// which cross as their query string, and Set, which crosses as an array.
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
@@ -635,154 +635,6 @@ describe("URL and URI helpers", () => {
})
})
describe("Headers", () => {
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
expect(
await value(`
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
headers.append("Accept", "text/html")
headers.append("accept", "application/json")
headers.set("x-count", "2")
headers.delete("x-null")
const copy = new Headers(headers)
copy.set("content-type", "text/html")
return {
get: headers.get("content-type"),
missing: headers.get("x-missing"),
combined: headers.get("ACCEPT"),
has: [headers.has("Accept"), headers.has("x-null")],
count: headers.get("x-count"),
copied: [headers.get("content-type"), copy.get("content-type")],
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
map: [...new Headers(new Map([["k", "v"]]))],
keys: [...headers.keys()],
values: [...headers.values()],
entries: [...headers.entries()],
}
`),
).toEqual({
get: "text/plain",
missing: null,
combined: "text/html, application/json",
has: [true, false],
count: "2",
copied: ["text/plain", "text/html"],
pairs: [
["a", "1"],
["b", "2"],
],
map: [["k", "v"]],
keys: ["accept", "content-type", "x-count"],
values: ["text/html, application/json", "text/plain", "2"],
entries: [
["accept", "text/html, application/json"],
["content-type", "text/plain"],
["x-count", "2"],
],
})
})
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
expect(
await value(`
const headers = new Headers({ b: "2", a: "1" })
headers.append("Set-Cookie", "x=1")
headers.append("set-cookie", "y=2")
const seen = []
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
const [first] = headers
function* pairs() { yield* headers }
return {
seen,
first,
spread: [...headers],
from: Array.from(headers).length,
generator: [...pairs()].length,
object: Object.fromEntries(headers),
cookies: headers.getSetCookie(),
}
`),
).toEqual({
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
first: ["a", "1"],
spread: [
["a", "1"],
["b", "2"],
["set-cookie", "x=1"],
["set-cookie", "y=2"],
],
from: 4,
generator: 4,
object: { a: "1", b: "2", "set-cookie": "y=2" },
cookies: ["x=1", "y=2"],
})
})
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
const result = await run(`
const headers = new Headers({ "X-A": "1", b: "2" })
console.log(headers)
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
`)
expect(result.ok && result.value).toEqual({
headers: { b: "2", "x-a": "1" },
json: '{"headers":{"b":"2","x-a":"1"}}',
text: "[object Headers]",
type: "object",
is: true,
})
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
})
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
expect(
await value(`
function message(run) {
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
}
const headers = new Headers()
return [
message(() => Headers()),
message(() => new Headers(null)),
message(() => new Headers(1)),
message(() => new Headers("a=1")),
message(() => new Headers(new Date())),
message(() => new Headers(() => 1)),
message(() => new Headers([["name"]])),
message(() => new Headers([["a", "b", "c"]])),
message(() => new Headers({ "bad name": "x" })),
message(() => new Headers({ name: "bad\u0000value" })),
message(() => headers.get("invalid\u0100")),
message(() => headers.has({})),
message(() => headers.set("a", "invalid\u0100")),
message(() => headers.append("a")),
message(() => headers.forEach()),
message(() => headers.forEach(1)),
message(() => { const get = headers.get; return get("a") }),
]
`),
).toEqual([
"Constructor Headers requires 'new'.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects iterable [name, value] pairs.",
"new Headers(...) expects iterable [name, value] pairs.",
expect.stringContaining("bad name"),
expect.stringContaining("invalid value"),
expect.stringContaining("Invalid header name"),
expect.stringContaining("[object Object]"),
expect.stringContaining("invalid value"),
"Headers.append requires 2 arguments.",
"Headers.forEach requires 1 argument.",
"Headers.forEach expects a function callback.",
"Headers.prototype.get called on incompatible receiver undefined.",
])
})
})
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
expect(
@@ -816,28 +668,18 @@ describe("Map", () => {
expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/)
})
test("keys/values/entries return live iterators", async () => {
test("keys/values/entries return arrays", async () => {
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
const keys = m.keys()
const first = keys.next()
m.set("c", 3)
return { first, rest: [...keys], values: [...m.values()], entries: [...m.entries()], same: [...m[Symbol.iterator]()] }
return { keys: m.keys(), values: m.values(), entries: m.entries() }
`),
).toEqual({
first: { value: "a", done: false },
rest: ["b", "c"],
values: [1, 2, 3],
keys: ["a", "b"],
values: [1, 2],
entries: [
["a", 1],
["b", 2],
["c", 3],
],
same: [
["a", 1],
["b", 2],
["c", 3],
],
})
})
@@ -898,42 +740,6 @@ describe("Map", () => {
).toEqual({ a: 3, b: 1, c: 1 })
})
test("getOrInsert and getOrInsertComputed insert only when the key is missing", async () => {
expect(
await value(`
const groups = new Map()
groups.getOrInsert("a", []).push(1)
groups.getOrInsert("a", []).push(2)
let calls = 0
const computed = (key) => { calls++; return key + "!" }
const first = groups.getOrInsertComputed("b", computed)
const second = groups.getOrInsertComputed("b", computed)
const zero = groups.getOrInsertComputed(-0, (key) => 1 / key === Infinity)
return [[...groups], first, second, calls, zero]
`),
).toEqual([
[
["a", [1, 2]],
["b", "b!"],
[0, true],
],
"b!",
"b!",
1,
true,
])
expect(
await value(`
const m = new Map()
const outer = m.getOrInsertComputed("k", () => { m.set("k", "inner"); return "outer" })
let thrown
try { m.getOrInsertComputed("j", () => { throw new Error("boom") }) } catch (error) { thrown = error.message }
return [outer, m.get("k"), thrown, m.has("j")]
`),
).toEqual(["outer", "outer", "boom", false])
expect((await error(`new Map().getOrInsertComputed("k", 5)`)).message).toContain("expects a function callback")
})
test("maps serialize to {} at the boundary, like JSON", async () => {
expect(await value(`return new Map([["a", 1]])`)).toEqual({})
expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}")
@@ -1097,7 +903,7 @@ describe("Uint8Array", () => {
console.log(b, new Uint8Array())
return [String(b), b + "", +new Uint8Array([5]), Number.isNaN(Number(b)), b == "1,2", JSON.stringify(b), b.toLocaleString(), typeof b, b instanceof Uint8Array]
`),
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "1,2", "object", true])
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "[object Uint8Array]", "object", true])
expect((await run(`console.log(new Uint8Array([1, 2]), new Uint8Array())`)).logs).toEqual([
"Uint8Array(2) [1,2] Uint8Array(0) []",
])
@@ -1165,151 +971,6 @@ describe("TextEncoder and TextDecoder", () => {
})
})
describe("built-in iterators", () => {
test("keys/values/entries and [Symbol.iterator] step with next() and stay live", async () => {
expect(
await value(`
const items = ["a"]
const it = items.entries()
items.push("b")
const steps = [it.next(), it.next(), it.next()]
items.push("c")
return { steps, after: it.next(), same: items[Symbol.iterator] === items.values }
`),
).toEqual({
steps: [{ value: [0, "a"], done: false }, { value: [1, "b"], done: false }, { done: true }],
after: { done: true },
same: true,
})
expect(
await value(`
const s = new Set([1, 2])
const u = new URLSearchParams("a=1&b=2")
const h = new Headers({ b: "2", a: "1" })
const bytes = new Uint8Array([7, 8])
return [
[...s.entries()], [...s[Symbol.iterator]()], s[Symbol.iterator] === s.values,
[...u.keys()], [...u[Symbol.iterator]()], u[Symbol.iterator] === u.entries,
[...h.values()], [...h[Symbol.iterator]()], h[Symbol.iterator] === h.entries,
[...bytes.entries()], [...bytes[Symbol.iterator]()], bytes[Symbol.iterator] === bytes.values,
[..."ab"[Symbol.iterator]()],
]
`),
).toEqual([
[
[1, 1],
[2, 2],
],
[1, 2],
true,
["a", "b"],
[
["a", "1"],
["b", "2"],
],
true,
["1", "2"],
[
["a", "1"],
["b", "2"],
],
true,
[
[0, 7],
[1, 8],
],
[7, 8],
true,
["a", "b"],
])
})
test("iterators are consumed once by every iteration site", async () => {
expect(
await value(`
const it = [1, 2, 3, 4].values()
const picked = []
for (const item of it) { picked.push(item); if (item === 2) break }
const [third] = it
return { picked, third, rest: [...it], spent: Array.from(it), again: it[Symbol.iterator]() === it }
`),
).toEqual({ picked: [1, 2], third: 3, rest: [4], spent: [], again: true })
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
return [
Object.fromEntries(m.entries()), Array.from(m.keys(), (k) => k + "!"), new Set(m.values()).size,
await Promise.all([Promise.resolve(1), 2].values()),
]
`),
).toEqual([{ a: 1, b: 2 }, ["a!", "b!"], 2, [1, 2]])
expect(await value(`let s = 0; for await (const v of [Promise.resolve(1), 2].values()) s += v; return s`)).toBe(3)
expect(
await value(`return new Set([1, 2]).union({ size: 1, has: () => false, keys: () => new Set([3]).keys() })`),
).toEqual([1, 2, 3])
})
test("iterators are opaque references", async () => {
expect(await value(`return [1].keys()`)).toEqual({})
expect(await value(`return JSON.stringify({ it: [1].keys() })`)).toBe('{"it":{}}')
expect(await value(`return [typeof [1].keys(), Array.isArray([1].keys()), Object.keys([1].keys())]`)).toEqual([
"object",
false,
[],
])
const logged = await run(`console.log([1].keys()); return null`)
expect(logged.logs?.[0]).toBe("[opaque reference]")
expect((await error(`return [1].keys() + ""`)).message).toContain("Binary operators require data values")
expect((await error(`return [1].keys().next.call({})`)).message).toContain("is not a function")
expect((await error(`const it = [1].keys(); const next = it.next; return next()`)).message).toContain(
"Iterator.prototype.next called on incompatible receiver undefined",
)
})
})
describe("toLocaleString", () => {
test("numbers and dates format as en-US in UTC; everything else falls back to toString", async () => {
expect(
await value(`
return [
(1234567.891).toLocaleString(), new Date(0).toLocaleString(), new Date(0).toLocaleDateString(),
new Date(0).toLocaleTimeString(), "a".toLocaleString(), true.toLocaleString(), ({}).toLocaleString(),
({ toString: () => "custom" }).toLocaleString(), new Uint8Array([1, 2]).toLocaleString(),
]
`),
).toEqual([
"1,234,567.891",
"1/1/1970, 12:00:00 AM",
"1/1/1970",
"12:00:00 AM",
"a",
"true",
"[object Object]",
"custom",
"1,2",
])
})
test("arrays join each element's toLocaleString, skipping holes and nullish elements", async () => {
expect(
await value(`
let calls = 0
const item = { toLocaleString() { calls++; return "o" } }
return [[1234.5, "x", null, undefined, item, new Date(0)].toLocaleString(), [, item, , item].toLocaleString(), calls]
`),
).toEqual(["1,234.5,x,,,o,1/1/1970, 12:00:00 AM", ",o,,o", 3])
expect((await error(`const f = ({}).toLocaleString; f()`)).message).toContain(
"Object.prototype.toLocaleString called on null or undefined",
)
})
test("toLocaleLowerCase and toLocaleUpperCase ignore the locale argument", async () => {
expect(
await value(`return ["ABC".toLocaleLowerCase("tr"), "abc".toLocaleUpperCase(), "İ".toLocaleLowerCase()]`),
).toEqual(["abc", "ABC", "i̇"])
})
})
describe("stdlib integration", () => {
test("constructor follows own keys, shadowing, writes, and new", async () => {
expect(
@@ -13,6 +13,8 @@ built-ins/Array/prototype/copyWithin/coerced-values-target.js # Array.copyWithi
built-ins/Array/prototype/copyWithin/return-abrupt-from-end.js # Expected a Test262Error but got a TypeError
built-ins/Array/prototype/copyWithin/return-abrupt-from-start.js # Expected a Test262Error but got a TypeError
built-ins/Array/prototype/copyWithin/return-abrupt-from-target.js # Expected a Test262Error but got a TypeError
built-ins/Array/prototype/entries/iteration-mutable.js # .next is not a function.
built-ins/Array/prototype/entries/iteration.js # .next is not a function.
built-ins/Array/prototype/every/15.4.4.16-7-6.js # res Expected SameValue(«true», «false») to be true
built-ins/Array/prototype/every/15.4.4.16-7-c-i-8.js # [, , , ].every(callbackfn) Expected SameValue(«true», «false») to be true
built-ins/Array/prototype/every/15.4.4.16-8-10.js # … cannot be constructed: user-defined constructors and classes are not supported. Call it as a funct
@@ -84,6 +86,8 @@ built-ins/Array/prototype/join/S15.4.4.5_A3.1_T2.js # Array.join expects zero a
built-ins/Array/prototype/join/S15.4.4.5_A3.2_T2.js # x.join() must return "*" Expected SameValue(«"[object Object]"», «"*"») to be true
built-ins/Array/prototype/join/S15.4.4.5_A4_T3.js # Array.prototype.join called on incompatible receiver a data object.
built-ins/Array/prototype/join/S15.4.4.5_A5_T1.js # #1: Array.prototype[1] = 1; x = [0]; x.length = 2; x.join() === "0,1". Actual: 0,
built-ins/Array/prototype/keys/iteration-mutable.js # .next is not a function.
built-ins/Array/prototype/keys/iteration.js # .next is not a function.
built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-1.js # Array.lastIndexOf expects start index to be a number.
built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-15.js # Array.lastIndexOf expects start index to be a number.
built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-16.js # Array.lastIndexOf expects start index to be a number.
@@ -225,6 +229,7 @@ built-ins/Array/prototype/splice/S15.4.4.12_A4_T1.js # Array.prototype.splice c
built-ins/Array/prototype/splice/S15.4.4.12_A4_T2.js # Array.prototype.splice called on incompatible receiver a data object.
built-ins/Array/prototype/splice/S15.4.4.12_A4_T3.js # Array.prototype.splice called on incompatible receiver a data object.
built-ins/Array/prototype/splice/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A1_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; var arr = [undefined, obj, null, obj, o
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A3_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; Array.prototype[1] = obj; x = [obj]; x.
built-ins/Array/prototype/toReversed/holes-not-preserved.js # Actual [4, undefined, 2, undefined, 0] and expected [4, 3, 2, undefined, 0] should have the same con
built-ins/Array/prototype/toSorted/holes-not-preserved.js # Actual [1, 3, 4, undefined, undefined] and expected [1, 2, 3, 4, undefined] should have the same con
@@ -236,6 +241,9 @@ built-ins/Array/prototype/unshift/S15.4.4.13_A2_T3.js # Array.prototype.unshift
built-ins/Array/prototype/unshift/S15.4.4.13_A3_T2.js # Array.prototype.unshift called on incompatible receiver a data object.
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T1.js # Array.prototype.unshift called on incompatible receiver a data object.
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T2.js # #3: Array.prototype[0] = 1; x = []; x.length = 1; x.unshift(0); x[1] === 1. Actual: undefined
built-ins/Array/prototype/values/iteration-mutable.js # .next is not a function.
built-ins/Array/prototype/values/iteration.js # .next is not a function.
language/statements/async-function/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
language/statements/async-generator/dflt-params-abrupt.js # Expected a Test262Error to be thrown but no exception was thrown at all
language/statements/async-generator/dflt-params-ref-later.js # Expected a ReferenceError to be thrown but no exception was thrown at all
language/statements/async-generator/dflt-params-ref-self.js # Expected a ReferenceError to be thrown but no exception was thrown at all
@@ -283,13 +291,16 @@ language/statements/async-generator/dstr/obj-ptrn-prop-id-init-throws.js # Expe
language/statements/async-generator/dstr/obj-ptrn-prop-id-init-unresolvable.js # Expected a ReferenceError to be thrown but no exception was thrown at all
language/statements/async-generator/dstr/obj-ptrn-prop-obj-value-null.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/async-generator/dstr/obj-ptrn-prop-obj-value-undef.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/async-generator/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
language/statements/async-generator/return-undefined-implicit-and-explicit.js # Actual ["tick 1", "tick 2", "g1 ret", "g2 ret", "g3 ret", "g4 ret"] and expected ["tick 1", "g1 ret"
language/statements/const/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/for-await-of/async-func-decl-dstr-array-elem-init-in.js # Failed to parse TypeScript: '…' expected.
language/statements/for-await-of/async-func-decl-dstr-obj-empty-bool.js # TypeError: Object destructuring requires a data object or array value, received a boolean.
language/statements/for-await-of/async-func-decl-dstr-obj-empty-num.js # TypeError: Object destructuring requires a data object or array value, received a number.
language/statements/for-await-of/async-func-decl-dstr-obj-empty-string.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-await-of/async-func-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
language/statements/for-await-of/async-func-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-await-of/async-gen-decl-dstr-array-elem-init-in.js # Failed to parse TypeScript: '…' expected.
language/statements/for-await-of/async-gen-decl-dstr-array-elem-iter-rtrn-close-null.js # "Promise incorrectly fulfilled."
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-bool.js # TypeError: Object destructuring requires a data object or array value, received a boolean.
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-num.js # TypeError: Object destructuring requires a data object or array value, received a number.
@@ -297,6 +308,8 @@ language/statements/for-await-of/async-gen-decl-dstr-obj-empty-string.js # Type
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-in/head-lhs-member.js # Unsupported for...in binding.
language/statements/for-of/Array.prototype.Symbol.iterator.js # The called value is not a function.
language/statements/for-of/dstr/array-elem-init-in.js # Failed to parse TypeScript: '…' expected.
language/statements/for-of/dstr/array-elem-iter-rtrn-close-err.js # Iterator next must be a function.
language/statements/for-of/dstr/array-elem-iter-rtrn-close-null.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/for-of/dstr/array-elem-iter-thrw-close-err.js # Expected SameValue(«1», «0») to be true
@@ -342,6 +355,7 @@ language/statements/function/S14_A5_T1.js # Identifier '…' has already been d
language/statements/function/S14_A5_T2.js # Identifier '…' has already been declared.
language/statements/function/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/function/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
language/statements/generators/dflt-params-abrupt.js # Expected a Test262Error to be thrown but no exception was thrown at all
language/statements/generators/dflt-params-ref-later.js # Expected a ReferenceError to be thrown but no exception was thrown at all
language/statements/generators/dflt-params-ref-self.js # Expected a ReferenceError to be thrown but no exception was thrown at all
@@ -392,6 +406,8 @@ language/statements/generators/dstr/obj-ptrn-prop-obj-value-undef.js # Expected
language/statements/generators/has-instance.js # The right-hand side of '…' has no '…' object.
language/statements/generators/prototype-typeof.js # Expected SameValue(«"undefined"», «"object"») to be true
language/statements/generators/prototype-uniqueness.js # Expected true but got false
language/statements/generators/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
language/statements/labeled/value-await-non-module-escaped.js # Failed to parse TypeScript: Keywords cannot contain escape characters.
language/statements/labeled/value-await-non-module.js # Failed to parse TypeScript: Expression expected.
language/statements/let/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/return/S12.9_A1_T1.js # expected SyntaxError but the program ran
-256
View File
@@ -3,13 +3,10 @@
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
* - WebCryptoAPI/randomUUID.https.any.js
* - fetch/api/headers/{headers-basic,headers-errors}.any.js
*
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
*
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
* `checkIteratorProperties` (prototype chain and property descriptors) and the custom iterator on a Headers
* instance are left out.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
@@ -169,256 +166,3 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
).toEqual([true, true, true, 768])
})
})
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
const testharness = `
const failures = []
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
function assert_true(actual, message) { assert_equals(actual, true, message) }
function assert_false(actual, message) { assert_equals(actual, false, message) }
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
function assert_unreached() { throw new Error("unreachable") }
`
describe("Headers WPT parity (fetch/api/headers)", () => {
test("headers-basic.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { new Headers() }, "Create headers from no parameter")
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
test(function() { new Headers({}) }, "Create headers from empty object")
var parameters = [null, 1]
parameters.forEach(function(parameter) {
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
})
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
var headerSeq = []
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
test(function() {
var headers = new Headers(headerSeq)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
}, "Create headers with sequence")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with record")
test(function() {
var headers = new Headers(headerDict)
var headers2 = new Headers(headers)
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with existing headers")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.append(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check append method")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.set(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check set method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
}, "Check has method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) {
assert_true(headers.has(name), "headers have a header: " + name)
headers.delete(name)
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
}
}, "Check delete method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
}, "Check get method")
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
var sortedHeaderDict = {}
var headerValues = []
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
headerValues.push(headerEntriesDict[value])
return value.toLowerCase()
}).sort()
test(function() {
var headers = new Headers(headerEntriesDict)
var actual = headers.keys()
sortedHeaderKeys.forEach(function(key) {
const entry = actual.next()
assert_false(entry.done)
assert_equals(entry.value, key)
})
assert_true(actual.next().done)
assert_true(actual.next().done)
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
}, "Check keys method")
test(function() {
var headers = new Headers(headerEntriesDict)
var actual = headers.values()
sortedHeaderKeys.forEach(function(key) {
const entry = actual.next()
assert_false(entry.done)
assert_equals(entry.value, sortedHeaderDict[key])
})
assert_true(actual.next().done)
assert_true(actual.next().done)
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
}, "Check values method")
test(function() {
var headers = new Headers(headerEntriesDict)
var actual = headers.entries()
sortedHeaderKeys.forEach(function(key) {
const entry = actual.next()
assert_false(entry.done)
assert_equals(entry.value[0], key)
assert_equals(entry.value[1], sortedHeaderDict[key])
})
assert_true(actual.next().done)
assert_true(actual.next().done)
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
}, "Check entries method")
test(function() {
var headers = new Headers(headerEntriesDict)
var actual = headers[Symbol.iterator]()
sortedHeaderKeys.forEach(function(key) {
const entry = actual.next()
assert_false(entry.done)
assert_equals(entry.value[0], key)
assert_equals(entry.value[1], sortedHeaderDict[key])
})
assert_true(actual.next().done)
assert_true(actual.next().done)
}, "Check Symbol.iterator method")
test(function() {
var headers = new Headers(headerEntriesDict)
var reference = sortedHeaderKeys[Symbol.iterator]()
headers.forEach(function(value, key, container) {
assert_equals(headers, container)
const entry = reference.next()
assert_false(entry.done)
assert_equals(key, entry.value)
assert_equals(value, sortedHeaderDict[entry.value])
})
assert_true(reference.next().done)
}, "Check forEach method")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
headers.delete("foo")
}
assert_array_equals(actualKeys, ["bar", "baz"])
assert_array_equals(actualValues, ["0", "1"])
}, "Iteration skips elements removed while iterating")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.delete("bar")
}
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
assert_array_equals(actualValues, ["0", "1", "3"])
}, "Removing elements already iterated over causes an element to be skipped during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("X-yZ", "4")
}
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
}, "Appending a value pair during iteration causes it to be reached during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("abc", "-1")
}
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
return failures
`),
).toEqual([])
})
test("headers-errors.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
var badNames = ["invalid\u0100", {}]
var badValues = ["invalid\u0100"]
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
})
test(function() {
var headers = new Headers([["name", "value"]])
assert_throws_js(TypeError, function() { headers.forEach() })
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
assert_throws_js(TypeError, function() { headers.forEach(1) })
}, "Headers forEach throws if argument is not callable")
test(function() {
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
var counter = 0
try {
headers.forEach(function(value, name) {
counter++
if (name == "name2") throw "error"
})
} catch (e) {
assert_equals(counter, 2)
assert_equals(e, "error")
return
}
assert_unreached()
}, "Headers forEach loop should stop if callback is throwing exception")
return failures
`),
).toEqual([])
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.11",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.11",
"version": "2.0.8",
"private": true,
"type": "module",
"license": "MIT",

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