Compare commits

..

1 Commits

Author SHA1 Message Date
opencode 7673ed6bd6 release: v2.0.8 2026-09-18 09:12:25 +00:00
418 changed files with 6441 additions and 11553 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.10",
"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.10",
"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.10",
"version": "2.0.8",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,10 +203,11 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.10",
"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.10",
"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.10",
"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.10",
"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.10",
"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.10",
"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.10",
"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.10",
"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.10",
"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.10",
"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.10",
"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.10",
"version": "2.0.8",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +545,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +559,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +574,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.10",
"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.10",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +643,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +658,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +682,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +703,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.10",
"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.10",
"version": "2.0.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +760,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.10",
"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.10",
"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.10",
"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.10",
"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.10",
"version": "2.0.8",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +893,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +928,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +963,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.10",
"version": "2.0.8",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +996,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.10",
"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.10",
"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-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
}
}
+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.10",
"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.10",
"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(
@@ -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") {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.8",
"description": "",
"type": "module",
"exports": {
@@ -10,8 +10,7 @@
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./ssh/schema": "./src/servers/ssh/schema.ts",
"./ssh": "./src/servers/ssh/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -47,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:",
@@ -88,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:",
@@ -95,4 +96,3 @@
"tailwindcss": "4.3.3"
}
}
+12 -17
View File
@@ -1,10 +1,9 @@
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { useI18n } from "@opencode/ui/context/i18n"
import { Button } from "@opencode/ui/button"
@@ -546,21 +545,17 @@ export function ComposerAttachments(props: {
</AttachmentCard>
}
>
{(image) => {
// Restored drafts and history carry image ids only; bytes load when shown.
const [url] = createResource(() => image().blob, resolveBlobUrl)
return (
<>
<img
src={url() ?? ""}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</>
)
}}
{(image) => (
<>
<img
src={image().blob.url}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</>
)}
</Show>
</Tooltip>
<button
@@ -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 -5
View File
@@ -11,7 +11,6 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
import { useData, useServer } from "@/runtime/server/current"
import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/shell/notifications/toast"
@@ -264,7 +263,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" }),
@@ -324,9 +322,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
},
openAttachment: (attachment) => {
if (attachment.type !== "image") return
void resolveBlobUrl(attachment.blob).then((src) => {
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
})
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename }))
},
openContext(key) {
const item = controller.contextItem(key)
+2 -6
View File
@@ -136,7 +136,7 @@ describe("composer persistence schemas", () => {
).toEqual(value)
})
test("migrates inline images, keeps store references without a URL, and never encodes dataUrl", () => {
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
@@ -149,16 +149,12 @@ describe("composer persistence schemas", () => {
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
],
})
expect(value.prompt).toHaveLength(6)
expect(value.prompt).toHaveLength(3)
expect(value.prompt[0]).toEqual({
...image,
sourcePath: "/image.png",
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
})
// Bytes still in the draft store resolve on use; a non-blob URL is discarded in favour of the id.
expect(value.prompt[3]).toEqual({ ...image, blob: { id: "missing", url: "" } })
expect(value.prompt[4]).toEqual({ ...image, blob: { id: "bad", url: "" } })
expect(value.prompt[5]).toEqual({ ...image, blob: { id: "missing", url: "" } })
const encoded = Schema.encodeSync(ComposerStore)(value)
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
expect(
+4 -19
View File
@@ -3,19 +3,7 @@ import { checksum } from "@opencode/util/encode"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import { Persistence } from "@/runtime/persistence/schema"
// Effect twins of workspaces/files/types until this module moves to plain codecs.
const SelectedLineRange = Persistence.struct({
start: Schema.Number,
end: Schema.Number,
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
})
const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
})
import { FileSelection, SelectedLineRange } from "@/workspaces/files/types"
const PartBase = {
content: Schema.String,
@@ -73,12 +61,11 @@ const ImageFields = {
}
const Image = Persistence.struct({
...ImageFields,
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:|$)/)) }),
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
})
// Draft storage keeps content-addressed blobs in the store until an image is shown or sent; a
// reference without a URL resolves through `resolveBlobUrl`. Legacy inline data remains usable.
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
// inline data remains usable, but unresolved references are not renderable.
export const ImageAttachmentPart = Schema.Struct({
...ImageFields,
blob: Persistence.optional(
@@ -240,5 +227,3 @@ export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pi
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
-9
View File
@@ -88,15 +88,6 @@ describe("prompt state initialization", () => {
start: 5,
end: 12,
},
// A reference without a usable URL keeps its id; the bytes resolve from the draft store on use.
{
type: "image",
id: "missing-blob",
filename: "missing.png",
mime: "image/png",
blob: { id: "content-hash-without-a-url", url: "" },
},
{ type: "image", id: "invalid-url", filename: "invalid.png", mime: "image/png", blob: { id: "hash", url: "" } },
{
type: "image",
id: "legacy",
+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 -16
View File
@@ -8,7 +8,7 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
import type { ModelSelection } from "@/providers/models/selection"
@@ -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
@@ -60,22 +59,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!read) {
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!value) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
}
if (submitting.has(input.adapter.state)) return
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
const value = {
...read,
images: await Promise.all(
read.images.map(async (image) => ({
...image,
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
})),
),
}
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
@@ -251,8 +240,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(
+7 -13
View File
@@ -11,15 +11,15 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useDialog } from "@opencode/ui/context/dialog"
import { createResource } from "solid-js"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
export const HomeServersSchema = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export function createHomeProjectsController(home: HomeController) {
@@ -114,13 +114,8 @@ export function createHomeProjectsController(home: HomeController) {
extensions: ["json"],
},
async (file) => {
// Validating an imported file is the one place the shared Effect schema is needed here.
const [{ Schema }, { SessionTransfer }] = await Promise.all([
import("effect"),
import("@opencode/schema/session-transfer"),
])
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
@@ -189,4 +184,3 @@ export function createHomeProjectsController(home: HomeController) {
}
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
+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")
+6 -6
View File
@@ -5,7 +5,7 @@ import { Show, Suspense, createMemo, createSignal, lazy, onMount } from "solid-j
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { debounce } from "@solid-primitives/scheduled"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
@@ -21,6 +21,7 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { SummaryPopover } from "@/session/summary/popover"
@@ -33,12 +34,12 @@ const NewSessionSummary = lazy(async () => {
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
export const WorkspaceOnboardingSchema = Codec.struct({
used: Codec.boolean,
export const WorkspaceOnboardingSchema = Persistence.struct({
used: Schema.Boolean,
})
export const ProviderTipSchema = Codec.struct({
dismissedAt: Codec.number,
export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export const WorkspaceTipSchema = ProviderTipSchema
@@ -266,4 +267,3 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
</Show>
)
}
+22 -11
View File
@@ -1,6 +1,7 @@
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import type { ProviderListResponse } from "@/runtime/server/types"
import { useIntegrations } from "./integrations"
@@ -52,24 +53,34 @@ export function useProviders(directory: Accessor<string | undefined>) {
.filter((integration) => popularProviderSet.has(integration.id))
.map((integration) => ({ id: integration.id, name: integration.name }))
const seen = new Set(catalog.map((integration) => integration.id))
const more = [...providers().all.values()]
.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id))
.map((p) => ({ id: p.id, name: p.name }))
return [...catalog, ...more]
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
Iterable.map((p) => ({ id: p.id, name: p.name })),
(v) => [...catalog, ...v],
)
},
connected: () => {
const connected = new Set(providers().connected)
return [...providers().all.values()].filter((p) => connected.has(p.id))
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => connected.has(p.id)),
(v) => Array.from(v),
)
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [...providers().all].filter(
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
)
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return paid
},
}
}
+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) => {
+30 -28
View File
@@ -3,11 +3,12 @@ import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Codec } from "@/runtime/persistence/codec"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
import { useSettings } from "@/settings/model"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { hasCustomAgent, resolveAgent } from "./agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -17,45 +18,46 @@ import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Codec.struct({
providerID: Codec.string,
modelID: Codec.string,
variant: Codec.optional(Codec.string),
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Schema.optional(Schema.String),
})
export type ModelKey = typeof ModelKeySchema.Type
const ChoiceSchema = Codec.struct({
model: Codec.lenientOptional(ModelKeySchema),
variant: Codec.lenientOptional(Codec.nullOr(Codec.string)),
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
const StateSchema = Codec.struct({
const StateSchema = Schema.Struct({
...ChoiceSchema.fields,
agent: Codec.lenientOptional(Codec.string),
choices: Codec.lenientOptional(Codec.record(ChoiceSchema)),
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Codec.record(Codec.fallback(Codec.undefinedOr(StateSchema), () => undefined))
const Current = Codec.struct({ session: SessionsSchema })
const StoredSelection = Codec.struct(
{
session: Codec.lenientOptional(Codec.record(Codec.unknown)),
pick: Codec.lenientOptional(Codec.record(Codec.unknown)),
},
{ preserve: true },
const SessionsSchema = Schema.Record(
Schema.String,
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
)
export const ModelSelectionSchema = Codec.migrate(
const Current = Persistence.struct({ session: SessionsSchema })
export const ModelSelectionSchema = Persistence.migrate(
Current,
Codec.transform(StoredSelection, {
decode: (value) => ({
...value,
session:
value.session ?? Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
Schema.Struct({
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => ({
session:
value.session ??
Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
})),
encode: SchemaGetter.transform((value) => value),
}),
encode: (value) => value,
}),
),
)
const WORKSPACE_KEY = "__workspace__"
+16 -9
View File
@@ -1,7 +1,7 @@
import { flatten, resolveTemplate, translator, type Flatten } from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Codec } from "@/runtime/persistence/codec"
import { Option, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode/ui/context"
import {
I18nProvider,
@@ -12,6 +12,7 @@ import {
type UiPluralCategory,
} from "@opencode/ui/context/i18n"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import en from "@/runtime/i18n/en"
import { dict } from "@opencode/ui/i18n/en"
import {
@@ -55,9 +56,14 @@ function cookie(locale: Locale) {
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const LocaleSchema = Codec.literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Codec.struct({
locale: Codec.transform(Codec.string, { decode: normalizeLocale, encode: (locale) => locale }),
const LocaleSchema = Schema.Literals(DESKTOP_NATIVE_LOCALES)
const StoredLocaleSchema = Schema.Struct({
locale: Schema.String.pipe(
Schema.decodeTo(LocaleSchema, {
decode: SchemaGetter.transform(normalizeLocale),
encode: SchemaGetter.transform((locale) => locale),
}),
),
})
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
@@ -154,11 +160,11 @@ function detectLocale(): Locale {
}
export function normalizeLocale(value: string): Locale {
return Codec.decodeOption(LocaleSchema, value) ?? "en"
return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en")
}
export const languageSchema = Codec.struct({
locale: StoredLocaleSchema.fields.locale,
export const languageSchema = Persistence.struct({
locale: StoredLocaleSchema.fields.locale,
})
function readStoredLocale() {
@@ -166,7 +172,9 @@ function readStoredLocale() {
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
return Codec.decodeOption(Codec.fromJsonString(StoredLocaleSchema), raw)?.locale
const next = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw)
if (Option.isNone(next)) return
return next.value.locale
} catch {
return
}
@@ -288,4 +296,3 @@ export function UiI18nBridge(props: { children?: JSX.Element }) {
</I18nProvider>
)
}
@@ -1,17 +0,0 @@
import { expect, test } from "bun:test"
import { Codec } from "./codec"
// Type-level checks: these compile only if inference matches what `typeof schema.Type` gave callers.
test("struct types infer optional and required fields", () => {
const s = Codec.struct({ id: Codec.string, tab: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
const value: typeof s.Type = { id: "x" }
const tab: string | undefined = value.tab
const n: number | undefined = value.n
const t = Codec.transform(s, { decode: (old) => old.tab ?? old.id, encode: (v) => ({ id: v }) })
const out: string | Codec.Invalid = t.decode({ id: "a" })
const onlyOptional = Codec.struct({ tab: Codec.optional(Codec.string) })
const empty: typeof onlyOptional.Type = {}
const maybe: string | undefined = empty.tab
const viaTransform = Codec.transform(onlyOptional, { decode: (old) => old.tab, encode: (tab) => ({ tab }) })
expect([tab, n, out, maybe, viaTransform.decode({})]).toEqual([undefined, undefined, "a", undefined, undefined])
})
@@ -1,85 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Codec } from "./codec"
describe("Codec", () => {
test("primitives reject the wrong shape and finite numbers only", () => {
expect(Codec.string.decode("a")).toBe("a")
expect(Codec.string.decode(1)).toBe(Codec.INVALID)
expect(Codec.number.decode(1.5)).toBe(1.5)
expect(Codec.number.decode(Number.NaN)).toBe(Codec.INVALID)
expect(Codec.nonNegativeInt.decode(-1)).toBe(Codec.INVALID)
expect(Codec.literals(["a", "b"]).decode("c")).toBe(Codec.INVALID)
expect(Codec.literal("x").decode("x")).toBe("x")
})
test("struct keeps optional fields absent and rejects invalid required ones", () => {
const codec = Codec.struct({ id: Codec.string, title: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
expect(codec.decode({ id: "1" })).toEqual({ id: "1" })
expect(codec.decode({ id: "1", title: "t", n: "bad" })).toEqual({ id: "1", title: "t" })
expect(codec.decode({ id: "1", title: 3 })).toBe(Codec.INVALID)
expect(codec.decode({ title: "t" })).toBe(Codec.INVALID)
expect(codec.decode([])).toBe(Codec.INVALID)
expect(codec.encode({ id: "1" })).toEqual({ id: "1" })
const value: typeof codec.Type = { id: "1", title: undefined }
expect(value.id).toBe("1")
})
test("lenient collections recover what they can", () => {
const items = Codec.lenientArray(Codec.struct({ id: Codec.string }))
expect(items.decode([{ id: "a" }, { id: 1 }, "x", { id: "b" }])).toEqual([{ id: "a" }, { id: "b" }])
expect(items.decode("nope")).toEqual([])
expect(Codec.array(Codec.string).decode(["a", 1])).toBe(Codec.INVALID)
const map = Codec.lenientRecord(Codec.boolean)
expect(map.decode({ a: true, b: "x" })).toEqual({})
expect(map.decode({ a: true })).toEqual({ a: true })
})
test("union, transform and fallback compose", () => {
const session = Codec.struct({ type: Codec.literal("session"), id: Codec.string })
const draft = Codec.struct({ type: Codec.literal("draft"), directory: Codec.string })
const tab = Codec.union([session, draft])
expect(tab.decode({ type: "draft", directory: "/x" })).toEqual({ type: "draft", directory: "/x" })
expect(tab.decode({ type: "other" })).toBe(Codec.INVALID)
const upper = Codec.transform(Codec.string, { decode: (s) => s.toUpperCase(), encode: (s) => s.toLowerCase() })
expect(upper.decode("ab")).toBe("AB")
expect(upper.encode("AB")).toBe("ab")
const safe = Codec.fallback(Codec.number, () => 7)
expect(safe.decode("x")).toBe(7)
expect(safe.decode(undefined)).toBe(7)
expect(safe.decode(2)).toBe(2)
})
test("brand constructs and decodes as its base", () => {
const Key = Codec.brand<"ServerConnection.Key">()
const key = Key.make("http://a")
expect(Key.decode(key)).toBe(key)
expect(Key.decode(3)).toBe(Codec.INVALID)
})
test("withInitial recovers field by field and merges new defaults", () => {
const layout = Codec.struct({
sidebar: Codec.struct({ opened: Codec.boolean, width: Codec.number }),
theme: Codec.lenientOptional(Codec.literals(["light", "dark"])),
})
const initial: typeof layout.Type = { sidebar: { opened: true, width: 240 } }
const codec = Codec.fromJsonString(Codec.withInitial(layout, initial))
expect(codec.decode(JSON.stringify({ sidebar: { opened: false, width: "wide" }, theme: "dark" }))).toEqual({
sidebar: { opened: false, width: 240 },
theme: "dark",
})
expect(codec.decode(JSON.stringify({ sidebar: 5 }))).toEqual(initial)
expect(codec.decode("{not json")).toBe(Codec.INVALID)
expect(JSON.parse(codec.encode({ sidebar: { opened: true, width: 1 } }))).toEqual({ sidebar: { opened: true, width: 1 } })
})
test("migrate reads the old shape first", () => {
const current = Codec.struct({ tabs: Codec.array(Codec.string) })
const previous = Codec.struct({ tab: Codec.optional(Codec.string) })
const read = Codec.transform(previous, {
decode: (old) => ({ tabs: old.tab ? [old.tab] : [] }),
encode: (value) => ({ tab: value.tabs[0] }),
})
const codec = Codec.withInitial(Codec.migrate(current, read), { tabs: [] })
expect(codec.decode({ tab: "a" })).toEqual({ tabs: ["a"] })
})
})
@@ -1,366 +0,0 @@
export * as Codec from "./codec"
// Plain codecs for persisted state. They replace Effect Schema in the renderer's initial module
// graph, where Effect's own module initialisation was the single largest startup cost that was not
// rendering. Semantics mirror the Persistence helpers: decoding never throws, `INVALID` marks a
// value that cannot be recovered, and the lenient combinators recover what they can.
export const INVALID: unique symbol = Symbol.for("opencode/persistence/codec/invalid")
export type Invalid = typeof INVALID
const tag: unique symbol = Symbol.for("opencode/persistence/codec")
export interface Of<T, E = unknown> {
readonly [tag]: true
/** Phantom: `typeof codec.Type` is the decoded type, as with Effect schemas. */
readonly Type: T
readonly Encoded: E
readonly optional?: boolean
decode(input: unknown): T | Invalid
encode(value: T): E
}
export type Any = Of<any, any>
export type Type<C extends Any> = C["Type"]
export function isCodec(value: unknown): value is Any {
return typeof value === "object" && value !== null && tag in value
}
export function make<T, E = unknown>(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of<T, E> {
return { [tag]: true, decode, encode } as Of<T, E>
}
export function is<T>(codec: Of<T>, input: unknown): input is T {
return codec.decode(input) !== INVALID
}
export function decodeOption<T>(codec: Of<T>, input: unknown): T | undefined {
const value = codec.decode(input)
return value === INVALID ? undefined : value
}
export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
const value = codec.decode(input)
if (value === INVALID) throw new Error("Value does not match its codec")
return value
}
/** Encodes and checks the result decodes, so an invalid in-memory value fails loudly instead of persisting. */
export function encodeOrThrow<T, E>(codec: Of<T, E>, value: T): E {
const encoded = codec.encode(value)
if (codec.decode(encoded) === INVALID) throw new Error("Value does not match its codec")
return encoded
}
const identity = <T>(value: T) => value
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
export const boolean: Of<boolean, boolean> = make((v) => (typeof v === "boolean" ? v : INVALID), identity)
export const unknown: Of<unknown, unknown> = make((v) => v, identity)
/** Finite numbers only: NaN and infinities are not JSON and never valid state. */
export const number: Of<number, number> = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity)
export const int: Of<number, number> = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity)
export const nonNegativeInt: Of<number, number> = make(
(v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID),
identity,
)
export function literal<const L extends string | number | boolean | null>(value: L): Of<L, L> {
return make((v) => (v === value ? value : INVALID), identity)
}
export function literals<const L extends ReadonlyArray<string | number | boolean | null>>(values: L): Of<L[number], L[number]> {
const set = new Set<unknown>(values)
return make((v) => (set.has(v) ? (v as L[number]) : INVALID), identity)
}
/** A string carrying a nominal brand, with the constructor Effect's `Schema.brand` gave callers. */
export function brand<B extends string>(): Of<string & { readonly [K in B]: B }, string> & {
make(value: string): string & { readonly [K in B]: B }
} {
return Object.assign(make<string & { readonly [K in B]: B }, string>((v) => (typeof v === "string" ? (v as never) : INVALID), identity), {
make: (value: string) => value as never,
})
}
export function nullOr<T, E>(codec: Of<T, E>): Of<T | null, E | null> {
return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v)))
}
export function undefinedOr<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> {
return make((v) => (v === undefined ? undefined : codec.decode(v)), (v) => (v === undefined ? undefined : codec.encode(v)))
}
/** A struct field that may be absent. Present but invalid values make the struct invalid. */
export function optional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return { ...undefinedOr(codec), optional: true } as never
}
/** A struct field that may be absent, and whose invalid values are dropped rather than rejected. */
export function lenientOptional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
return {
...make<T | undefined, E | undefined>(
(v) => {
if (v === undefined) return undefined
const value = codec.decode(v)
return value === INVALID ? undefined : value
},
(v) => (v === undefined ? undefined : codec.encode(v)),
),
optional: true,
} as never
}
type Fields = Record<string, Any>
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F]
type RequiredKeys<F extends Fields> = Exclude<keyof F, OptionalKeys<F>>
type Simplify<T> = { [K in keyof T]: T[K] } & {}
export type StructType<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Type"] } & { [K in OptionalKeys<F>]?: F[K]["Type"] }
>
export type StructEncoded<F extends Fields> = Simplify<
{ [K in RequiredKeys<F>]: F[K]["Encoded"] } & { [K in OptionalKeys<F>]?: F[K]["Encoded"] }
>
export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncoded<F>> {
readonly fields: F
}
// `preserve` keeps keys the struct does not declare, for migration shapes that only describe the
// fields they rewrite (Effect's `onExcessProperty: "preserve"`); the current schema then decides.
export function struct<const F extends Fields>(fields: F, options?: { preserve?: boolean }): Struct<F> {
const entries = Object.entries(fields)
return {
...make<StructType<F>, StructEncoded<F>>(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const record = input as Record<string, unknown>
const out: Record<string, unknown> = options?.preserve ? { ...record } : {}
for (const [key, codec] of entries) {
const present = Object.hasOwn(record, key)
if (!present && codec.optional) continue
const value = codec.decode(record[key])
if (value === INVALID) return INVALID
if (value !== undefined || present) out[key] = value
else delete out[key]
}
return out as StructType<F>
},
(value) => {
const out: Record<string, unknown> = {}
for (const [key, codec] of entries) {
const field = (value as Record<string, unknown>)[key]
if (field === undefined && !Object.hasOwn(value as object, key)) continue
out[key] = codec.encode(field)
}
return out as StructEncoded<F>
},
),
fields,
}
}
export function array<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return INVALID
const out: T[] = []
for (const item of input) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out.push(value)
}
return out
},
(value) => value.map((item) => codec.encode(item)),
)
}
/** Keeps the items that decode and drops the rest, like `Persistence.array`. */
export function lenientArray<T, E>(codec: Of<T, E>): Of<T[], E[]> {
return make(
(input) => {
if (!Array.isArray(input)) return []
return input.flatMap((item) => {
const value = codec.decode(item)
return value === INVALID ? [] : [value]
})
},
(value) => value.map((item) => codec.encode(item)),
)
}
export function record<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
return make(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const out: Record<string, T> = {}
for (const [key, item] of Object.entries(input)) {
const value = codec.decode(item)
if (value === INVALID) return INVALID
out[key] = value
}
return out
},
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
)
}
/** A record that drops entries whose values are invalid, the replacement for `catchDecoding` to none. */
export function sparseRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
return make(
(input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
const out: Record<string, T> = {}
for (const [key, item] of Object.entries(input)) {
const value = codec.decode(item)
if (value !== INVALID) out[key] = value
}
return out
},
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
)
}
/** An invalid record becomes empty rather than failing the whole store, like `Persistence.record`. */
export function lenientRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
const strict = record(codec)
return make(
(input) => {
const value = strict.decode(input)
return value === INVALID ? {} : value
},
strict.encode,
)
}
export function union<const C extends ReadonlyArray<Any>>(codecs: C): Of<C[number]["Type"], C[number]["Encoded"]> {
return make(
(input) => {
for (const codec of codecs) {
const value = codec.decode(input)
if (value !== INVALID) return value
}
return INVALID
},
(value) => {
// Encode with the first member that accepts the value's shape; members are disjoint in practice.
for (const codec of codecs) if (codec.decode(value) !== INVALID) return codec.encode(value)
return value as C[number]["Encoded"]
},
)
}
/** Maps a decoded value into another shape, the replacement for `decodeTo` + `SchemaGetter.transform`. */
export function transform<T, E, T2>(
codec: Of<T, E>,
options: { decode: (value: T) => T2; encode: (value: T2) => T },
): Of<T2, E> {
return make(
(input) => {
const value = codec.decode(input)
return value === INVALID ? INVALID : options.decode(value)
},
(value) => codec.encode(options.encode(value)),
)
}
/** Decodes with `source`, maps, then validates with `target`: Effect's `decodeTo` with a transform. */
export function decodeTo<T, E, T2, E2>(
source: Of<T, E>,
target: Of<T2, E2>,
options: { decode: (value: T) => E2; encode: (value: T2) => T },
): Of<T2, E> {
return make(
(input) => {
const value = source.decode(input)
return value === INVALID ? INVALID : target.decode(options.decode(value))
},
(value) => source.encode(options.encode(value)),
)
}
/** Invalid and missing values become `value()`, like `Persistence.fallback`. */
export function fallback<T, E>(codec: Of<T, E>, value: () => NoInfer<T>): Of<T, E> {
return make(
(input) => {
if (input === undefined) return value()
const decoded = codec.decode(input)
return decoded === INVALID ? value() : decoded
},
codec.encode,
)
}
export function fromJsonString<T, E>(codec: Of<T, E>): Of<T, string> {
return make(
(input) => {
if (typeof input !== "string") return INVALID
try {
return codec.decode(JSON.parse(input))
} catch {
return INVALID
}
},
(value) => JSON.stringify(codec.encode(value)),
)
}
export type Decoder = Pick<Of<unknown>, "decode">
export type Migrated<C extends Any> = { readonly current: C; readonly read: Decoder }
/** Older stored shapes go through `read` first; `current` describes what the store holds today. */
export function migrate<C extends Any>(current: C, read: Decoder): Migrated<C> {
return { current, read }
}
function isMigrated<C extends Any>(definition: C | Migrated<C>): definition is Migrated<C> {
return !isCodec(definition) && "current" in definition
}
// Stored values recover field by field against the initial value: an object's valid fields are
// kept, invalid or missing ones take their initial counterpart, and the result is merged over the
// initial so new fields appear with their defaults. Mirrors `Persistence.withInitial`.
export function withInitial<C extends Any>(definition: C | Migrated<C>, initial: Type<C>): Of<Type<C>, unknown> {
const codec = isMigrated(definition) ? definition.current : definition
const read = isMigrated(definition) ? definition.read : unknown
return make(
(input) => {
const stored = read.decode(input)
if (stored === INVALID) return INVALID
return merge(initial, recover(codec, stored, initial))
},
(value) => codec.encode(value),
)
}
function recover(codec: Any, value: unknown, initial: unknown): unknown {
if (value === undefined) return initial
if ("fields" in codec && isObject(value)) {
const fields = (codec as Struct<Fields>).fields
return Object.fromEntries(
Object.entries(fields).flatMap(([name, field]) => {
const defaults = isObject(initial) ? initial[name] : undefined
const next = recover(field, value[name], defaults)
if (next === undefined && !Object.hasOwn(value, name) && defaults === undefined) return []
return [[name, next]]
}),
)
}
const decoded = codec.decode(value)
return decoded === INVALID ? initial : decoded
}
function merge(initial: unknown, value: unknown): unknown {
if (value === undefined) return initial
if (!isObject(initial) || !isObject(value)) return value
return Object.fromEntries(
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
)
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -3,7 +3,6 @@ import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { Codec } from "@/runtime/persistence/codec"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
import { languageSchema } from "@/runtime/i18n/language"
import { HomeServersSchema } from "@/home/projects/controller"
@@ -11,9 +10,9 @@ import { ModelProvidersSchema } from "@/settings/models/models"
describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceOnboardingSchema, { used: false }), input))
const tip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProviderTipSchema, { dismissedAt: 0 }), input))
const workspaceTip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }), input))
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -26,7 +25,7 @@ describe("persisted consumer schemas", () => {
test("collapse records recover malformed entries without losing valid siblings", () => {
for (const schema of [HomeServersSchema, ModelProvidersSchema]) {
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(schema, { collapsed: {} }), input)
const decode = Schema.decodeUnknownSync(Persistence.withInitial(schema, { collapsed: {} }))
expect(decode({})).toEqual({ collapsed: {} })
expect(decode({ collapsed: [] })).toEqual({ collapsed: {} })
expect(decode({ collapsed: { open: false, closed: true, invalid: "false" } })).toEqual({
@@ -36,19 +35,21 @@ describe("persisted consumer schemas", () => {
})
test("model selection migrates legacy picks and omits workspace state", () => {
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), input)
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))
expect(decode({})).toEqual({ session: {} })
const state = decode({ pick: { __workspace__: { agent: "plan" }, session1: { agent: "build" } } })
expect(state.session.session1?.agent).toBe("build")
expect(state.session.__workspace__).toBeUndefined()
const encoded = Codec.fromJsonString(Codec.withInitial(ModelSelectionSchema, { session: {} })).encode(state)
const encoded = Schema.encodeSync(
Schema.fromJsonString(Persistence.withInitial(ModelSelectionSchema, { session: {} })),
)(state)
expect(JSON.parse(encoded)).toEqual({ session: { session1: { agent: "build" } } })
expect(decode(JSON.parse(encoded))).toEqual(state)
})
test("current model selections take precedence over legacy picks", () => {
expect(
Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
session: {},
pick: { session1: { agent: "plan" } },
}),
@@ -56,7 +57,7 @@ describe("persisted consumer schemas", () => {
})
test("model selection validates nested model keys and preserves explicit null variants", () => {
const state = Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
const state = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
session: {
good: { agent: "build", model: { providerID: "provider", modelID: "model", variant: "high" }, variant: null },
partial: { agent: "plan", model: { providerID: "provider", modelID: 42 }, variant: false },
@@ -75,7 +76,7 @@ describe("persisted consumer schemas", () => {
})
test("file views validate scroll positions and line sides independently", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(FileViewsSchema, { file: {} }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(FileViewsSchema, { file: {} }))
expect(decode({})).toEqual({ file: {} })
const state = decode({
file: {
@@ -102,7 +103,7 @@ describe("persisted consumer schemas", () => {
})
test("language preserves runtime defaults and normalizes unsupported locales to English", () => {
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(languageSchema, { locale: "fr" }), input)
const decode = Schema.decodeUnknownSync(Persistence.withInitial(languageSchema, { locale: "fr" }))
expect(decode({})).toEqual({ locale: "fr" })
expect(decode({ locale: undefined })).toEqual({ locale: "fr" })
expect(decode({ locale: 42 })).toEqual({ locale: "fr" })
@@ -110,6 +111,3 @@ describe("persisted consumer schemas", () => {
expect(decode({ locale: "ar" })).toEqual({ locale: "ar" })
})
})
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { createDraftStore, draftTextChunk, draftTextThreshold, resolveBlobUrl } from "./drafts"
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
function memoryDriver() {
const documents = new Map<string, string>()
@@ -287,34 +287,15 @@ describe("draft store image retention", () => {
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references without fetching their bytes", async () => {
test("loading a document pins the images it references", async () => {
const { memory, store } = fresh()
const reads: string[] = []
const getBlob = memory.driver.getBlob
memory.driver.getBlob = (id) => {
reads.push(id)
return getBlob(id)
}
const id = await memory.driver.putBlob(image(6))
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
const loaded = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob
expect(loaded).toEqual({ id })
expect(reads).toEqual([])
// The first consumer that shows or sends the image loads it; the pin from the load keeps it.
const url = (await resolveBlobUrl(loaded))!
expect(url.startsWith("blob:")).toBe(true)
expect(reads).toEqual([id])
expect(await resolveBlobUrl(loaded)).toBe(url)
expect(reads).toEqual([id])
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
await tick()
expect(await released(store, 6, url)).toBe(false)
await store.removeItem("loaded")
await tick()
expect(await released(store, 6, url)).toBe(true)
})
test("a reference to bytes the store no longer holds resolves to nothing", async () => {
fresh()
expect(await resolveBlobUrl({ id: "gone" })).toBeUndefined()
})
})
+11 -29
View File
@@ -1,5 +1,5 @@
import type { AsyncStorage } from "@solid-primitives/storage"
import { Codec } from "./codec"
import { Option, Schema } from "effect"
export type BlobReference = { id: string; url: string }
@@ -42,18 +42,6 @@ const refs = new Map<string, Set<string>>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
// Fetches image bytes from the store created last. Documents load without their bytes; a consumer
// that renders or sends an image resolves its URL through here, so a history full of large
// attachments costs nothing at startup.
let loader: ((id: string) => Promise<string | undefined>) | undefined
/** The object URL for an image reference, loading its bytes from the draft store on first use. */
export function resolveBlobUrl(blob: { id: string; url?: string }) {
if (blob.url) return Promise.resolve(blob.url)
const existing = retained.get(aliases.get(blob.id) ?? blob.id)
if (existing) return Promise.resolve(existing.url)
return loader?.(blob.id) ?? Promise.resolve(undefined)
}
function blobUrl(id: string, blob: Blob, grace?: number) {
const existing = retained.get(id)
@@ -132,7 +120,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
const existing = retained.get(id)
if (existing) return Promise.resolve(existing.url)
if (existing) return existing.url
const pending = loading.get(id)
if (pending) return pending
const next = driver
@@ -142,7 +130,6 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
loading.set(id, next)
return next
}
loader = loadBlobUrl
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob, grace) }
@@ -234,11 +221,9 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
if (ref.kind === "text" && Array.isArray(ref.ids)) {
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
}
// Bytes stay in the store until something renders or sends the image (see resolveBlobUrl);
// only an image already pinned in this page gets its URL back immediately.
if (typeof ref.id === "string") {
const url = retained.get(aliases.get(ref.id) ?? ref.id)?.url
return { ...item, blob: url ? { id: ref.id, url } : { id: ref.id } }
const url = await loadBlobUrl(ref.id)
if (url) return { ...item, blob: { id: ref.id, url } }
}
}
return Object.fromEntries(
@@ -312,12 +297,12 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
getItem: async (key) => {
const value = await driver.get(key)
if (value === null) return null
const parsed = Codec.fromJsonString(Codec.unknown).decode(value)
// Let the owning persistence codec apply its invalid-document policy.
if (parsed === Codec.INVALID) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed), grace)
return JSON.stringify(await decode(parsed))
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed.value), grace)
return JSON.stringify(await decode(parsed.value))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
setDocument,
@@ -438,9 +423,7 @@ function referenced(json: string) {
export async function blobDataUrl(blob: BlobReference, mime: string) {
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const url = kept ? undefined : await resolveBlobUrl(blob)
if (!kept && !url) throw new Error(`Attachment ${blob.id} has no stored bytes`)
const data = kept ? kept.blob : await fetch(url!).then((response) => response.blob())
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
@@ -455,4 +438,3 @@ export async function blobDataUrl(blob: BlobReference, mime: string) {
export function createLegacyBlobReference(dataUrl: string): BlobReference {
return { id: dataUrl, url: dataUrl }
}
+11 -44
View File
@@ -6,7 +6,6 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Codec } from "./codec"
import { persistStore } from "./persist"
import { Persistence } from "./schema"
@@ -473,57 +472,25 @@ export function removePersisted(
}
}
type Definition<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any> =
| S
| Persistence.Migrated<Extract<S, Schema.ConstraintCodec<object, unknown>>>
| Codec.Migrated<Extract<S, Codec.Any>>
// Persisted stores are moving from Effect Schema to the plain codecs in ./codec so the renderer
// stops paying for Effect at startup; both are accepted while the migration is underway.
function serializer<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
definition: Definition<S>,
initial: S["Type"],
) {
if (Codec.isCodec(definition) || (!("current" in definition) ? false : Codec.isCodec(definition.current))) {
const codec = Codec.withInitial(definition as Codec.Any | Codec.Migrated<Codec.Any>, initial)
const json = Codec.fromJsonString(codec)
return {
decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined,
deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"],
serialize: (value: S["Type"]) => Codec.encodeOrThrow(json, value),
encode: (value: S["Type"]) => Codec.encodeOrThrow(codec, value),
initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"],
}
}
const schema = definition as Schema.ConstraintCodec<object, unknown> | Persistence.Migrated<Schema.ConstraintCodec<object, unknown>>
const initialized = Persistence.withInitial(schema, initial as object)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
return {
decode: (raw: string) => Option.getOrUndefined(decode(raw)) as S["Type"] | undefined,
deserialize: Schema.decodeUnknownSync(json) as (raw: unknown) => S["Type"],
serialize: Schema.encodeSync(json) as (value: S["Type"]) => string,
encode: Schema.encodeSync(initialized) as (value: S["Type"]) => unknown,
initial: Schema.decodeUnknownSync(Schema.toType(initialized))(initial as object) as S["Type"],
}
}
export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
target: string | PersistTarget,
schema: Definition<S>,
schema: S | Persistence.Migrated<S>,
initial: NoInfer<S["Type"]>,
platformOverride?: Platform,
): PersistedWithReady<S["Type"]> {
const platform = platformOverride ?? usePlatform()
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
const codec = serializer<S>(schema, initial)
const { encode, serialize } = codec
const initialized = Persistence.withInitial(schema, initial)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
const encode = Schema.encodeSync(initialized)
const serialize = Schema.encodeSync(json)
const normalize = (raw: string) => {
const value = codec.decode(raw)
if (value !== undefined) return serialize(value)
const value = decode(raw)
if (Option.isSome(value)) return serialize(value.value)
}
const store = createStore<S["Type"]>(codec.initial)
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
const isDesktop = platform.platform === "desktop" && !!platform.storage
const draft = config.draft ? platform.draftStore : undefined
const prefix = `${config.storage ?? "default"}:`
@@ -635,7 +602,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Co
name: config.key,
storage,
serialize,
deserialize: codec.deserialize,
deserialize: Schema.decodeUnknownSync(json),
sync: channel ? messageSync(channel) : undefined,
// Drafts take the encoded document itself so large text is externalized without the store
// re-parsing the serialized form on every save.
-11
View File
@@ -1,11 +0,0 @@
import type { Brand } from "effect"
import { Codec } from "@/runtime/persistence/codec"
// The server key's brand is shared with the Effect schema in ./persistence.ts (type only, so this
// module loads nothing of Effect), letting stores port to plain codecs one at a time.
export type ServerKey = string & Brand.Brand<"ServerConnection.Key">
export const ServerKey: Codec.Of<ServerKey, string> & { make(value: string): ServerKey } = Object.assign(
Codec.make<ServerKey, string>((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v),
{ make: (value: string) => value as ServerKey },
)
@@ -1,14 +1,15 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { IconState, ModelState, ProjectState, VcsState, serverState } from "./persistence"
import { createRoot } from "solid-js"
import { isServer } from "solid-js/web"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
const initial = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
function serverSchema(canonical?: () => string | undefined) {
return Codec.withInitial(serverState(canonical), initial)
return Persistence.withInitial(serverState(canonical), initial)
}
describe("server persistence schema", () => {
@@ -28,7 +29,7 @@ describe("server persistence schema", () => {
],
projects: { local: [{ worktree: "/project", expanded: true }] },
}
const state = Codec.decodeOrThrow(schema, input)
const state = Schema.decodeUnknownSync(schema)(input)
expect(state).toEqual({
list: [
{ type: "http", http: { url: "http://localhost:4096" } },
@@ -47,13 +48,13 @@ describe("server persistence schema", () => {
recentlyClosed: {},
})
expect(input.list[1]).toHaveProperty("username", "legacy")
const encoded = schema.encode(state)
const encoded = Schema.encodeSync(schema)(state)
expect(encoded).toEqual(state)
expect(Codec.decodeOrThrow(schema, encoded)).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(encoded)).toEqual(state)
})
test("defaults missing or malformed fields and drops invalid entries independently", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(serverSchema(), input))
const decode = Schema.decodeUnknownSync(serverSchema())
const empty = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
expect(decode({})).toEqual(empty)
expect(decode({ list: null, hidden: [], projects: false, lastProject: 1, recentlyClosed: "bad" })).toEqual(empty)
@@ -73,7 +74,7 @@ describe("server persistence schema", () => {
test("moves canonical project buckets without changing server keys or unrelated scopes", () => {
const schema = serverSchema(() => "https://opencode.example.com")
const state = Codec.decodeOrThrow(schema, {
const state = Schema.decodeUnknownSync(schema)({
list: ["https://opencode.example.com"],
hidden: { "https://opencode.example.com": true },
projects: {
@@ -99,14 +100,14 @@ describe("server persistence schema", () => {
expect(state.list[0]?.http.url).toBe("https://opencode.example.com")
expect(state.hidden).toEqual({ "https://opencode.example.com": true })
expect(state.recentlyClosed).toEqual({ local: ["/closed"], "https://opencode.example.com": ["/old-closed"] })
expect(schema.encode(state)).toEqual(state)
expect(Codec.decodeOrThrow(schema, state)).toEqual(state)
expect(Schema.encodeSync(schema)(state)).toEqual(state)
expect(Schema.decodeUnknownSync(schema)(state)).toEqual(state)
})
test("reads the latest canonical local prop on each decode", () => {
const props: { canonicalLocalServer?: string } = {}
const schema = serverSchema(() => props.canonicalLocalServer)
const decode = ((input: unknown) => Codec.decodeOrThrow(schema, input))
const decode = Schema.decodeUnknownSync(schema)
const input = {
projects: { remote: [{ worktree: "/project", expanded: true }] },
lastProject: { remote: "/project" },
@@ -121,7 +122,7 @@ describe("server persistence schema", () => {
})
test("migrates a last project without a project list", () => {
expect(Codec.decodeOrThrow(serverSchema(() => "remote"), { lastProject: { remote: "/project" } })).toEqual({
expect(Schema.decodeUnknownSync(serverSchema(() => "remote"))({ lastProject: { remote: "/project" } })).toEqual({
list: [],
hidden: {},
projects: {},
@@ -133,7 +134,7 @@ describe("server persistence schema", () => {
describe("model persistence schema", () => {
test("defaults missing state and keeps valid entries beside malformed entries", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelState, { user: [], recent: [], variant: {} }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelState, { user: [], recent: [], variant: {} }))
expect(decode({})).toEqual({ user: [], recent: [], variant: {} })
expect(decode({ user: null, recent: 1, variant: [] })).toEqual({ user: [], recent: [], variant: {} })
const state = decode({
@@ -154,24 +155,24 @@ describe("model persistence schema", () => {
recent: [{ providerID: "provider", modelID: "model" }],
variant: { model: "high" },
})
expect(ModelState.encode(state)).toEqual(state)
expect(Schema.encodeSync(ModelState)(state)).toEqual(state)
})
})
describe("directory cache schemas", () => {
test("defaults missing and malformed VCS caches but retains optional branch metadata", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(VcsState, { value: undefined }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(VcsState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: { branch: 1 } })).toEqual({ value: undefined })
expect(decode({ value: { default_branch: "main" } })).toEqual({ value: { default_branch: "main" } })
const state = decode({ value: { branch: "feature", default_branch: "main", obsolete: true } })
expect(state).toEqual({ value: { branch: "feature", default_branch: "main" } })
expect(VcsState.encode(state)).toEqual(state)
expect(Schema.encodeSync(VcsState)(state)).toEqual(state)
})
test("validates project name, icon overrides and startup commands", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProjectState, { value: undefined }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ProjectState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: [] })).toEqual({ value: undefined })
expect(decode({ value: { icon: { override: 1 } } })).toEqual({ value: undefined })
@@ -184,7 +185,7 @@ describe("directory cache schemas", () => {
commands: { start: "bun dev" },
},
})
expect(ProjectState.encode(state)).toEqual(state)
expect(Schema.encodeSync(ProjectState)(state)).toEqual(state)
expect(state.value).toEqual({
name: "Project",
icon: { override: "data:image/png;base64,abc", color: "blue" },
@@ -193,12 +194,12 @@ describe("directory cache schemas", () => {
})
test("validates optional icon strings", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(IconState, { value: undefined }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(IconState, { value: undefined }))
expect(decode({})).toEqual({ value: undefined })
expect(decode({ value: 42 })).toEqual({ value: undefined })
expect(decode({ value: null })).toEqual({ value: undefined })
expect(decode({ value: "" })).toEqual({ value: "" })
expect(IconState.encode(decode({ value: "data:image/png;base64,abc" }))).toEqual({
expect(Schema.encodeSync(IconState)(decode({ value: "data:image/png;base64,abc" }))).toEqual({
value: "data:image/png;base64,abc",
})
})
@@ -254,7 +255,7 @@ test.skipIf(isServer)(
const stored = values.get("opencode.global.dat:server")
expect(stored).toBeDefined()
if (!stored) throw new Error("server state was not written")
const decoded = Codec.decodeOrThrow(Codec.fromJsonString(serverSchema()), stored)
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(serverSchema()))(stored)
expect(decoded.projects.local).toEqual([{ worktree: "/project", expanded: true }])
expect(stored).not.toContain("username")
expect(decoded.list).toEqual(root.state[0].list)
@@ -263,4 +264,3 @@ test.skipIf(isServer)(
}
},
)
+103 -94
View File
@@ -1,127 +1,136 @@
import { Codec } from "@/runtime/persistence/codec"
import { ServerKey } from "./key"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export { ServerKey }
export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
export const ServerHttpBase = Codec.struct({
url: Codec.string,
password: Codec.optional(Codec.string),
export const ServerHttpBase = Persistence.struct({
url: Schema.String,
password: Schema.optional(Schema.String),
})
export const ServerHttp = Codec.struct({
type: Codec.literal("http"),
export const ServerHttp = Persistence.struct({
type: Schema.Literal("http"),
http: ServerHttpBase,
authToken: Codec.optional(Codec.boolean),
displayName: Codec.optional(Codec.string),
label: Codec.optional(Codec.string),
authToken: Schema.optional(Schema.Boolean),
displayName: Schema.optional(Schema.String),
label: Schema.optional(Schema.String),
})
// Servers were stored as a URL string, then as the HTTP block alone, before the current shape.
const StoredServer = Codec.decodeTo(Codec.union([ServerHttp, ServerHttpBase, Codec.string]), ServerHttp, {
decode: (value) => {
if (typeof value === "string") return { type: "http" as const, http: { url: value } }
if ("http" in value) return value
return { type: "http" as const, http: value }
},
encode: (value) => value,
})
const ProjectList = Codec.lenientArray(
Codec.struct({
worktree: Codec.string,
expanded: Codec.fallback(Codec.boolean, () => true),
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
Schema.decodeTo(ServerHttp, {
decode: SchemaGetter.transform((value) => {
if (typeof value === "string") return { type: "http", http: { url: value } }
if ("http" in value) return value
return { type: "http", http: value }
}),
encode: SchemaGetter.transform((value) => value),
}),
)
const Projects = Codec.lenientRecord(ProjectList)
const LastProject = Codec.fallback(Codec.sparseRecord(Codec.string), () => ({}))
const State = Codec.struct({
list: Codec.lenientArray(StoredServer),
hidden: Codec.sparseRecord(Codec.boolean),
projects: Codec.record(ProjectList),
lastProject: Codec.sparseRecord(Codec.string),
recentlyClosed: Codec.record(Codec.lenientArray(Codec.string)),
const ProjectList = Persistence.array(
Persistence.struct({
worktree: Schema.String,
expanded: Persistence.fallback(Schema.Boolean, () => true),
}),
)
const Projects = Persistence.record(ProjectList)
const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))
const State = Persistence.struct({
list: Persistence.array(StoredServer),
hidden: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.Boolean.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
projects: Schema.Record(Schema.String, Schema.mutableKey(ProjectList)),
lastProject: Schema.Record(
Schema.String,
Schema.mutableKey(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
),
recentlyClosed: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(Schema.String))),
})
const StoredState = Codec.struct({ projects: Projects, lastProject: LastProject }, { preserve: true })
// Projects and last-opened entries recorded under the canonical local server's URL move under
// "local" when that URL is known, so they survive the server changing address.
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
return Codec.migrate(
return Persistence.migrate(
State,
Codec.transform(StoredState, {
decode: (value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const canonical = canonicalLocalServer()
if (!canonical || canonical === "local") return value
const previous = value.projects[canonical]
const last = value.lastProject[canonical]
if (!previous && last === undefined) return value
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
},
encode: (value) => value,
}),
const projects = { ...value.projects }
if (previous) {
const local = projects.local ?? []
const worktrees = new Set(local.map((project) => project.worktree))
projects.local = [
...local,
...previous.filter((project) => {
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
}),
]
delete projects[canonical]
}
const lastProject = { ...value.lastProject }
if (last !== undefined) {
lastProject.local ??= last
delete lastProject[canonical]
}
return { ...value, projects, lastProject }
}),
encode: SchemaGetter.transform((value) => value),
}),
),
)
}
export const ModelState = Codec.struct({
user: Codec.lenientArray(
Codec.struct({
providerID: Codec.string,
modelID: Codec.string,
visibility: Codec.literals(["show", "hide"]),
favorite: Codec.optional(Codec.boolean),
export const ModelState = Persistence.struct({
user: Persistence.array(
Persistence.struct({
providerID: Schema.String,
modelID: Schema.String,
visibility: Schema.Literals(["show", "hide"]),
favorite: Schema.optional(Schema.Boolean),
}),
),
recent: Codec.lenientArray(Codec.struct({ providerID: Codec.string, modelID: Codec.string })),
variant: Codec.sparseRecord(Codec.undefinedOr(Codec.string)),
recent: Persistence.array(Persistence.struct({ providerID: Schema.String, modelID: Schema.String })),
variant: Schema.Record(
Schema.String,
Schema.mutableKey(
Schema.UndefinedOr(Schema.String).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
),
),
})
export const VcsState = Codec.struct({
value: Codec.optional(
Codec.struct({
branch: Codec.optional(Codec.string),
default_branch: Codec.optional(Codec.string),
export const VcsState = Persistence.struct({
value: Schema.optional(
Persistence.struct({
branch: Schema.optional(Schema.String),
default_branch: Schema.optional(Schema.String),
}),
),
})
const ProjectMeta = Codec.struct({
name: Codec.optional(Codec.string),
icon: Codec.optional(
Codec.struct({
override: Codec.optional(Codec.string),
color: Codec.optional(Codec.string),
const ProjectMeta = Persistence.struct({
name: Schema.optional(Schema.String),
icon: Schema.optional(
Persistence.struct({
override: Schema.optional(Schema.String),
color: Schema.optional(Schema.String),
}),
),
commands: Codec.optional(Codec.struct({ start: Codec.optional(Codec.string) })),
commands: Schema.optional(Persistence.struct({ start: Schema.optional(Schema.String) })),
})
export const ProjectState = Codec.struct({
value: Codec.optional(ProjectMeta),
export const ProjectState = Persistence.struct({
value: Schema.optional(ProjectMeta),
})
export const IconState = Codec.struct({
value: Codec.optional(Codec.string),
export const IconState = Persistence.struct({
value: Schema.optional(Schema.String),
})
@@ -1,12 +1,13 @@
import { describe, expect, test } from "bun:test"
import { canRemoveServer, createServerProjects, resolveServerList, ServerConnection } from "./registry"
import { Schema } from "effect"
import { serverState } from "./persistence"
import { createStore } from "solid-js/store"
import { ServerScope } from "./scope"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
function serverSchema() {
return Codec.withInitial(serverState(), {
return Persistence.withInitial(serverState(), {
list: [],
hidden: {},
projects: {},
@@ -18,7 +19,7 @@ function serverSchema() {
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: Codec.decodeOrThrow(serverSchema(), { list: [{ url: "https://server.example.test" }] }).list,
stored: Schema.decodeUnknownSync(serverSchema())({ list: [{ url: "https://server.example.test" }] }).list,
props: [
{
type: "http",
@@ -43,7 +44,7 @@ describe("resolveServerList", () => {
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: Codec.decodeOrThrow(serverSchema(), {
stored: Schema.decodeUnknownSync(serverSchema())({
list: [{ url: "https://server.example.test", password: "saved" }],
}).list,
props: [{ type: "http", http: { url: "https://server.example.test" } }],
@@ -76,7 +77,7 @@ test("treats WSL sidecars as remote server connections", () => {
})
test("keeps exact persisted server identities and prevents removing provided servers", () => {
const stored = Codec.decodeOrThrow(serverSchema(), {
const stored = Schema.decodeUnknownSync(serverSchema())({
list: ["http://localhost:4096", "http://localhost:4096/", "http://127.0.0.1:4096"],
}).list
expect(resolveServerList({ stored }).map((server) => String(ServerConnection.key(server)))).toEqual([
@@ -90,7 +91,7 @@ test("keeps exact persisted server identities and prevents removing provided ser
})
test("project actions update schema-derived state and follow dynamic server scopes", () => {
const [store, setStore] = createStore(Codec.decodeOrThrow(serverSchema(), {}))
const [store, setStore] = createStore(Schema.decodeUnknownSync(serverSchema())({}))
const props: { server: ServerConnection.Key; canonicalLocalServer?: ServerConnection.Key } = {
server: ServerConnection.Key.make("https://remote.example"),
}
@@ -114,4 +115,3 @@ test("project actions update schema-derived state and follow dynamic server scop
expect(store.projects.local).toEqual([{ worktree: "/local", expanded: true }])
expect(store.projects[props.server]).toEqual([{ worktree: "/remote", expanded: false }])
})
+12 -13
View File
@@ -1,12 +1,12 @@
import { Codec } from "@/runtime/persistence/codec"
import { Option, Schema } from "effect"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Codec.fromJsonString(
Codec.struct({
urls: Codec.array(Codec.string),
username: Codec.literal("opencode"),
password: Codec.string,
}),
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
)
export function serverAddress(value: string) {
@@ -20,10 +20,9 @@ export function serverAddress(value: string) {
}
export function decodePairingCode(value: string) {
const result = Codec.decodeOption(pairing, value)
if (!result) return
const urls = [...new Set(result.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.password }
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.value.password }
}
+51 -44
View File
@@ -1,3 +1,4 @@
import { Effect, Fiber } from "effect"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { SshConfig, SshItem, SshPlatform } from "./types"
@@ -23,8 +24,7 @@ export function createSshController(input: {
| undefined
>
>({})
// One in-flight request per server; cancelling settles it immediately and ignores its outcome.
const tasks = new Map<string, { cancelled: boolean }>()
const tasks = new Map<string, Fiber.Fiber<void>>()
const item = (id: string) => input.items().find((item) => item.config.id === id)
const settle = (id: string) => {
const attempt = attempts[id]
@@ -33,32 +33,31 @@ export function createSshController(input: {
setAttempts(id, { active: false, onConnected: undefined })
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
}
const finish = (id: string, task: { cancelled: boolean }) => {
if (task.cancelled) return
task.cancelled = true
if (tasks.get(id) === task) tasks.delete(id)
setAttempts(id, "submitting", false)
}
const interrupt = (id: string) => {
const task = tasks.get(id)
if (task) finish(id, task)
}
const run = (id: string, work: () => Promise<unknown>) => {
setAttempts(id, { submitting: true, error: false })
const task = { cancelled: false }
tasks.set(id, task)
work().then(
() => finish(id, task),
() => {
if (task.cancelled) return
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
finish(id, task)
},
)
}
onCleanup(() => {
for (const id of [...tasks.keys()]) interrupt(id)
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
setAttempts(id, { submitting: true, error: false })
tasks.set(
id,
Effect.runFork(
effect.pipe(
Effect.asVoid,
Effect.catch(() =>
Effect.sync(() => {
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
}),
),
Effect.ensuring(
Effect.sync(() => {
tasks.delete(id)
setAttempts(id, "submitting", false)
}),
),
),
),
)
}
onCleanup(() => {
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
})
createEffect(() => {
for (const item of input.items()) {
@@ -115,32 +114,40 @@ export function createSshController(input: {
error: false,
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
})
run(config.id, async () => {
await api.start({ ...config, replace: options?.replace })
// Observe admission before treating an older disconnected snapshot as cancellation.
await input.refresh()
})
run(
config.id,
Effect.gen(function* () {
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
// Observe admission before treating an older disconnected snapshot as cancellation.
yield* Effect.tryPromise(input.refresh)
}),
)
},
respond: (id: string, prompt: string, value: string) => {
const api = input.api
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
setAttempts(id, "answered", prompt)
run(id, () => api.respond(id, prompt, value))
run(
id,
Effect.tryPromise(() => api.respond(id, prompt, value)),
)
},
cancel: (id: string) => {
const api = input.api
interrupt(id)
setAttempts(id, undefined)
if (!api) return
void api
.cancel(id)
.then(() => (item(id)?.saved ? undefined : api.forget(id)))
.catch(() => undefined)
cancel: (id: string) => {
const task = tasks.get(id)
const api = input.api
Effect.runFork(
Effect.gen(function* () {
if (task) yield* Fiber.interrupt(task)
setAttempts(id, undefined)
if (!api) return
yield* Effect.tryPromise(() => api.cancel(id))
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
}).pipe(Effect.ignore),
)
},
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
disconnect: (id: string) => input.api?.disconnect(id),
forget: (id: string) => input.api?.forget(id),
}
}
-61
View File
@@ -1,61 +0,0 @@
import { Schema } from "effect"
// The SSH state contract between the desktop main process and its renderer. Only the main process
// needs the schemas (its RPC server validates with them); the renderer imports the types from ./types.
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
+60 -8
View File
@@ -1,9 +1,63 @@
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
import type { SshConfig, SshHttp, SshItem, SshStart, SshState } from "./schema"
export type { SshConfig, SshHttp, SshItem, SshStart, SshState }
import { Schema } from "effect"
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
export type SshPlatform = {
getState(): Promise<SshState>
subscribe(callback: (state: SshState) => void): () => void
@@ -16,5 +70,3 @@ export type SshPlatform = {
forget(id: string): Promise<void>
openConfig(): Promise<void>
}
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(OpenAppPreferences, { app: "finder" }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
describe("open app preferences", () => {
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
@@ -17,4 +18,3 @@ describe("open app preferences", () => {
expect(decode({})).toEqual({ app: "finder" })
})
})
@@ -5,7 +5,8 @@ import { usePlatform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
@@ -29,8 +30,8 @@ export const OPEN_APPS = [
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const OpenAppPreferences = Codec.struct({
app: Codec.literals(OPEN_APPS),
export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
@@ -241,4 +242,3 @@ function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
appExistence.set(app, request)
return request
}
@@ -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)
},
}
}
+8 -12
View File
@@ -5,20 +5,17 @@ import {
type SessionReviewExpandMode,
} from "@opencode/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
const ReviewPanel = Codec.struct({
sidebarOpened: Codec.boolean,
sidebarWidth: Codec.make<number, number>(
(v) =>
typeof v === "number" && v >= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN && v <= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX
? v
: Codec.INVALID,
(v) => v,
),
expandMode: Codec.literals(["expand", "collapse"]),
const ReviewPanel = Persistence.struct({
sidebarOpened: Schema.Boolean,
sidebarWidth: Schema.Finite.check(
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
),
expandMode: Schema.Literals(["expand", "collapse"]),
})
export function createReviewPanelState(platform?: Platform) {
@@ -50,4 +47,3 @@ export function createReviewPanelState(platform?: Platform) {
}
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
@@ -2,8 +2,9 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/runtime/server/scope"
import { base64Encode } from "@opencode/util/encode"
import { Persist } from "@/runtime/persistence/storage"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
import type { Platform } from "@/runtime/platform/platform"
import { Schema } from "effect"
let getWorkspaceTerminalCacheKey: typeof import("./context").getWorkspaceTerminalCacheKey
let clearWorkspaceTerminals: typeof import("./context").clearWorkspaceTerminals
@@ -20,10 +21,10 @@ beforeAll(async () => {
const mod = await import("./context")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
clearWorkspaceTerminals = mod.clearWorkspaceTerminals
const schema = Codec.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = ((input: unknown) => Codec.decodeOrThrow(schema, input))
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
decodeTerminalState = Schema.decodeUnknownSync(schema)
roundTripTerminalState = (value) =>
Codec.decodeOrThrow(schema, schema.encode(Codec.decodeOrThrow(schema, value)))
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
})
describe("getWorkspaceTerminalCacheKey", () => {
@@ -140,4 +141,3 @@ describe("TerminalState", () => {
expect(roundTripTerminalState(value)).toEqual(value)
})
})
+31 -33
View File
@@ -8,20 +8,18 @@ import { base64Encode } from "@opencode/util/encode"
import { defaultTitle, titleNumber } from "./title"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema, SchemaGetter } from "effect"
const PTY = Codec.struct({
id: Codec.make<string, string>(
(v) => (typeof v === "string" && v.length > 0 ? v : Codec.INVALID),
(v) => v,
),
title: Codec.fallback(Codec.string, () => ""),
titleNumber: Codec.fallback(Codec.number, () => 0),
rows: Codec.lenientOptional(Codec.number),
cols: Codec.lenientOptional(Codec.number),
buffer: Codec.lenientOptional(Codec.string),
scrollY: Codec.lenientOptional(Codec.number),
cursor: Codec.lenientOptional(Codec.number),
const PTY = Persistence.struct({
id: Schema.NonEmptyString,
title: Persistence.fallback(Schema.String, () => ""),
titleNumber: Persistence.fallback(Schema.Finite, () => 0),
rows: Persistence.optional(Schema.Finite),
cols: Persistence.optional(Schema.Finite),
buffer: Persistence.optional(Schema.String),
scrollY: Persistence.optional(Schema.Finite),
cursor: Persistence.optional(Schema.Finite),
})
export type LocalPTY = typeof PTY.Type
@@ -33,26 +31,28 @@ function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
}
const State = Codec.struct({
active: Codec.lenientOptional(Codec.string),
all: Codec.lenientArray(PTY),
const State = Persistence.struct({
active: Persistence.optional(Schema.String),
all: Persistence.array(PTY),
})
export const TerminalState = Codec.transform(State, {
decode: (value): typeof State.Type => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
},
encode: (value) => value,
})
export const TerminalState = State.pipe(
Schema.decodeTo(Schema.toType(State), {
decode: SchemaGetter.transform((value) => {
const seen = new Set<string>()
const all = value.all.flatMap((pty) => {
if (seen.has(pty.id)) return []
seen.add(pty.id)
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
})
return {
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
all,
}
}),
encode: SchemaGetter.transform((value) => value),
}),
)
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
@@ -458,5 +458,3 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
},
})
@@ -1,40 +1,40 @@
import { describe, expect, test } from "bun:test"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Codec } from "@/runtime/persistence/codec"
const schema = Codec.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(GoUpsellState, {
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
}),
)
describe("usage exceeded preferences", () => {
test("defaults unseen prompts", () => {
expect(decode({})).toEqual({
go_upsell_last_seen_at: null,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: null,
})
})
test("preserves timestamps while recovering malformed siblings", () => {
expect(
decode({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: "true",
go_upsell_account_rate_limit_last_seen_at: Infinity,
go_upsell_account_rate_limit_dont_show: 456,
}),
).toEqual({
go_upsell_last_seen_at: 123,
go_upsell_dont_show: null,
go_upsell_account_rate_limit_last_seen_at: null,
go_upsell_account_rate_limit_dont_show: 456,
})
})
})
@@ -2,7 +2,8 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode/client/promise"
import { onCleanup } from "solid-js"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useSessionLayout } from "./session-layout"
import { useDialog, useI18n } from "@opencode/ui/context"
import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded"
@@ -14,11 +15,11 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
export const GoUpsellState = Codec.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Codec.nullOr(Codec.number),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Codec.nullOr(Codec.number),
export const GoUpsellState = Persistence.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite),
})
function goUpsellKeys(status: SessionStatus) {
@@ -105,4 +106,3 @@ export function useUsageExceededDialogs() {
}),
)
}
@@ -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"
+8 -10
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
import {
settingsSchema,
settingsPersistence,
@@ -12,9 +13,9 @@ import {
terminalFontFamily,
} from "./model"
const schema = Codec.withInitial(settingsPersistence, defaultSettings)
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
const encode = (value: typeof settingsSchema.Type) => schema.encode(value)
const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
describe("settings timeline detail migration", () => {
test("migrates saved switches and round trips the current settings", () => {
@@ -50,14 +51,14 @@ describe("settings schema", () => {
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
appearance: { ...defaultSettings.appearance, fontSize: 20 },
}
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(settingsPersistence, initial), input)
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
placement: "separate",
details: "expanded",
})
expect(() => Codec.decodeOrThrow(settingsSchema, {})).toThrow()
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
})
test("supplies the existing defaults for an empty document", () => {
@@ -170,7 +171,7 @@ describe("settings schema", () => {
test("does not silently repair invalid values during encoding", () => {
expect(() =>
Codec.encodeOrThrow(settingsSchema, { ...decode({}), appearance: { fontSize: "large" } } as never),
Schema.encodeUnknownSync(settingsSchema)({ ...decode({}), appearance: { fontSize: "large" } }),
).toThrow()
})
})
@@ -202,6 +203,3 @@ describe("settings font families", () => {
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
})
})
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -5,7 +5,8 @@ import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
import { useModels } from "@/providers/models/models"
import { useServerSDK } from "@/runtime/server/client"
@@ -19,8 +20,8 @@ type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const ModelProvidersSchema = Codec.struct({
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
@@ -210,4 +211,3 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
</>
)
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import {
activeCommandRegistrations,
addCommandRegistration,
@@ -10,12 +10,12 @@ import {
} from "./command"
test("command catalog persistence validates metadata and omits executable fields", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(CommandCatalog, input))
const decode = Schema.decodeUnknownSync(CommandCatalog)
const catalog = decode({ open: { title: "Open", keybind: "mod+o", hidden: false, onSelect: "invalid" } })
expect(catalog).toEqual({ open: { title: "Open", keybind: "mod+o", hidden: false } })
expect(decode({})).toEqual({})
expect(() => decode({ open: { title: 1 } })).toThrow()
expect(decode(CommandCatalog.encode(catalog))).toEqual(catalog)
expect(decode(Schema.encodeSync(CommandCatalog)(catalog))).toEqual(catalog)
})
const paletteOptions: CommandOption[] = [
@@ -79,4 +79,3 @@ describe("resolveKeybindOption", () => {
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
})
})
+10 -11
View File
@@ -2,8 +2,8 @@ import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
@@ -109,16 +109,16 @@ export function resolveKeybindOption(candidates: CommandOption[] | undefined, ev
type CommandSource = "palette" | "keybind" | "slash"
export const CommandCatalogItem = Codec.struct({
title: Codec.string,
description: Codec.optional(Codec.string),
category: Codec.optional(Codec.string),
keybind: Codec.optional(Codec.string),
slash: Codec.optional(Codec.string),
hidden: Codec.optional(Codec.boolean),
export const CommandCatalogItem = Persistence.struct({
title: Schema.String,
description: Schema.optional(Schema.String),
category: Schema.optional(Schema.String),
keybind: Schema.optional(Schema.String),
slash: Schema.optional(Schema.String),
hidden: Schema.optional(Schema.Boolean),
})
export type CommandCatalogItem = typeof CommandCatalogItem.Type
export const CommandCatalog = Codec.record(CommandCatalogItem)
export const CommandCatalog = Schema.Record(Schema.String, Schema.mutableKey(CommandCatalogItem))
export type CommandCatalog = typeof CommandCatalog.Type
export type CommandRegistration = {
@@ -480,4 +480,3 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
},
})
@@ -1,22 +1,23 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "@/shell/tabs/tabs"
import { NotificationStore, openNotificationSession, type Notification } from "./notification"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
test("notification persistence validates and salvages individual notifications", () => {
const valid: Notification[] = [
{ type: "turn-complete", time: 123, viewed: false, session: "session-1" },
{ type: "error", time: 124, viewed: true, error: { type: "api", message: "failed", status: 500 } },
]
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(NotificationStore, { list: [] }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(NotificationStore, { list: [] }))
const store = decode({
list: [valid[0], null, { type: "unknown", time: 123, viewed: false }, { ...valid[1], error: "invalid" }, valid[1]],
})
expect(store.list).toEqual(valid)
expect(decode({})).toEqual({ list: [] })
expect(decode({ list: {} })).toEqual({ list: [] })
expect(decode(NotificationStore.encode(store))).toEqual(store)
expect(decode(Schema.encodeSync(NotificationStore)(store))).toEqual(store)
})
test("opens notification sessions through the tab router", () => {
@@ -40,4 +41,3 @@ test("opens notification sessions through the tab router", () => {
expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"])
})
@@ -1,6 +1,6 @@
import { createStore, reconcile } from "solid-js/store"
import { Codec } from "@/runtime/persistence/codec"
import type { SessionError } from "@opencode/schema/session-error"
import { Schema } from "effect"
import { SessionError } from "@opencode/schema/session-error"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import type { ServerSDK } from "@/runtime/server/client"
@@ -10,7 +10,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { useGlobal } from "@/runtime/server/runtime"
@@ -20,28 +20,19 @@ import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
const NotificationBase = {
directory: Codec.optional(Codec.string),
session: Codec.optional(Codec.string),
metadata: Codec.optional(Codec.unknown),
time: Codec.number,
viewed: Codec.boolean,
}
// The error payload is whatever the server reported; shape-checking it here would load the shared
// Effect schema into the renderer's startup path for a value the server already validated.
const StoredSessionError = Codec.make<SessionError.Error, unknown>(
(value) =>
typeof value === "object" && value !== null && "name" in value && "message" in value
? (value as unknown as SessionError.Error)
: Codec.INVALID,
(value) => value,
)
export const Notification = Codec.union([
Codec.struct({ ...NotificationBase, type: Codec.literal("turn-complete") }),
Codec.struct({ ...NotificationBase, type: Codec.literal("error"), error: StoredSessionError }),
const NotificationBase = {
directory: Schema.optional(Schema.String),
session: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Unknown),
time: Schema.Finite,
viewed: Schema.Boolean,
}
export const Notification = Schema.Union([
Persistence.struct({ ...NotificationBase, type: Schema.Literal("turn-complete") }),
Persistence.struct({ ...NotificationBase, type: Schema.Literal("error"), error: SessionError.Error }),
])
export type Notification = typeof Notification.Type
export const NotificationStore = Codec.struct({ list: Codec.lenientArray(Notification) })
export const NotificationStore = Persistence.struct({ list: Persistence.array(Notification) })
type NotificationIndex = {
session: {
@@ -377,5 +368,3 @@ export const useNotification = () => {
const server = useServer()
return server.ctx.notification
}
+8 -8
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { Schema } from "effect"
import { ServerConnection } from "@/runtime/server/registry"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
@@ -10,21 +11,21 @@ test("settings has its own layout route", () => {
})
describe("layout persistence", () => {
const schema = Codec.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
initial.fileTree.width = 300
initial.review.panelOpened = true
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(layoutPersistence, initial), input)
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
expect(() => Codec.decodeOrThrow(layoutSchema, {})).toThrow()
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
})
test("restores shipped defaults for missing and invalid fields", () => {
@@ -55,8 +56,8 @@ describe("layout persistence", () => {
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
expect(schema.encode(value)).toEqual(value)
expect(decode(schema.encode(value))).toEqual(value)
expect(Schema.encodeSync(schema)(value)).toEqual(value)
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
})
@@ -168,4 +169,3 @@ describe("pruneSessionKeys", () => {
expect(drop).toEqual([])
})
})
File diff suppressed because it is too large Load Diff
+50 -45
View File
@@ -1,58 +1,63 @@
export * as TabStorage from "./schema"
import { Codec } from "@/runtime/persistence/codec"
import { ServerKey } from "@/runtime/server/key"
import { Schema, SchemaGetter } from "effect"
import { ServerKey } from "@/runtime/server/persistence"
import { Persistence } from "@/runtime/persistence/schema"
export { ServerKey }
export const Session = Codec.struct({
type: Codec.literal("session"),
export const Session = Persistence.struct({
type: Schema.Literal("session"),
server: ServerKey,
sessionId: Codec.string,
routeSessionId: Codec.lenientOptional(Codec.string),
routeParentId: Codec.lenientOptional(Codec.string),
sessionId: Schema.String,
routeSessionId: Persistence.optional(Schema.String),
routeParentId: Persistence.optional(Schema.String),
})
export const Draft = Codec.struct({
type: Codec.literal("draft"),
draftID: Codec.string,
export const Draft = Persistence.struct({
type: Schema.Literal("draft"),
draftID: Schema.String,
server: ServerKey,
directory: Codec.string,
worktree: Codec.lenientOptional(Codec.string),
branch: Codec.lenientOptional(Codec.string),
mcp: Codec.lenientOptional(Codec.struct({ target: Codec.string, states: Codec.lenientRecord(Codec.boolean) })),
directory: Schema.String,
worktree: Persistence.optional(Schema.String),
branch: Persistence.optional(Schema.String),
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
})
// A stored route that only repeats the session id carries nothing; drop it and its parent.
const SessionCodec = Codec.transform(Session, {
decode: (tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
}),
encode: (tab) => tab,
})
export const Tab = Codec.union([Session, Draft])
export const Tabs = Codec.lenientArray(Codec.union([SessionCodec, Draft]))
export const Recent = Codec.struct({
key: Codec.optional(Codec.string),
})
export const Info = Codec.struct({
title: Codec.optional(Codec.string),
directory: Codec.optional(Codec.string),
})
export const Infos = Codec.record(Info)
export const Panes = Codec.record(
Codec.struct({
terminal: Codec.optional(Codec.boolean),
review: Codec.optional(Codec.boolean),
terminalHeight: Codec.optional(Codec.number),
sessionWidth: Codec.optional(Codec.number),
const SessionCodec = Session.pipe(
Schema.decodeTo(Schema.toType(Session), {
decode: SchemaGetter.transform((tab) => ({
type: tab.type,
server: tab.server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
: {}),
})),
encode: SchemaGetter.transform((tab) => tab),
}),
)
export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt })
export const Closed = Codec.lenientArray(ClosedTab)
export const Tab = Schema.Union([Session, Draft])
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
export const Recent = Persistence.struct({
key: Schema.optional(Schema.String),
})
export const Info = Persistence.struct({
title: Schema.optional(Schema.String),
directory: Schema.optional(Schema.String),
})
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
export const Panes = Schema.Record(
Schema.String,
Schema.mutableKey(
Persistence.struct({
terminal: Schema.optional(Schema.Boolean),
review: Schema.optional(Schema.Boolean),
terminalHeight: Schema.optional(Schema.Finite),
sessionWidth: Schema.optional(Schema.Finite),
}),
),
)
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
export const Closed = Persistence.array(ClosedTab)
+14 -15
View File
@@ -3,12 +3,13 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { Schema } from "effect"
import { TabStorage } from "./schema"
import type { ServerConnection } from "@/runtime/server/registry"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input))
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
@@ -25,7 +26,7 @@ describe("tab migration", () => {
}
const restored = decodeTabs([legacy, draft])
expect(restored).toEqual([legacy, draft])
expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft])
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
})
test("drops null and malformed persisted tabs", () => {
@@ -63,13 +64,13 @@ describe("tab migration", () => {
draft,
])
expect(tabs).toEqual([sessionTab("root"), draft])
expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs)
expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs)
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
})
test("salvages valid closed session tabs", () => {
expect(
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
{ tab: sessionTab("a"), index: 1 },
{ tab: sessionTab("b"), index: -1 },
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
@@ -80,16 +81,16 @@ describe("tab migration", () => {
test("validates auxiliary tab state", () => {
expect(
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }),
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
).toEqual({ key: undefined })
expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({})
expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
tab: { title: "Title", directory: "/project" },
})
const panes = Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: true, terminalHeight: 300 } })
expect(TabStorage.Panes.encode(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: "yes" } })).toThrow()
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
})
})
@@ -208,5 +209,3 @@ describe("closed tab stack", () => {
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
})
})
@@ -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)
}
@@ -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)
}}
@@ -1,13 +1,13 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { HighlightsStore } from "./highlights"
import { Codec } from "@/runtime/persistence/codec"
import { Persistence } from "@/runtime/persistence/schema"
test("highlight persistence defaults missing or invalid versions and round-trips valid versions", () => {
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(HighlightsStore, { version: undefined }), input))
const decode = Schema.decodeUnknownSync(Persistence.withInitial(HighlightsStore, { version: undefined }))
expect(decode({})).toEqual({ version: undefined })
expect(decode({ version: null })).toEqual({ version: undefined })
const value = decode({ version: "1.2.3", legacy: true })
expect(value).toEqual({ version: "1.2.3" })
expect(HighlightsStore.encode(value)).toEqual(value)
expect(Schema.encodeSync(HighlightsStore)(value)).toEqual(value)
})
@@ -1,18 +1,18 @@
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { usePlatform } from "@/runtime/platform/platform"
import { useSettings } from "@/settings/model"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { DialogReleaseNotes, type Highlight } from "@/shell/updates/release-notes"
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
export const HighlightsStore = Codec.struct({
version: Codec.undefinedOr(Codec.string),
export const HighlightsStore = Persistence.struct({
version: Schema.UndefinedOr(Schema.String),
})
type ParsedRelease = {
@@ -233,4 +233,3 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
}
},
})
+12 -12
View File
@@ -1,19 +1,20 @@
import type { FileContent } from "@/runtime/server/types"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
export const FileSelection = Codec.struct({
startLine: Codec.number,
startChar: Codec.number,
endLine: Codec.number,
endChar: Codec.number,
export const FileSelection = Persistence.struct({
startLine: Schema.Number,
startChar: Schema.Number,
endLine: Schema.Number,
endChar: Schema.Number,
})
export type FileSelection = typeof FileSelection.Type
export const SelectedLineRange = Codec.struct({
start: Codec.number,
end: Codec.number,
side: Codec.lenientOptional(Codec.literals(["additions", "deletions"])),
endSide: Codec.lenientOptional(Codec.literals(["additions", "deletions"])),
export const SelectedLineRange = Persistence.struct({
start: Schema.Number,
end: Schema.Number,
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
})
export type SelectedLineRange = typeof SelectedLineRange.Type
@@ -43,4 +44,3 @@ export function selectionFromLines(range: SelectedLineRange): FileSelection {
endChar: 0,
}
}
@@ -1,7 +1,8 @@
import { createEffect, createRoot } from "solid-js"
import { produce } from "solid-js/store"
import { Codec } from "@/runtime/persistence/codec"
import { Schema } from "effect"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import { SelectedLineRange } from "./types"
import type { ServerScope } from "@/runtime/server/scope"
@@ -10,14 +11,14 @@ const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
const FileViewSchema = Codec.struct({
scrollTop: Codec.lenientOptional(Codec.number),
scrollLeft: Codec.lenientOptional(Codec.number),
selectedLines: Codec.lenientOptional(Codec.nullOr(SelectedLineRange)),
const FileViewSchema = Persistence.struct({
scrollTop: Persistence.optional(Schema.Finite),
scrollLeft: Persistence.optional(Schema.Finite),
selectedLines: Persistence.optional(Schema.NullOr(SelectedLineRange)),
})
export const FileViewsSchema = Codec.struct({
file: Codec.lenientRecord(Codec.fallback(FileViewSchema, () => ({}))),
export const FileViewsSchema = Schema.Struct({
file: Persistence.record(Persistence.fallback(FileViewSchema, () => ({}))),
})
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
@@ -149,4 +150,3 @@ export function createFileViewCache(scope: ServerScope) {
clear: () => cache.clear(),
}
}
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { resolveObjectURL } from "node:buffer"
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { createDraftStore } from "@/runtime/persistence/drafts"
function fixture(id: string, getBlob: () => Promise<Blob | null>) {
const documents = new Map([
@@ -20,19 +20,7 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
return { store, documents }
}
test("loading history and a draft reads no image bytes", async () => {
let reads = 0
const { store } = fixture("history-cache-lazy", async () => {
reads++
return new Blob(["shared screenshot"])
})
const [history, draft] = await Promise.all([store.getItem("history"), store.getItem("draft")])
expect(JSON.parse(history!).entries[0].prompt[0].blob).toEqual({ id: "history-cache-lazy" })
expect(JSON.parse(draft!).prompt[0].blob).toEqual({ id: "history-cache-lazy" })
expect(reads).toBe(0)
})
test("deduplicates concurrent resolves without invalidating either live reference", async () => {
test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => {
const pending = Promise.withResolvers<Blob | null>()
const started = Promise.withResolvers<void>()
let reads = 0
@@ -41,33 +29,55 @@ test("deduplicates concurrent resolves without invalidating either live referenc
started.resolve()
return pending.promise
})
await store.getItem("history")
const first = resolveBlobUrl({ id: "history-cache-concurrent" })
const second = resolveBlobUrl({ id: "history-cache-concurrent" })
const history = store.getItem("history")
const draft = store.getItem("draft")
await started.promise
pending.resolve(new Blob(["shared screenshot"]))
const [a, b] = await Promise.all([first, second])
expect(a).toBe(b!)
const [saved, active] = await Promise.all([history, draft])
const reference = JSON.parse(saved!).entries[0].prompt[0].blob
expect(JSON.parse(active!).prompt[0].blob).toEqual(reference)
expect(reads).toBe(1)
await store.removeItem("history")
expect(await resolveObjectURL(a!)?.text()).toBe("shared screenshot")
expect(await resolveBlobUrl({ id: "history-cache-concurrent" })).toBe(a!)
expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot")
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
expect(reads).toBe(1)
})
test("a document re-read while its image is live gets the URL back without a read", async () => {
test("hydrates repeated references once within one history document", async () => {
let reads = 0
const { store, documents } = fixture("history-cache-repeated", async () => {
reads++
return new Blob(["repeated screenshot"])
})
documents.set(
"history",
JSON.stringify({
entries: Array.from({ length: 100 }, () => ({
prompt: [{ type: "image", blob: { id: "history-cache-repeated" } }],
})),
}),
)
const value = JSON.parse((await store.getItem("history"))!)
expect(value.entries).toHaveLength(100)
expect(
new Set(value.entries.map((entry: { prompt: { blob: { url: string } }[] }) => entry.prompt[0].blob.url)).size,
).toBe(1)
expect(reads).toBe(1)
})
test("reuses a live URL on remount but reads the latest document", async () => {
let reads = 0
const { store, documents } = fixture("history-cache-remount", async () => {
reads++
return new Blob(["saved screenshot"])
})
const url = await resolveBlobUrl({ id: "history-cache-remount" })
const first = JSON.parse((await store.getItem("history"))!)
const changed = JSON.parse(documents.get("history")!)
changed.entries[0].prompt.unshift({ type: "text", content: "new admission" })
documents.set("history", JSON.stringify(changed))
const second = JSON.parse((await store.getItem("history"))!)
expect(second.entries[0].prompt[0].content).toBe("new admission")
expect(second.entries[0].prompt[1].blob).toEqual({ id: "history-cache-remount", url })
expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob)
expect(reads).toBe(1)
})
@@ -79,34 +89,33 @@ test("reuses a just-stored attachment without a round trip", async () => {
})
const reference = await store.putBlob(new Blob(["pending admission"]))
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
expect(await resolveBlobUrl({ id: reference.id })).toBe(reference.url)
expect(reads).toBe(0)
expect(await resolveObjectURL(reference.url)?.text()).toBe("pending admission")
})
test("does not retain a missing blob result", async () => {
let reads = 0
fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toBeUndefined()
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toStartWith("blob:")
const { store } = fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toBeUndefined()
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toStartWith("blob:")
expect(reads).toBe(2)
})
test("retries after a failed blob read", async () => {
let reads = 0
fixture("history-cache-failure", async () => {
const { store } = fixture("history-cache-failure", async () => {
if (++reads === 1) throw new Error("temporary storage failure")
return new Blob(["recovered"])
})
await expect(resolveBlobUrl({ id: "history-cache-failure" })).rejects.toThrow("temporary storage failure")
expect(await resolveBlobUrl({ id: "history-cache-failure" })).toStartWith("blob:")
await expect(store.getItem("history")).rejects.toThrow("temporary storage failure")
expect(JSON.parse((await store.getItem("history"))!).entries[0].prompt[0].blob.url).toStartWith("blob:")
expect(reads).toBe(2)
})
test("keeps different blob IDs independent", async () => {
const reads: string[] = []
createDraftStore({
get: async () => null,
const store = createDraftStore({
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
set: async () => [],
remove: async () => {},
putBlob: async () => "unused",
@@ -115,8 +124,10 @@ test("keeps different blob IDs independent", async () => {
return new Blob([id])
},
})
const urls = await Promise.all(["history-cache-first", "history-cache-second"].map((id) => resolveBlobUrl({ id })))
expect(urls[0]).not.toBe(urls[1])
expect(await Promise.all(urls.map((url) => resolveObjectURL(url!)?.text()))).toEqual(reads)
const value = JSON.parse((await store.getItem("history"))!)
expect(value[0].blob.url).not.toBe(value[1].blob.url)
expect(
await Promise.all(value.map((item: { blob: { url: string } }) => resolveObjectURL(item.blob.url)?.text())),
).toEqual(reads)
expect(reads).toEqual(["history-cache-first", "history-cache-second"])
})
@@ -5,7 +5,7 @@ import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { createComposerReady, createComposerState } from "@/composer/state"
import { ServerScope } from "@/runtime/server/scope"
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { createDraftStore } from "@/runtime/persistence/drafts"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
@@ -103,11 +103,15 @@ describe("prompt persistence", () => {
}),
}))
await root.session.ready.promise
// Bytes stay in the store until the image is shown or sent.
expect(root.session.current()).toEqual([
{ type: "image", id: "image", filename: "image.png", mime: "image/png", blob: { id: "composer-image", url: "" } },
{
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
},
])
expect(await resolveBlobUrl(root.session.current()[0]!.blob)).toStartWith("blob:")
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
flushPersisted()
await Bun.sleep(0)
@@ -233,7 +237,7 @@ describe("prompt persistence", () => {
})
})
test("moves image data URLs into blobs and resolves object URLs on demand", async () => {
test("moves image data URLs into blobs and hydrates object URLs", async () => {
const documents = new Map<string, string>()
const blobs = new Map<string, Blob>()
const store = createDraftStore({
@@ -254,8 +258,8 @@ test("moves image data URLs into blobs and resolves object URLs on demand", asyn
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
expect(documents.get("prompt")).not.toContain("dataUrl")
const value = JSON.parse((await store.getItem("prompt"))!)
expect(value.prompt[0].blob).toEqual({ id: "1" })
expect(await resolveBlobUrl(value.prompt[0].blob)).toStartWith("blob:")
expect(value.prompt[0].blob.id).toBe("1")
expect(value.prompt[0].blob.url).toStartWith("blob:")
})
test("does not let delayed blob migration overwrite a newer draft", async () => {
+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.10",
"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.10",
"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

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