Compare commits

..
Author SHA1 Message Date
Hona 68a11d3652 fix(ai): stop reparsing streamed tool input 2026-09-19 04:24:53 +00:00
Luke Parker 7125f5f8b5 chore(desktop): measure the startup bench from the screen (#49894) 2026-09-19 14:06:58 +10:00
opencode-agent[bot] 01a6ed8d97 chore: update nix node_modules hashes 2026-09-19 02:49:44 +00:00
004583d598 refactor(codemode): remove TypeScript stripping (#49870)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-18 21:24:11 -05:00
Aiden Cline cbd911368e refactor(schema): separate provider and model settings (#49850) 2026-09-18 21:20:08 -05:00
opencode-agent[bot] 8bfb247854 chore: update nix node_modules hashes 2026-09-19 02:20:04 +00:00
Luke Parker dd71cdbd84 fix(app): stop retaining failed sends in history and oversized toast text (#49717) 2026-09-19 12:00:55 +10:00
Luke Parker 1c90e8f01c perf(app): drop luxon for the three date operations that used it (#49876) 2026-09-19 12:00:32 +10:00
Luke Parker 9843d13c29 chore(desktop): record startup marks for the benchmark (#49872) 2026-09-19 12:00:03 +10:00
Luke Parker 871db0025e fix(browser): describe tabs.open focus as an explicit opt-out (#49877) 2026-09-19 11:55:49 +10:00
Dax Raad 7b7a67080e feat(web): add v2 announcement banner to legacy docs 2026-09-18 21:35:13 -04:00
Aiden Cline 3361953fe1 fix(core): explain user-invoked skills in skill guidance (#49871) 2026-09-18 20:32:36 -05:00
opencode-agent[bot]andrekram1-node e9201ce933 fix(core): retain console config after refresh errors (#49873)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-18 20:22:23 -05:00
Dax Raad b38d72e174 docs: link v1 docs from header 2026-09-18 20:54:48 -04:00
Luke Parker e1e2efe8ec perf(desktop): show the first window the moment Electron is ready (#49869) 2026-09-19 10:41:27 +10:00
Luke Parker c2b171d7c4 perf(client): poll for a starting service every 25 ms (#49780) 2026-09-19 10:40:32 +10:00
Luke Parker c3bf8864b9 perf(ui): let the resize observer take the first scrollbar measurement (#49778) 2026-09-19 10:38:33 +10:00
Luke Parker 3693f56374 perf(desktop): enable the V8 code cache for the renderer scheme (#49767) 2026-09-19 10:35:07 +10:00
Luke Parker 971f4e558e perf(desktop): load node-pty when an interactive WSL install starts (#49789) 2026-09-19 10:34:44 +10:00
Luke Parker dd95e3f2ed perf(desktop): collect orphaned draft blobs after the window is up (#49797) 2026-09-19 10:34:25 +10:00
Luke Parker dfde4628e3 perf(desktop): only persist the background colour when it changes (#49770) 2026-09-19 10:34:04 +10:00
Luke Parker e755fce13d perf(desktop): leave declarations and source maps out of the asar (#49772) 2026-09-19 10:33:48 +10:00
Luke Parker 5b565e8923 perf(desktop): create windows before the background service connects (#49763) 2026-09-19 10:33:39 +10:00
Kit Langton ec8d95f1f2 fix(simulation): include images in UI captures (#49868) 2026-09-18 17:21:44 -07:00
opencode-agent[bot] ea52d18ec5 chore: update nix node_modules hashes 2026-09-18 23:57:47 +00:00
Aiden Clineandrekram1-node 0f9504d6c5 feat(plugin): add list to the tool domain (#49838)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-18 18:51:37 -05:00
Luke Parker f6353a48b5 perf(desktop): replace electron-store with a plain JSON settings store (#49791) 2026-09-19 09:41:44 +10:00
Luke Parker 4facc03371 perf(desktop): remember window bounds without electron-window-state (#49792) 2026-09-19 09:41:15 +10:00
Luke Parker 485a027c1a perf(desktop): load the browser pane on first use (#49774) 2026-09-19 09:39:36 +10:00
Luke Parker 988a66ca4f perf(desktop): ship the bundled CLI version instead of spawning for it (#49762) 2026-09-19 09:36:55 +10:00
Luke Parker 98c6b41573 chore(desktop): add a packaged startup benchmark (#49758) 2026-09-19 09:23:09 +10:00
opencode-agent[bot]andthdxr 4ee9bf0213 revert(core): remove autonomous goal command (#49866)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-09-18 23:14:36 +00:00
James Long 681338c533 refactor(theme): detect theme format by fields (#49853) 2026-09-18 17:19:21 -04:00
Dax 5464aa4a74 feat(core): add autonomous goal command (#45379) 2026-09-18 20:59:08 +00:00
James Long 36083c9520 docs(www): document theme format and workflow (#49855) 2026-09-18 16:46:32 -04:00
James Long 168aba6183 feat(theme): register native V2 opencode theme (#49846) 2026-09-18 16:33:59 -04:00
Kit Langton ec19dc09c7 fix(sdk): support Node package imports (#49810) 2026-09-18 13:10:59 -07:00
Aiden Cline fca4701688 fix(ai): allow future Anthropic option values (#49835) 2026-09-18 14:24:11 -05:00
James Long c720ca20ed refactor(theme): rename default tokens to base (#49837) 2026-09-18 15:20:15 -04:00
James Long dae1e8abbd docs(www): finalize background color (#49203) 2026-09-18 15:07:49 -04:00
James Long ffe4d1522c refactor(theme): derive V1 hue angles from swatches (#49839) 2026-09-18 15:00:09 -04:00
James Long b1860465cd refactor(theme): split base and mode overrides (#49826) 2026-09-18 14:48:27 -04:00
CestercianandAiden Cline cab11795e5 fix(tui): pass markdown renderer into /btw answer dialog (#49804)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-18 13:32:11 -05:00
James Long 810e79a6d4 refactor(theme): align hue direction with mode (#49820) 2026-09-18 13:29:19 -04:00
James Long d81604714f feat(theme): resolve state precedence (#49813) 2026-09-18 12:55:30 -04:00
James Long d1e828c217 refactor(theme): replace theme contexts with a dialog surface (#49661) 2026-09-18 12:47:21 -04:00
Aiden Clineandrekram1-node 437cdc03a9 feat(core): repair malformed tool arguments before validation (#45002)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-18 10:26:02 -05:00
Aiden Cline 219ecada68 refactor(core): move provider policies into settings (#49666) 2026-09-18 10:18:33 -05:00
Dax Raad 9e1fa80fb7 refactor(tui): simplify scrollbox sizing 2026-09-18 11:17:42 -04:00
James Long 278db3023f fix(core): bound consumed job results (#47030) 2026-09-18 10:52:34 -04:00
Dax Raad c2067d59af fix(tui): simplify btw dialog sizing 2026-09-18 10:27:57 -04:00
256 changed files with 6206 additions and 3722 deletions
+4 -47
View File
@@ -87,7 +87,6 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
@@ -100,7 +99,6 @@
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
@@ -207,7 +205,6 @@
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -414,9 +411,7 @@
"@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": {
@@ -3343,8 +3338,6 @@
"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=="],
@@ -3639,8 +3632,6 @@
"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=="],
@@ -3779,8 +3770,6 @@
"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=="],
@@ -3907,16 +3896,12 @@
"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=="],
@@ -4461,8 +4446,6 @@
"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=="],
@@ -4473,7 +4456,7 @@
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
@@ -4735,8 +4718,6 @@
"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=="],
@@ -5491,8 +5472,6 @@
"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=="],
@@ -5617,8 +5596,6 @@
"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=="],
@@ -6445,10 +6422,6 @@
"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=="],
@@ -6489,8 +6462,6 @@
"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=="],
@@ -6521,8 +6492,6 @@
"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=="],
@@ -6925,18 +6894,12 @@
"@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/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/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"@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=="],
@@ -7013,8 +6976,6 @@
"@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=="],
@@ -7393,8 +7354,6 @@
"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=="],
@@ -7419,6 +7378,8 @@
"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=="],
@@ -7567,8 +7528,6 @@
"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=="],
@@ -7577,8 +7536,6 @@
"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-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
}
}
+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.
- 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.
- 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.
- 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
@@ -83,7 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const AnthropicServiceTier = Schema.Literals(["auto", "standard_only"])
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
const AnthropicTextBlock = Schema.Struct({
type: Schema.tag("text"),
@@ -276,7 +276,7 @@ const AnthropicThinkingBlockBinding = Schema.Struct({
})
const AnthropicThinkingFields = {
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
display: Schema.optional(knownString<"summarized" | "omitted">()),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}
const AnthropicThinkingEnabled = Schema.Struct({
@@ -66,7 +66,6 @@ const inputDelta = (tool: PendingTool, text: string) =>
name: tool.name,
namespace: tool.namespace,
text,
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
+1 -1
View File
@@ -182,7 +182,7 @@ export const ToolInputDelta = Schema.Struct({
name: Schema.String,
namespace: Schema.optional(Schema.String),
text: Schema.String,
/** Best-effort parse of all input fragments received through this delta. */
/** Optional best-effort parse supplied by adapters; native routes emit raw text until the final tool call. */
input: Schema.optional(Schema.Unknown),
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
@@ -4,6 +4,22 @@ 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,
@@ -215,13 +215,31 @@ describe("Anthropic Messages route", () => {
}),
)
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: "fast" },
{ service_tier: 1 },
{ metadata: { user_id: 42 } },
{ cache_control: { type: "ephemeral", ttl: "2h" } },
{ cache_control: { type: "ephemeral", ttl: "future-ttl" } },
{ output_config: { format: { type: "text" } } },
{ thinking: { type: "automatic" } },
]
@@ -1662,13 +1680,12 @@ describe("Anthropic Messages route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
@@ -822,13 +822,12 @@ describe("Bedrock Converse route", () => {
])
const events = response.events.filter((event) => event.type === "tool-input-delta")
expect(events).toEqual([
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"', input: {} },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
{
type: "tool-input-delta",
id: "tool_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
])
expect(response.events.at(-1)).toMatchObject({
@@ -916,7 +915,6 @@ describe("Bedrock Converse route", () => {
id: "tool_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
])
}),
@@ -413,14 +413,12 @@ describe("Mistral Chat", () => {
id: "Ab12Cd34E",
name: "lookup",
text: '{"city":',
input: {},
},
{
type: "tool-input-delta",
id: "Ab12Cd34E",
name: "lookup",
text: '"Paris"}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
{
@@ -313,7 +313,6 @@ describe("Open Responses basic-item lifecycles", () => {
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
text: '{\n "city": "Paris"\n}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
@@ -1511,13 +1511,12 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
@@ -1568,7 +1567,6 @@ describe("OpenAI Chat route", () => {
id: "call_1",
name: "lookup",
text: '{"query":"weather"',
input: { query: "weather" },
},
{
type: "step-finish",
@@ -1624,7 +1622,6 @@ describe("OpenAI Chat route", () => {
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{
type: "step-finish",
@@ -1690,7 +1687,6 @@ describe("OpenAI Chat route", () => {
id: "call_1",
name: "lookup",
text: '{"query":"weather"',
input: { query: "weather" },
},
])
}),
@@ -1780,13 +1776,12 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
@@ -4038,14 +4038,12 @@ describe("OpenAI Responses route", () => {
id: "call_1",
name: "lookup",
text: '{"query"',
input: {},
},
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{
type: "tool-input-end",
@@ -4117,8 +4115,8 @@ describe("OpenAI Responses route", () => {
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
])
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
@@ -4156,7 +4154,6 @@ describe("OpenAI Responses route", () => {
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
])
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "weather" } })
@@ -4217,7 +4214,6 @@ describe("OpenAI Responses route", () => {
id: "call_1",
name: "lookup",
text: '{"query":"streamed"}',
input: { query: "streamed" },
},
])
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "final" } })
+3 -22
View File
@@ -23,11 +23,9 @@ describe("ToolStream", () => {
expect(first.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
])
expect(second.events).toEqual([
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
])
expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }])
expect(finished).toEqual({
tools: {},
events: [
@@ -38,7 +36,7 @@ describe("ToolStream", () => {
}),
)
test("exposes cumulative partial string values", () => {
test("streams raw deltas without reparsing cumulative input", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
@@ -53,26 +51,9 @@ describe("ToolStream", () => {
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
})
test("defaults partial input to an empty object when the accumulated value cannot be parsed", () => {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) throw result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
})
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
-2
View File
@@ -46,7 +46,6 @@
"@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,7 +87,6 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
import { Schema } from "effect"
import { PromptHistoryState } from "../schema"
import { Persistence } from "@/runtime/persistence/schema"
@@ -36,6 +36,24 @@ 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,6 +48,18 @@ 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,6 +4,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import {
clonePromptHistoryComments,
prependHistoryEntry,
removeHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./entry"
@@ -13,6 +14,7 @@ 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
@@ -32,6 +34,13 @@ 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)
},
}
}
@@ -56,5 +65,12 @@ export function createComposerHistory() {
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
remove(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.remove(prompt, mode, comments)
const saved = clonePrompt(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.remove(saved, mode, metadata))
},
}
}
+1
View File
@@ -264,6 +264,7 @@ 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" }),
+8 -7
View File
@@ -53,6 +53,7 @@ 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,
@@ -60,7 +61,9 @@ function submitInput(
commands,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
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("")}`),
resetHistory() {},
setMode() {},
closePopover() {},
@@ -533,12 +536,8 @@ describe("Composer submission", () => {
missingSelection() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const submission = submitInput(
adapter,
notify,
"normal",
() => [],
)
const history: string[] = []
const submission = submitInput(adapter, notify, "normal", () => [], history)
await submission.submit(new Event("submit"))
await first.promise
@@ -549,6 +548,8 @@ describe("Composer submission", () => {
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: text }])
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
})
test("forwards structured mentions to custom commands", async () => {
+3
View File
@@ -32,6 +32,7 @@ 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
@@ -250,6 +251,8 @@ 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(
@@ -1,41 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createAltHold } from "./alt-hold"
describe("createAltHold", () => {
test("is active only while Alt is held and resets on blur", () => {
let active = () => false
let presses = 0
const dispose = createRoot((dispose) => {
active = createAltHold(true, () => presses++)
return dispose
})
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Alt" }))
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Alt", repeat: true }))
expect(active()).toBe(true)
expect(presses).toBe(1)
window.dispatchEvent(new KeyboardEvent("keyup", { key: "Alt" }))
expect(active()).toBe(false)
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Alt" }))
window.dispatchEvent(new Event("blur"))
expect(active()).toBe(false)
expect(presses).toBe(2)
dispose()
})
test("does nothing outside Desktop", () => {
let presses = 0
const active = createRoot((dispose) => {
const value = createAltHold(false, () => presses++)
dispose()
return value
})
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Alt" }))
expect(active()).toBe(false)
expect(presses).toBe(0)
})
})
@@ -1,19 +0,0 @@
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore } from "solid-js/store"
export function createAltHold(enabled: boolean, onPress: () => void) {
const [state, setState] = createStore({ active: false })
if (!enabled) return () => false
makeEventListener(window, "keydown", (event) => {
if (event.key !== "Alt" || state.active) return
setState("active", true)
onPress()
})
makeEventListener(window, "keyup", (event) => {
if (event.key === "Alt") setState("active", false)
})
makeEventListener(window, "blur", () => setState("active", false))
return () => state.active
}
+13 -40
View File
@@ -3,7 +3,6 @@ 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"
@@ -27,7 +26,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
import { buildHomeSessionRecords, homeProjectForSession, homeSessionLocation, type HomeSessionRecord } from "./records"
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./records"
@@ -93,25 +92,6 @@ export function createHomeSessionsController(home: HomeController) {
const groups = createMemo(() => groupSessions(records(), language))
const prefetched = new Set<string>()
const location = (record: HomeSessionRecord) => {
const branch = home.server.focusedContext()?.data.location.vcs.info(record.session.location)?.branch.current
return homeSessionLocation(record.session.location.directory, branch)
}
const syncLocations = (record?: HomeSessionRecord) => {
if (platform.platform !== "desktop") return
const ctx = home.server.focusedContext()
if (!ctx) return
if (record) {
void ctx.data.location.vcs.sync(record.session.location).catch(() => undefined)
return
}
const locations = new Map(
records().map((record) => [pathKey(record.session.location.directory), record.session.location] as const),
)
void Promise.allSettled(Array.from(locations.values(), (location) => ctx.data.location.vcs.sync(location)))
}
createEffect(() => {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
@@ -272,13 +252,6 @@ export function createHomeSessionsController(home: HomeController) {
loading: () => sessionLoad.isPending,
searchRecords: allRecords,
},
platform: {
desktop: platform.platform === "desktop",
},
location: {
value: location,
sync: syncLocations,
},
session: {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
@@ -353,19 +326,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 = 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 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 olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
+1 -17
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode/client/promise"
import type { LocalProject } from "@/shell/state/layout"
import { buildHomeSessionRecords, homeSessionLocation } from "./records"
import { buildHomeSessionRecords } from "./records"
const session = (id: string, directory: string, projectID: string) =>
({
@@ -59,19 +59,3 @@ describe("buildHomeSessionRecords", () => {
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
})
})
describe("homeSessionLocation", () => {
test("returns the worktree directory name and branch", () => {
expect(homeSessionLocation("/repo/.worktrees/crisp-cactus", "feature/home")).toEqual({
worktree: "crisp-cactus",
branch: "feature/home",
})
})
test("uses the worktree name while branch metadata is unavailable", () => {
expect(homeSessionLocation("/repo/.worktrees/crisp-cactus")).toEqual({
worktree: "crisp-cactus",
branch: undefined,
})
})
})
@@ -2,7 +2,6 @@ import type { SessionInfo } from "@opencode/client/promise"
import type { LocalProject } from "@/shell/state/layout"
import { compareSessionTime, displayName } from "@/shell/layout/helpers"
import { pathKey } from "@/workspaces/path-key"
import { getFilename } from "@opencode/util/path"
export type HomeSessionRecord = {
session: SessionInfo
@@ -10,10 +9,6 @@ export type HomeSessionRecord = {
projectName: string
}
export function homeSessionLocation(directory: string, branch?: string) {
return { worktree: getFilename(directory) || directory, branch }
}
export function buildHomeSessionRecords(input: {
sessions: () => SessionInfo[]
projectDirectories: () => string[] | undefined
@@ -16,9 +16,6 @@ export function HomeSessions(props: {
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
desktop={props.sessions.platform.desktop}
location={props.sessions.location.value}
onRevealLocations={props.sessions.location.sync}
showProjectName={props.sessions.session.showProjectName()}
server={server()}
canCreateSession={props.sessions.session.canCreate()}
+35 -128
View File
@@ -15,7 +15,6 @@ import { ServerConnection } from "@/runtime/server/registry"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { sessionLabel } from "@/session/title"
import { shouldOpenSessionInBackground } from "./open"
import { createAltHold } from "./alt-hold"
import "./view.css"
import {
HomeSessionStatusController,
@@ -47,9 +46,6 @@ export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: HomeSessionGroup[]
loading: boolean
desktop: boolean
location: (record: HomeSessionRecord) => { worktree: string; branch: string | undefined }
onRevealLocations: (record?: HomeSessionRecord) => void
showProjectName: boolean
server: ServerConnection.Key
canCreateSession: boolean
@@ -95,8 +91,6 @@ type HomeSessionRowUI = {
export function HomeSessionsView(props: HomeSessionsViewProps) {
const [rowUI, setRowUI] = createStore<HomeSessionRowUI>({ menu: undefined, editor: undefined })
const [hover, setHover] = createStore<{ sessionID: string | undefined }>({ sessionID: undefined })
const showLocations = createAltHold(props.desktop, props.onRevealLocations)
return (
<section
ref={props.onSetHoverTarget}
@@ -107,12 +101,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-1 pt-6 md:pb-3 lg:pt-12"
onWheel={props.onWheel}
>
<HomeSessionSearch
{...props}
showLocations={showLocations()}
hoveredSession={hover.sessionID}
onHoverSession={(sessionID) => setHover("sessionID", sessionID)}
/>
<HomeSessionSearch {...props} />
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
<Button
@@ -173,17 +162,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
rows would be disposed mid-interaction whenever a
sync response lands. */}
<Key each={group().sessions} by={(record) => record.session.id}>
{(record) => (
<HomeSessionRow
{...props}
record={record()}
rowUI={rowUI}
setRowUI={setRowUI}
showLocations={showLocations()}
hoveredSession={hover.sessionID}
onHoverSession={(sessionID) => setHover("sessionID", sessionID)}
/>
)}
{(record) => <HomeSessionRow {...props} record={record()} rowUI={rowUI} setRowUI={setRowUI} />}
</Key>
</div>
</>
@@ -219,7 +198,12 @@ function HomeSessionLeadingController(props: {
)
}
function HomeSessionLeading(props: { record: HomeSessionRecord; open: boolean; unread: boolean; loading: boolean }) {
function HomeSessionLeading(props: {
record: HomeSessionRecord
open: boolean
unread: boolean
loading: boolean
}) {
return (
<div class="relative shrink-0">
<Show when={props.open}>
@@ -242,13 +226,7 @@ function HomeSessionLeading(props: { record: HomeSessionRecord; open: boolean; u
)
}
function HomeSessionSearch(
props: HomeSessionsViewProps & {
showLocations: boolean
hoveredSession: string | undefined
onHoverSession: (sessionID: string | undefined) => void
},
) {
function HomeSessionSearch(props: HomeSessionsViewProps) {
return (
<div class="w-full">
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
@@ -391,13 +369,9 @@ function HomeSessionSearchResultRow(
props: HomeSessionsViewProps & {
record: HomeSessionRecord
selected: boolean
showLocations: boolean
hoveredSession: string | undefined
onHoverSession: (sessionID: string | undefined) => void
},
) {
const title = createMemo(() => sessionLabel(props.record.session))
const location = createMemo(() => props.location(props.record))
const showProjectName = () => props.showProjectName && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
@@ -411,21 +385,14 @@ function HomeSessionSearchResultRow(
role="option"
aria-selected={props.selected}
class={`
flex h-10 w-full shrink-0 cursor-default items-center border-0 py-3 pl-[18px] pr-6 text-left
flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left
transition-[background-color] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
classList={{
"bg-v2-overlay-simple-overlay-hover": props.selected,
"gap-1.5": props.desktop,
"gap-2": !props.desktop,
}}
onMouseEnter={() => {
props.onSearchHighlight(props.record)
props.onHoverSession(props.record.session.id)
props.onRevealLocations(props.record)
}}
onMouseLeave={() => props.onHoverSession(undefined)}
onMouseEnter={() => props.onSearchHighlight(props.record)}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
@@ -436,22 +403,16 @@ function HomeSessionSearchResultRow(
props.onSearchSelect(props.record, { background: true })
}}
>
<HomeSessionLeadingController server={props.server} isOpenTab={props.isOpenTab} record={props.record} />
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
/>
<div data-slot="home-session-labels" class="flex min-w-0 flex-1 items-center gap-1.5">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} trailing={props.desktop} search />
<HomeSessionProjectName name={props.record.projectName} search />
</Show>
<HomeSessionLocation
location={location()}
show={props.showLocations || props.hoveredSession === props.record.session.id}
instantHide={
!props.showLocations &&
props.hoveredSession !== undefined &&
props.hoveredSession !== props.record.session.id
}
desktop={props.desktop}
/>
</div>
</button>
)
@@ -484,13 +445,9 @@ function HomeSessionRow(
record: HomeSessionRecord
rowUI: HomeSessionRowUI
setRowUI: SetStoreFunction<HomeSessionRowUI>
showLocations: boolean
hoveredSession: string | undefined
onHoverSession: (sessionID: string | undefined) => void
},
) {
const title = createMemo(() => sessionLabel(props.record.session))
const location = createMemo(() => props.location(props.record))
const showProjectName = () => props.showProjectName && props.record.projectName
const sessionID = () => props.record.session.id
const menu = () => (props.rowUI.menu?.id === sessionID() ? props.rowUI.menu : undefined)
@@ -573,10 +530,13 @@ function HomeSessionRow(
fallback={
<div
data-slot="home-session-editor"
class="flex h-10 min-w-0 w-full flex-1 items-center py-3 ps-1.5 pe-3 md:ps-3 md:pe-10"
classList={{ "gap-1.5": props.desktop, "gap-2": !props.desktop }}
class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 ps-1.5 pe-3 md:ps-3 md:pe-10"
>
<HomeSessionLeadingController server={props.server} isOpenTab={props.isOpenTab} record={props.record} />
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
/>
<div data-slot="home-session-labels" class="contents">
<InlineInput
data-component="home-session-rename"
@@ -587,7 +547,7 @@ function HomeSessionRow(
class={`
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
@@ -626,20 +586,14 @@ function HomeSessionRow(
aria-haspopup="menu"
aria-expanded={!!menu()}
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center rounded-[6px] border-0
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 ps-1.5 pe-3 md:ps-3 md:pe-10 text-start text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
classList={{ "gap-1.5": props.desktop, "gap-2": !props.desktop }}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onMouseEnter={() => {
props.onHoverSession(props.record.session.id)
props.onRevealLocations(props.record)
}}
onMouseLeave={() => props.onHoverSession(undefined)}
onPointerDown={(event) => {
suppressClick = false
if (event.pointerType !== "touch") return
@@ -690,26 +644,16 @@ function HomeSessionRow(
props.onOpenSession(props.record.session, { background: true })
}}
>
<HomeSessionLeadingController server={props.server} isOpenTab={props.isOpenTab} record={props.record} />
<div
data-slot="home-session-labels"
class="min-w-0 flex-1 items-center gap-1.5"
classList={{ flex: props.desktop, contents: !props.desktop }}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
/>
<div data-slot="home-session-labels" class="contents">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} trailing={props.desktop} />
<HomeSessionProjectName name={props.record.projectName} />
</Show>
<HomeSessionLocation
location={location()}
show={props.showLocations || props.hoveredSession === props.record.session.id}
instantHide={
!props.showLocations &&
props.hoveredSession !== undefined &&
props.hoveredSession !== props.record.session.id
}
desktop={props.desktop}
/>
</div>
</button>
</Show>
@@ -802,50 +746,13 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
)
}
function HomeSessionLocation(props: {
location: { worktree: string; branch: string | undefined }
show: boolean
instantHide: boolean
desktop: boolean
}) {
if (!props.desktop) return null
return (
<span
dir="ltr"
aria-hidden={!props.show || undefined}
class="flex min-w-0 shrink-0 items-center gap-1.5 overflow-hidden whitespace-nowrap ease-out motion-reduce:transition-none"
classList={{
"-ms-1.5 max-w-0 opacity-0": !props.show,
"max-w-[60%] opacity-100": props.show,
"transition-[max-width,opacity] duration-[120ms]": !props.instantHide,
"transition-none": props.instantHide,
}}
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 overflow-hidden text-ellipsis [font-weight:440]">{props.location.worktree}</span>
<Show when={props.location.branch}>
{(branch) => (
<>
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 overflow-hidden text-ellipsis [font-weight:440]">{branch()}</span>
</>
)}
</Show>
</span>
)
}
function HomeSessionProjectName(props: { name: string; search?: boolean; trailing?: boolean }) {
function HomeSessionProjectName(props: { name: string; search?: boolean }) {
return (
<span
data-component="home-session-project-name"
dir="auto"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
classList={{
"flex-[0_1_auto]": !!props.trailing,
"flex-[1_1_auto]": !props.trailing,
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
}}
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
>
{props.name}
</span>
+12 -16
View File
@@ -1,5 +1,4 @@
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"
@@ -9,6 +8,8 @@ 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}`
@@ -29,27 +30,23 @@ 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) => {
const parsed = DateTime.fromISO(model.release_date)
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
}),
available().map(
(model) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter(
(x) =>
Math.abs(
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
.diffNow()
.as("months"),
) < 6,
),
filter((x) => {
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
return Math.abs(Date.now() - released) < sixMonths
}),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
@@ -101,9 +98,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
const date = release().get(key)
if (!date?.isValid) return true
return false
// Models without a parseable release date stay visible.
return !Number.isFinite(release().get(key) ?? NaN)
}
const setVisibility = (model: ModelKey, state: boolean) => {
@@ -1,6 +1,12 @@
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 "—"
@@ -14,7 +20,7 @@ export function createSessionContextFormatter(locale: string) {
},
time(value: number | undefined) {
if (!value) return "—"
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
return dateTime.format(value)
},
}
}
+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("elevated")
const theme = useTheme().surface("dialog")
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.default} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
<text fg={theme.text.muted} onMouseUp={props.onClose}>
esc
</text>
</box>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.base}>
{props.error instanceof Error ? props.error.message : String(props.error)}
</text>
<text flexShrink={0} fg={theme.text.subdued}>
<text flexShrink={0} fg={theme.text.muted}>
{props.component} · Fix the component and save to retry.
</text>
</box>
+18
View File
@@ -1,9 +1,27 @@
#!/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")
+3 -1
View File
@@ -131,7 +131,9 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}).pipe(
Effect.repeat({
until: Option.isSome,
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
// 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 })),
}),
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
)
+69 -61
View File
@@ -8,7 +8,9 @@ export type LocationPublicRef = { directory: string }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: any }
export type ProviderCompaction = { type: "summary" } | { type: "native" }
export type ProviderTransport = "http" | "websocket"
export type AgentColor = string
@@ -218,19 +220,8 @@ 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 } }
@@ -465,11 +456,23 @@ export type V2EventServerConnected = {
data: {}
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
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 PermissionRule = { action: string; resource: string; effect: PermissionEffect }
@@ -1443,20 +1446,6 @@ 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
@@ -1671,6 +1660,31 @@ 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 = {
@@ -1854,29 +1868,6 @@ 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
@@ -1902,6 +1893,27 @@ 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
@@ -2085,31 +2097,27 @@ 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?: { [x: string]: JsonValue }
settings?: ConfigProviderSettings
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?: { [x: string]: JsonValue }
settings?: ConfigModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: { [x: string]: JsonValue }
settings?: ConfigModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
+4 -3
View File
@@ -1,6 +1,5 @@
export type EnsureTiming = {
readonly pollInterval: number
readonly attempts: number
readonly requestTimeout: number
readonly spawnDelay: number
readonly maxSpawnDelay: number
@@ -11,9 +10,11 @@ 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: 100,
attempts: 1_200,
pollInterval: 25,
requestTimeout: 2_000,
spawnDelay: 5_000,
maxSpawnDelay: 30_000,
@@ -2,7 +2,6 @@ import { withEnsureTiming } from "../../src/service-timing"
const timing = {
pollInterval: 20,
attempts: 120,
requestTimeout: 100,
spawnDelay: 200,
maxSpawnDelay: 1_200,
+3 -7
View File
@@ -15,8 +15,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Source and execution model
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
TypeScript-only syntax is rejected rather than stripped before execution.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
@@ -41,8 +40,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
rest parameter are accepted unless the program itself begins with `"use strict"`.
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
default in a `for...of` head and Unicode-escaped keywords.
## Values and literals
@@ -494,9 +491,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
subset; this matrix is the full reference.
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
short orientation to the supported subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
+1 -8
View File
@@ -19,12 +19,6 @@
"exports": {
".": "./src/index.ts"
},
"imports": {
"#transpile": {
"workerd": "./src/interpreter/transpile.workerd.ts",
"default": "./src/interpreter/transpile.node.ts"
}
},
"scripts": {
"build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit",
@@ -32,8 +26,7 @@
},
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:"
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+9 -7
View File
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
imports?: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
@@ -41,12 +41,14 @@ try {
]
}),
)
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
if (pkg.imports) {
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
}
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
+1 -14
View File
@@ -1,14 +1,10 @@
import { parse, type Program } from "acorn"
import { Cause, Effect, Scope } from "effect"
// #transpile: conditional import — full typescript on node/bun, an identity
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
import { transpile } from "#transpile"
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
import { toBoundary } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { createBuiltins } from "./intrinsics.js"
import { PendingThrow } from "./model.js"
import { Pending } from "./promises.js"
import { Interpreter } from "./interpreter.js"
@@ -110,16 +106,7 @@ export const executeProgram = <R>(
}
const parseProgram = (code: string): Program => {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
if (transpiled.error !== undefined) {
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
const bodyEnd = transpiled.outputText.lastIndexOf("}")
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
return parse(executableCode, {
return parse(code, {
ecmaVersion: "latest",
sourceType: "script",
allowReturnOutsideFunction: true,
@@ -1,25 +0,0 @@
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// Full TypeScript transpilation on node/bun runtimes.
export const transpile = (source: string): TranspileResult => {
const transpiled = transpileModule(source, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (diagnostic) {
return {
outputText: transpiled.outputText,
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
}
}
return { outputText: transpiled.outputText }
}
@@ -1,11 +0,0 @@
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// workerd profile: the typescript compiler is ~11 MiB and probes node
// internals at module init, so codemode programs are passed through
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
// error from the interpreter instead of a transpile diagnostic.
export const transpile = (source: string): TranspileResult => ({ outputText: source })
+13 -7
View File
@@ -15,6 +15,12 @@ const error = async (code: string) => {
return result.error
}
describe("source syntax", () => {
test("rejects TypeScript-only syntax", async () => {
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
})
})
describe("error identity", () => {
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
@@ -101,7 +107,7 @@ describe("host errors escaping built-ins", () => {
test("report the location of the call that raised them", async () => {
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
})
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
@@ -109,13 +115,13 @@ describe("host errors escaping built-ins", () => {
})
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
"(line 1, col 14)",
"(line 1, col 10)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 2, col 5)",
"(line 1, col 8)",
)
})
@@ -128,7 +134,7 @@ describe("host errors escaping built-ins", () => {
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
})
})
@@ -147,7 +153,7 @@ describe("call depth", () => {
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
})
test("the limit is 10000 nested calls", async () => {
+14 -9
View File
@@ -34,7 +34,7 @@ import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode/ai/rou
import { ProviderShared } from "@opencode/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { makeParser } from "effect/unstable/encoding/Sse"
import type { ID, Info } from "./model.js"
import type { ID, RuntimeInfo } from "./model.js"
import { Provider } from "./provider.js"
import { State } from "./state.js"
@@ -46,14 +46,14 @@ type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
export interface SDKEvent {
readonly model: Info
readonly model: RuntimeInfo
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: Info
readonly model: RuntimeInfo
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
@@ -116,7 +116,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function prepareOptions(model: Info, pkg: string) {
function prepareOptions(model: RuntimeInfo, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.canonical ?? model.providerID,
@@ -128,6 +128,8 @@ function prepareOptions(model: Info, pkg: string) {
const customFetch = options.fetch
const chunkTimeout = options.chunkTimeout
delete options.chunkTimeout
delete options.compaction
delete options.transport
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const opts = { ...(init ?? {}) }
const signals = [
@@ -180,8 +182,8 @@ export interface Interface {
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: Info) => Effect.Effect<LanguageModel, InitError>
readonly language: (model: RuntimeInfo) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: RuntimeInfo) => Effect.Effect<LanguageModel, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
@@ -300,7 +302,7 @@ export const locationLayer = Layer.effect(
}),
)
function modelFromLanguage(info: Info, language: LanguageModelV3) {
function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const providerID = info.canonical ?? info.providerID
@@ -388,13 +390,16 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
if (settings === undefined) return undefined
const result = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
([key]) =>
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
key,
),
),
)
return Object.keys(result).length === 0 ? undefined : result
}
function mapBodyToProviderOptions(model: Info, packageName: string) {
function mapBodyToProviderOptions(model: RuntimeInfo, packageName: string) {
const settings = requestSettings(model.settings)
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
const forceReasoning =
@@ -68,8 +68,6 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.transport !== undefined) provider.transport = item.transport
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -116,8 +114,6 @@ export const Plugin = define({
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.transport !== undefined) model.transport = config.transport
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+1 -1
View File
@@ -1,7 +1,7 @@
export * as Watcher from "./watcher.js"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import { createWrapper } from "@parcel/watcher/wrapper.js"
import type ParcelWatcher from "@parcel/watcher"
import { FileSystem } from "@opencode/schema/filesystem"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
+61 -14
View File
@@ -36,6 +36,7 @@ export type Status = Background["status"]
const decodeBackground = Schema.decodeUnknownResult(Background)
const backgroundPrefix = "job.background/"
const COMPLETED_LIMIT = 25
export type Info = {
id: string
@@ -57,6 +58,7 @@ type Active = {
scope: Scope.Closeable
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
consumed: boolean
recovery?: Recovery
}
@@ -69,6 +71,7 @@ type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
scope?: Scope.Closeable
generation?: Scope.Closeable
}
type BackgroundResult = {
@@ -81,11 +84,12 @@ type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
generation: Scope.Closeable
}
type BlockStart =
| { type: "missing" }
| { type: "finished"; info: Info }
| { type: "finished"; info: Info; generation: Scope.Closeable }
| { type: "backgrounded"; info: Info }
| { type: "wait"; wait: BlockWait }
@@ -168,6 +172,9 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
/**
* Makes one scoped, process-local registry. Explicitly recoverable background
* work also owns a durable notification marker until its notification is admitted.
* Unconsumed results survive the start-to-wait handoff. Foreground block/cancel
* and non-recoverable wait results enter a 25-entry consumed history. Recoverable
* wait results stay available for background registration and acknowledgment.
*/
export const make = Effect.gen(function* () {
const kv = yield* KV.Service
@@ -176,6 +183,19 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const consume = (id: string, generation: Scope.Closeable) =>
SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(id)
if (!job || job.scope !== generation || job.info.status === "running" || job.consumed) return jobs
const next = new Map(jobs)
// Order history by first consumption, not by start time or subsequent reads.
next.delete(id)
next.set(id, { ...job, consumed: true })
const completed = [...next].filter(([, job]) => job.consumed && !job.info.notificationID)
for (const [id] of completed.slice(0, -COMPLETED_LIMIT)) next.delete(id)
return next
})
const persistBackground = Effect.fnUntraced(function* (job: Active) {
if (!job.recovery || !job.info.notificationID) return
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
@@ -260,6 +280,7 @@ export const make = Effect.gen(function* () {
scope,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
consumed: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
@@ -280,12 +301,19 @@ export const make = Effect.gen(function* () {
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
if (!job) return { timedOut: false }
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
return yield* Effect.gen(function* () {
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
}).pipe(
// Recoverable wait -> background is a supported handoff, even after failure.
Effect.tap((result) =>
result.info.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope),
),
)
})
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
@@ -303,10 +331,10 @@ export const make = Effect.gen(function* () {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job), generation: job.scope }, jobs]
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
return [
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded, generation: job.scope } },
new Map(jobs).set(input.id, {
...job,
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
@@ -314,12 +342,18 @@ export const make = Effect.gen(function* () {
]
})
if (result.type === "missing") return undefined
if (result.type === "finished") return { type: "finished", info: result.info }
if (result.type === "finished") {
yield* consume(input.id, result.generation)
return { type: "finished", info: result.info }
}
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
return yield* Effect.raceFirst(
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
).pipe(Effect.ensuring(removeBlock(input)))
).pipe(
Effect.tap((outcome) => (outcome.type === "finished" ? consume(input.id, result.wait.generation) : Effect.void)),
Effect.ensuring(removeBlock(input)),
)
})
const markBackground = Effect.fnUntraced(function* (job: Active) {
@@ -383,7 +417,7 @@ export const make = Effect.gen(function* () {
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job), generation: job.scope }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
@@ -394,11 +428,15 @@ export const make = Effect.gen(function* () {
},
}
yield* persistBackground(next)
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
return [
{ info: snapshot(next), done: job.done, scope: job.scope, generation: job.scope },
new Map(jobs).set(id, next),
]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
if (result.generation) yield* consume(id, result.generation)
return result.info
})
@@ -414,7 +452,16 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
kv.remove(`${backgroundPrefix}${notificationID}`),
SynchronizedRef.updateEffect(state.jobs, (jobs) =>
Effect.gen(function* () {
yield* kv.remove(`${backgroundPrefix}${notificationID}`)
const entry = [...jobs].find(([, job]) => job.info.notificationID === notificationID)
if (!entry || entry[1].info.status === "running") return jobs
const next = new Map(jobs)
next.delete(entry[0])
return next
}),
),
)
return Service.of({
+29 -20
View File
@@ -8,6 +8,7 @@ import { AISDK } from "./aisdk.js"
import { Credential } from "./credential.js"
import { Integration } from "./integration.js"
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
import type { RuntimeInfo } from "./model.js"
import { Npm } from "@opencode/util/npm"
import { Provider } from "./provider.js"
@@ -117,10 +118,10 @@ export interface Resolved {
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Info["compaction"]
/** Model transport overrides the provider transport; omitted means HTTP. */
readonly transport?: Info["transport"]
/** Model policy overrides the provider policy; omitted means summary compaction. */
readonly compaction?: Provider.Compaction
/** Provider transport policy; omitted means HTTP. */
readonly transport?: Provider.Transport
}
export interface Interface {
@@ -149,7 +150,7 @@ export const withVariant = (
variant
? {
...model,
settings: Provider.mergeOverlay(model.settings, variant.settings),
settings: Provider.mergeOverlay(model.settings, Provider.modelSettings(variant.settings)),
headers: Provider.mergeHeaders(model.headers, variant.headers),
body: Provider.mergeOverlay(model.body, variant.body),
}
@@ -159,11 +160,11 @@ export const withVariant = (
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
readonly loadAISDK?: (model: RuntimeInfo) => Effect.Effect<LanguageModel, AISDK.InitError>
}
export const fromCatalogModel = (
model: Info,
model: RuntimeInfo,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<
@@ -178,7 +179,11 @@ export const fromCatalogModel = (
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
Effect.flatMap((resolved) => {
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
if (
model.settings?.compaction?.type !== "native" ||
resolved.route.compact?.trigger ||
resolved.route.compact?.endpoint
)
return Effect.succeed(resolved)
return Effect.fail(
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
@@ -187,7 +192,7 @@ export const fromCatalogModel = (
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
model: Info,
model: RuntimeInfo,
credential?: Credential.Value,
dependencies?: Dependencies,
) {
@@ -244,7 +249,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
})
})
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
return {
...model,
@@ -256,7 +261,7 @@ function prepareRuntimeModel(model: Info, credential: Credential.Value | undefin
}
function validateProviderVariables(
model: Info,
model: RuntimeInfo,
resolved: LanguageModel,
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
const baseURL = resolved.route.endpoint.baseURL
@@ -266,7 +271,7 @@ function validateProviderVariables(
}
function prepareProviderSettings(
model: Info,
model: RuntimeInfo,
settings: Readonly<Record<string, unknown>>,
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
const baseURL = settings.baseURL
@@ -276,14 +281,14 @@ function prepareProviderSettings(
)
}
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
const failure = unresolvedProviderVariables(model, prepared)
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
}
function unresolvedProviderVariables(model: Info, baseURL: string) {
function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
if (variables.size === 0) return
return new UnresolvedProviderVariablesError({
@@ -306,14 +311,14 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
return { apiKey: credential.access }
}
const unsupported = (model: Info) =>
const unsupported = (model: RuntimeInfo) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
const initialization = (model: RuntimeInfo, phase: InitializationPhase, cause: unknown) =>
new ModelInitializationError({
providerID: model.providerID,
modelID: model.id,
@@ -355,7 +360,11 @@ export const layer = Layer.effect(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const runtimeInfo = yield* withVariant(selected, variant)
const selectedVariant = yield* withVariant(selected, variant)
const runtimeInfo: RuntimeInfo = {
...selectedVariant,
settings: Provider.mergeOverlay(provider?.settings, Provider.modelSettings(selectedVariant.settings)),
}
const model = yield* fromCatalogModel(runtimeInfo, credential, {
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
@@ -377,8 +386,8 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: selected.compaction,
transport: selected.transport,
compaction: runtimeInfo.settings?.compaction,
transport: provider?.settings?.transport,
}
})
return Service.of({
@@ -402,7 +411,7 @@ export const layer = Layer.effect(
}),
)
function hasConfiguredAuth(model: Info) {
function hasConfiguredAuth(model: RuntimeInfo) {
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
(value) => typeof value === "string" && value !== "",
)
+7 -3
View File
@@ -36,6 +36,9 @@ export type Ref = typeof Ref.Type
export const Info = Model.Info
export type Info = Model.Info
/** Effective provider and model settings used only while constructing a runtime model. */
export type RuntimeInfo = Omit<Info, "settings"> & { readonly settings?: Provider.Settings }
export type MutableInfo = DeepMutable<Info>
export { Event } from "@opencode/schema/model"
@@ -188,9 +191,10 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
compaction: model.compaction ?? provider?.compaction,
transport: model.transport ?? provider?.transport,
settings: Provider.mergeOverlay(provider?.settings, model.settings),
settings: Provider.mergeOverlay(
Provider.modelSettings(provider?.settings),
Provider.modelSettings(model.settings),
),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info
+1
View File
@@ -453,6 +453,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
tool: {
transform: tools.transform,
reload: tools.reload,
list: tools.list,
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
+2
View File
@@ -95,6 +95,7 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
import { OptimizePlugin } from "./optimize.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
@@ -205,6 +206,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ToolInputRepairPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
BrowserPlugin,
ConfigMcpPlugin.Plugin,
+15 -16
View File
@@ -7,7 +7,6 @@ import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
@@ -140,16 +139,18 @@ export const AzurePlugin = define({
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
if (resourceName)
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
resourceName,
...(typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
}
})
yield* ctx.model.transform((models) => {
@@ -167,8 +168,6 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.transport = item.provider.transport ?? "websocket"
})
}
}
@@ -240,9 +239,9 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false
const settings = Provider.mergeOverlay(provider.settings, model.settings)
function responsesWebSocketCapable(provider: Provider.Info) {
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
const settings = provider.settings
if (settings?.useDeploymentBasedUrls === true) return false
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
if (typeof settings?.baseURL !== "string") return true
+6 -4
View File
@@ -254,10 +254,13 @@ export const OpenAIPlugin = define({
yield* ctx.provider.transform((providers) => {
const item = providers.get(Provider.ID.openai)
if (!item) return
if (!chatgpt) return
const account = chatgpt.metadata?.accountID
const account = chatgpt?.metadata?.accountID
providers.update(item.provider.id, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: codexBaseURL })
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
...(chatgpt ? { baseURL: codexBaseURL } : {}),
})
if (!chatgpt) return
provider.headers = Provider.mergeHeaders(provider.headers, {
originator: "opencode",
"x-codex-beta-features": "remote_compaction_v2",
@@ -270,7 +273,6 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
@@ -6,6 +6,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigProvider } from "@opencode/schema/config/provider"
@@ -131,7 +132,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const config = credential
? yield* fetchConfig(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
Effect.as(
IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection)
? snapshot.config
: undefined,
),
),
),
)
: undefined
+6 -5
View File
@@ -95,12 +95,13 @@ export const XAIPlugin = define({
editor.method.update(device(ctx.app))
editor.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
yield* ctx.provider.transform((providers) => {
if (!providers.get(providerID)) return
providers.update(providerID, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
})
}
})
})
}),
})
@@ -0,0 +1,170 @@
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
import { definition } from "../tool/runtime.js"
// Repairs apply only when the input schema unambiguously supports them:
// - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 }
// - Closed object: { limit: "20", extra: true } -> { limit: 20 }
// - Optional null or empty-object placeholder: { limit: null } -> {}
// - Numeric or boolean string: { limit: "20", enabled: "false" } -> { limit: 20, enabled: false }
// - Nullable field: { count: "2" } -> { count: 2 }
// - Stringified array or compatible item: { tags: '["a"]', count: "2" } -> { tags: ["a"], count: [2] }
// - Positional tuple: { pair: ["2", "false"] } -> { pair: [2, false] }
// - Typed dictionary: { counts: { first: "2" } } -> { counts: { first: 2 } }
// - Nested fields and local references: { items: [{ count: "2" }] } -> { items: [{ count: 2 }] }
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const maxDepth = 6
export const Plugin = define({
id: "opencode.tool.input.repair",
effect: (ctx) =>
ctx.tool.hook("execute.before", (event) =>
Effect.gen(function* () {
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
if (event.tool === "execute") return
const tool = (yield* ctx.tool.list()).find((tool) => tool.id === event.tool)
if (!tool) return
const schema = definition(tool).inputSchema
if (schema.type !== "object") return
event.input = repair(event.input, schema, schema, 0)
}),
),
})
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
if (depth > maxDepth) return value
if (typeof schema.$ref === "string") {
const definitions = /^#\/\$defs\/[^/]+$/.test(schema.$ref)
? root.$defs
: /^#\/definitions\/[^/]+$/.test(schema.$ref)
? root.definitions
: undefined
if (!Predicate.isObject(definitions)) return value
const target = Object.fromEntries(
Object.entries(definitions).filter((entry): entry is [string, JsonSchema.JsonSchema] =>
Predicate.isObject(entry[1]),
),
)[
schema.$ref
.slice(schema.$ref.lastIndexOf("/") + 1)
.replaceAll("~1", "/")
.replaceAll("~0", "~")
]
return target ? repair(value, target, root, depth + 1) : value
}
if (Array.isArray(schema.type)) {
if (value === null && schema.type.includes("null")) return value
if (schema.type.includes(typeof value)) return value
const types = schema.type.filter((type) => type !== "null")
return types.length === 1 ? repair(value, { ...schema, type: types[0] }, root, depth + 1) : value
}
if (schema.type === undefined) {
if (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) return value
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : schema.oneOf
if (!Array.isArray(branches) || value === null) return value
if (branches.some((branch) => !Predicate.isObject(branch) || branch.type === typeof value)) return value
const candidates = branches.filter((branch) => Predicate.isObject(branch) && branch.type !== "null")
return candidates.length === 1 ? repair(value, candidates[0], root, depth + 1) : value
}
if (schema.type === "number" || schema.type === "integer") {
if (typeof value !== "string" || value.trim() === "") return value
const parsed = Number(value)
return Number.isFinite(parsed) && (schema.type !== "integer" || Number.isSafeInteger(parsed)) ? parsed : value
}
if (schema.type === "boolean") return value === "true" ? true : value === "false" ? false : value
if (schema.type === "object") return repairObject(value, schema, root, depth)
if (schema.type === "array") return repairArray(value, schema, root, depth)
return value
}
function repairObject(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
if (!Predicate.isObject(parsed)) return value
const properties = Predicate.isObject(schema.properties) ? schema.properties : {}
const required = Array.isArray(schema.required) ? schema.required : []
const patterned = Predicate.isObject(schema.patternProperties)
const composed = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)
return Object.keys(parsed).reduce<Record<string, unknown>>((result, key) => {
const current = result[key]
const declared = Object.hasOwn(properties, key)
const property = declared ? properties[key] : !patterned ? schema.additionalProperties : undefined
if (!declared && schema.additionalProperties === false && !patterned && !composed) {
const next = { ...result }
delete next[key]
return next
}
if (!Predicate.isObject(property)) return result
// Only a bare single-type property provably rejects null and `{}`. Compositions, enums,
// constants, references and nullable flags may accept them, so those are left alone.
const plain =
typeof property.type === "string" &&
property.type !== "null" &&
!composed &&
["anyOf", "oneOf", "allOf", "enum", "const", "$ref", "nullable"].every((keyword) => !(keyword in property))
const placeholder = Predicate.isObject(current) && Object.keys(current).length === 0 && property.type !== "object"
if (declared && !required.includes(key) && plain && (current === null || placeholder)) {
const next = { ...result }
delete next[key]
return next
}
const repaired = repair(current, property, root, depth + 1)
return repaired === current ? result : { ...result, [key]: repaired }
}, parsed)
}
function repairArray(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
const tuple = Array.isArray(schema.prefixItems)
? schema.prefixItems
: Array.isArray(schema.items)
? schema.items
: undefined
if (Array.isArray(parsed)) {
const repaired = parsed.map((item, index) => {
const member = tuple
? (tuple[index] ?? (Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems))
: schema.items
return Predicate.isObject(member) ? repair(item, member, root, depth + 1) : item
})
return repaired.every((item, index) => item === parsed[index]) ? parsed : repaired
}
if (tuple || !Predicate.isObject(schema.items)) return value
const repaired = repair(value, schema.items, root, depth + 1)
const type = schema.items.type
const compatible =
type === "object"
? Predicate.isObject(repaired)
: type === "array"
? Array.isArray(repaired)
: type === "integer"
? typeof repaired === "number" && Number.isSafeInteger(repaired)
: type === "number"
? typeof repaired === "number" && Number.isFinite(repaired)
: (type === "string" || type === "boolean") && typeof repaired === type
return compatible ? [repaired] : value
}
+14 -3
View File
@@ -132,11 +132,16 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
return yield* importPackage(specifier, entrypoint)
})
/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
/** opencode settings consumed in Core; native packages never receive them. */
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
const PROVIDER_ONLY_KEYS = ["chunkTimeout", "timeout", "transport"] as const
export function nativeSettings(settings: Settings): Settings {
return Struct.omit(settings, TRANSPORT_KEYS)
return Struct.omit(settings, CORE_KEYS)
}
export function modelSettings(settings: Settings | undefined) {
return settings && Struct.omit(settings, PROVIDER_ONLY_KEYS)
}
export function mergeOverlay(
@@ -177,6 +182,12 @@ export function mergeHeaders(
export const Request = Provider.Request
export type Request = Provider.Request
export const Compaction = Provider.Compaction
export type Compaction = Provider.Compaction
export const Transport = Provider.Transport
export type Transport = Provider.Transport
export const Settings = Provider.Settings
export type Settings = Provider.Settings
+3 -8
View File
@@ -736,7 +736,7 @@ export const layer = Layer.effect(
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
const request = { ...input, reason: "auto" as const }
if (input.overflow) return yield* recoverLocally(request)
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
if (input.context.model.compaction?.type !== "native") return yield* execute(request)
return yield* executeProvider(request)
})
const required = (input: RequiredInput) => {
@@ -759,12 +759,7 @@ export const layer = Layer.effect(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const policy = input.resolved.compaction
const threshold =
policy?.mode === "provider" && policy.threshold !== undefined
? Math.min(policy.threshold, promptCeiling)
: promptCeiling
return estimateTokens(input) >= threshold
return estimateTokens(input) >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
@@ -792,7 +787,7 @@ export const layer = Layer.effect(
inputID: input.inputID,
started: input.started,
}
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request)
},
}),
)
+1
View File
@@ -26,6 +26,7 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
+2
View File
@@ -39,6 +39,7 @@ type Data = {
}
export interface Interface extends State.Transformable<Editor> {
readonly list: () => Effect.Effect<ReadonlyArray<Tool.Info & { readonly id: string }>>
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -220,6 +221,7 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
list: () => Effect.sync(() => Array.from(state.get().tools.values())),
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const data = state.get()
+11 -4
View File
@@ -27,14 +27,15 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
Model.Info.make({
const model = (packageName: string, settings: Provider.Settings = {}): Model.RuntimeInfo => ({
...Model.Info.make({
...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
modelID: Model.ID.make("api-model"),
package: Provider.aisdk(packageName),
settings,
limit: { context: 100, output: 20 },
})
}),
settings,
})
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
specificationVersion: "v3",
@@ -155,14 +156,18 @@ it.effect("projects request settings, headers, and body overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: unknown
let options: Record<string, unknown> | undefined
yield* aisdk.hook.sdk((event) => {
body = event.options.body
options = event.options
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const input = model("@ai-sdk/google", {
apiKey: "secret",
thinkingConfig: { thinkingBudget: 1024 },
compaction: { type: "native" },
transport: "websocket",
})
const resolved = yield* aisdk.model({
...input,
@@ -185,6 +190,8 @@ it.effect("projects request settings, headers, and body overlays", () =>
})
expect(prepared.body.headers).toEqual({ "x-test": "header" })
expect(body).toEqual({ safety_setting: "strict" })
expect(options).not.toHaveProperty("compaction")
expect(options).not.toHaveProperty("transport")
}),
)
+2 -1
View File
@@ -511,7 +511,7 @@ describe("Provider and Model", () => {
}),
)
it.effect("resolves provider and model overlay merges", () =>
it.effect("keeps settings scoped while resolving request overlay merges", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -533,6 +533,7 @@ describe("Provider and Model", () => {
})
const model = required(yield* models.get(providerID, modelID))
expect((yield* providers.get(providerID))?.settings).toEqual({ provider: true, shared: "provider" })
expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.body).toEqual({ provider: true, shared: "model", model: true })
+27 -28
View File
@@ -34,9 +34,10 @@ function required<T>(value: T | undefined): T {
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const models = yield* Model.Service
const providers = yield* Provider.Service
yield* addPlugin([
new Document({
type: "document",
@@ -44,12 +45,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
compaction: { mode: "provider", threshold: 120_000 },
settings: { compaction: { type: "native" } },
models: {
native: {},
reset: { compaction: { mode: "provider" } },
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" },
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
unsupported: { package: "@opencode/ai/providers/openai/chat" },
},
},
@@ -62,16 +61,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
const local = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("local")))
const unsupported = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
const defaultModel = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("chat")))
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
mode: "provider",
})
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
mode: "provider",
threshold: 90_000,
})
expect(local.compaction).toEqual({ mode: "local" })
expect(defaultModel.compaction).toBeUndefined()
expect(native.settings?.compaction).toEqual({ type: "native" })
expect(local.settings?.compaction).toEqual({ type: "summary" })
expect(defaultModel.settings?.compaction).toBeUndefined()
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.compaction).toEqual({ type: "native" })
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
@@ -82,8 +75,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("inherits the provider websocket policy with model overrides", () =>
it.effect("keeps the provider websocket policy out of model settings", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
yield* addPlugin([
new Document({
@@ -92,8 +86,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
transport: "http",
models: { inherited: {}, override: { transport: "websocket" } },
settings: { transport: "http", timeout: 100, chunkTimeout: 200, shared: "provider" },
models: {
inherited: {
settings: { transport: "websocket", timeout: 1, chunkTimeout: 2, model: true },
},
},
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -101,11 +99,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
])
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.transport).toBe("http")
expect(override.transport).toBe("websocket")
expect(untouched.transport).toBeUndefined()
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.transport).toBe("http")
expect(inherited.settings).toEqual({ shared: "provider", model: true })
expect(untouched.settings?.transport).toBeUndefined()
}),
)
@@ -115,7 +112,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
]) {
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
it.live(`configured provider transport overrides ${builtin.id} defaults`, () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -131,7 +128,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
expect((yield* providers.get(providerID))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
yield* addPlugin([
new Document({
@@ -139,16 +137,17 @@ describe("ConfigProviderPlugin.Plugin", () => {
info: decode({
providers: {
[builtin.id]: {
transport: "http",
models: { override: { modelID: builtin.model, transport: "websocket" } },
settings: { transport: "http" },
models: { override: { modelID: builtin.model } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
expect((yield* providers.get(providerID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBeUndefined()
}),
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { Ignore } from "@opencode/core/filesystem/ignore"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import { createWrapper } from "@parcel/watcher/wrapper.js"
test("parcel patterns ignore built-in folders at any depth", async () => {
let ignoreGlobs: string[] = []
+1
View File
@@ -72,6 +72,7 @@ export const registerToolPlugin = <R>(
tool: {
transform: tools.transform,
reload: tools.reload,
list: tools.list,
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
+1
View File
@@ -133,6 +133,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
list: () => Effect.die("unused tool.list"),
hook: () => Effect.die("unused tool.hook"),
},
vcs: overrides.vcs ?? {
+1
View File
@@ -73,6 +73,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
tool: {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
list: () => Effect.die("unused tool.list"),
hook: (name, callback) => {
if (name === "execute.after") {
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
@@ -434,7 +434,7 @@ describe("AzurePlugin", () => {
),
)
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
it.effect("stores the Azure Responses WebSocket preference on the provider", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
@@ -472,11 +472,10 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
expect(responses.transport).toBe("websocket")
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
expect((yield* catalog.get(Provider.ID.azure))?.settings?.transport).toBe("websocket")
for (const modelID of [models.responses, models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.transport).toBeUndefined()
expect(model.settings?.transport).toBeUndefined()
}
}),
),
@@ -205,7 +205,8 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe("@opencode/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.transport).toBe("websocket")
expect(provider.settings?.transport).toBe("websocket")
expect(model.settings?.transport).toBeUndefined()
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -236,7 +237,7 @@ describe("OpenAIPlugin", () => {
id: "deployment-responses",
provider: Provider.ID.azure,
})
const prepare = (preference?: Model.Info["transport"]) =>
const prepare = (preference?: Provider.Transport) =>
Effect.gen(function* () {
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
@@ -550,14 +550,15 @@ describe("OpencodePlugin", () => {
),
)
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
it.effect("refreshes hosted search with Console config, retains it on failure, and skips unchanged snapshots", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { advertised: false, requests: 0 }
const state = { advertised: false, failing: false, requests: 0 }
const server = Bun.serve({
port: 0,
fetch: () => {
state.requests++
if (state.failing) return new Response("Unavailable", { status: 502 })
return Response.json({
providers: {},
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
@@ -607,10 +608,18 @@ describe("OpencodePlugin", () => {
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
state.advertised = false
state.failing = true
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
state.failing = false
state.advertised = false
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(5)
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
expect(yield* websearch.providers()).toEqual([])
expect(yield* websearch.default()).toBeUndefined()
@@ -83,8 +83,10 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const provider = yield* providers.get(providerID)
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.transport).toBe("websocket")
expect(provider?.settings?.transport).toBe("websocket")
expect(model?.settings?.transport).toBeUndefined()
}),
)
})
@@ -0,0 +1,114 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Agent } from "@opencode/core/agent"
import { Plugin } from "@opencode/core/plugin"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import { Tool } from "@opencode/core/tool"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const identity = {
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
}
it.effect("repairs tool input before validating its original schema", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([
{ ...ToolInputRepairPlugin.Plugin, revision: "1" },
{
id: "repairable-tool",
revision: "1",
effect: (ctx) =>
ctx.tool.transform((draft) =>
draft.add({
name: "repairable",
options: { codemode: false },
description: "Repairable",
input: Schema.Struct({ count: Schema.Int, enabled: Schema.Boolean }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
),
},
])
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-repair",
name: "repairable",
input: '{"count":"2","enabled":"true","extra":true}',
},
})
expect(executed).toEqual([{ count: 2, enabled: true }])
yield* registry.transform((draft) => {
draft.update("repairable", (tool) => {
tool.input = Schema.Struct({ count: Schema.Boolean, enabled: Schema.Boolean })
})
})
const updated = yield* registry.snapshot()
yield* updated.execute({
...identity,
call: {
type: "tool-call",
id: "call-updated",
name: "repairable",
input: { count: "false", enabled: "true" },
},
})
expect(executed).toEqual([
{ count: 2, enabled: true },
{ count: false, enabled: true },
])
yield* registry.transform((draft) => draft.remove("repairable"))
const removed = yield* registry.snapshot()
expect(
(yield* removed
.execute({
...identity,
call: { type: "tool-call", id: "call-removed", name: "repairable", input: {} },
})
.pipe(Effect.flip)).message,
).toBe('No tool named "repairable" is currently available. Please use a tool from the available tool list.')
}),
)
it.effect("repairs namespaced inner tool input called from Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
yield* plugins.activate([{ ...ToolInputRepairPlugin.Plugin, revision: "1" }])
yield* registry.transform((draft) =>
draft.add({
name: "count",
options: { namespace: "example" },
description: "Record a count",
input: Schema.Struct({ count: Schema.Int }),
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
}),
)
const snapshot = yield* registry.snapshot()
yield* snapshot.execute({
...identity,
call: {
type: "tool-call",
id: "call-codemode-repair",
name: "execute",
input: { code: 'return await tools.example.count({ count: "3" })' },
},
})
expect(executed).toEqual([{ count: 3 }])
}),
)
@@ -0,0 +1,455 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/core/session/message"
import type { ToolHooks } from "@opencode/plugin/effect/tool"
import { Tool } from "@opencode/schema/tool"
import { Effect, type JsonSchema } from "effect"
import { it } from "../lib/effect"
import { host } from "./host"
function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
const event: ToolHooks["execute.before"] = {
tool: "test",
input,
sessionID: Session.ID.make("ses_repair"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_repair"),
id: Tool.CallID.make("call_repair"),
}
const events: ToolHooks = {
"execute.before": event,
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
}
const tool = {
id: "test",
name: "test",
description: "Test repair",
input: inputSchema,
execute: () => Effect.succeed({ content: "unused" }),
}
const base = host()
return ToolInputRepairPlugin.Plugin.effect(
host({
tool: {
...base.tool,
list: () => Effect.succeed([tool]),
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
},
}),
).pipe(Effect.as(event))
}
const object = (properties: Record<string, unknown>, required?: string[]) => ({
type: "object" as const,
properties,
...(required ? { required } : {}),
})
describe("tool input repair plugin", () => {
it.effect("preserves valid input identity, nested containers, and unknown properties", () =>
Effect.gen(function* () {
const nested = { enabled: true }
const items = [2, 3]
const input = { count: 2, nested, items, extra: "keep" }
const event = yield* run(
input,
object({
count: { type: "integer" },
nested: object({ enabled: { type: "boolean" } }),
items: { type: "array", items: { type: "number" } },
}),
)
expect(event.input).toBe(input)
expect((event.input as typeof input).nested).toBe(nested)
expect((event.input as typeof input).items).toBe(items)
}),
)
it.effect("parses root objects and repairs nested stringified containers", () =>
Effect.gen(function* () {
const schema = object({
count: { type: "integer" },
item: object({ enabled: { type: "boolean" } }),
list: { type: "array", items: { type: "integer" } },
})
expect(
(yield* run('{"count":"2","item":"{\\"enabled\\":\\"false\\"}","list":"[\\"3\\"]"}', schema)).input,
).toEqual({
count: 2,
item: { enabled: false },
list: [3],
})
expect((yield* run("{broken", schema)).input).toBe("{broken")
expect((yield* run("[]", schema)).input).toBe("[]")
expect((yield* run(null, schema)).input).toBeNull()
}),
)
it.effect("removes extras only from explicitly closed objects without mutating inputs", () =>
Effect.gen(function* () {
const input = {
known: "2",
extra: true,
closed: { keep: "3", extra: true },
open: { keep: "4", extra: true },
items: [{ keep: "5", extra: true }],
}
const event = yield* run(input, {
...object({
known: { type: "integer" },
closed: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
open: object({ keep: { type: "integer" } }),
items: {
type: "array",
items: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
},
}),
additionalProperties: false,
})
expect(event.input).toEqual({
known: 2,
closed: { keep: 3 },
open: { keep: 4, extra: true },
items: [{ keep: 5 }],
})
expect(input.extra).toBeTrue()
expect(input.closed.extra).toBeTrue()
expect(input.items[0]?.extra).toBeTrue()
expect((yield* run({ extra: true }, { ...object({}), additionalProperties: false })).input).toEqual({})
}),
)
it.effect("preserves unknown keys when patterned ownership cannot be determined", () =>
Effect.gen(function* () {
const input = { known: 1, match: "2", extra: true }
const event = yield* run(input, {
...object({ known: { type: "integer" } }),
additionalProperties: false,
patternProperties: { "^match$": { type: "integer" } },
})
expect(event.input).toBe(input)
expect((event.input as typeof input).match).toBe("2")
expect((event.input as typeof input).extra).toBeTrue()
}),
)
it.effect("preserves properties that may belong to composed object schemas", () =>
Effect.gen(function* () {
const input = { name: "example", extra: true }
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
const event = yield* run(input, {
type: "object",
[keyword]: [object({ name: { type: "string" } })],
additionalProperties: false,
})
expect(event.input).toBe(input)
}
}),
)
it.effect("removes only optional nonnullable nulls and non-object empty placeholders", () =>
Effect.gen(function* () {
const input = {
optional: null,
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
placeholder: {},
array: {},
requiredPlaceholder: {},
object: {},
unknown: null,
}
const event = yield* run(
input,
object(
{
optional: { type: "string" },
required: { type: "string" },
nullable: { type: "string", nullable: true },
union: { anyOf: [{ type: "integer" }, { type: "null" }] },
constant: { anyOf: [{ type: "integer" }, { const: null }] },
permissive: { anyOf: [{ type: "integer" }, true] },
referenced: { anyOf: [{ type: "integer" }, { $ref: "#/$defs/nullable" }] },
placeholder: { type: "integer" },
array: { type: "array", items: { type: "string" } },
requiredPlaceholder: { type: "boolean" },
object: { type: "object" },
unknown: {},
},
["required", "requiredPlaceholder"],
),
)
expect(event.input).toEqual({
required: null,
nullable: null,
union: null,
constant: null,
permissive: null,
referenced: null,
requiredPlaceholder: {},
object: {},
unknown: null,
})
expect(input.optional).toBeNull()
expect(input.placeholder).toEqual({})
}),
)
it.effect("preserves nulls whose validity is hidden inside compositions", () =>
Effect.gen(function* () {
const input = { wrapped: null, enumerated: null, permissive: null, constant: null }
const event = yield* run(
input,
object({
wrapped: { anyOf: [{ type: ["string", "null"] }] },
enumerated: { anyOf: [{ enum: [null, "keep"] }] },
permissive: { anyOf: [{}] },
constant: { type: "string", const: "keep" },
}),
)
expect(event.input).toBe(input)
}),
)
it.effect("preserves optional-looking nulls when the parent composes requirements", () =>
Effect.gen(function* () {
const input = { value: null }
const event = yield* run(input, {
...object({ value: { type: "string" } }),
allOf: [{ required: ["value"] }],
})
expect(event.input).toBe(input)
}),
)
it.effect("coerces numeric and boolean strings while preserving invalid and existing values", () =>
Effect.gen(function* () {
const input = {
number: "1.5",
integer: "42",
enabled: "true",
disabled: "false",
valid: 3,
empty: " ",
infinite: "Infinity",
fractional: "1.5",
unsafe: "9007199254740992",
uppercase: "TRUE",
}
const event = yield* run(
input,
object({
number: { type: "number" },
integer: { type: "integer" },
enabled: { type: "boolean" },
disabled: { type: "boolean" },
valid: { type: "integer" },
empty: { type: "number" },
infinite: { type: "number" },
fractional: { type: "integer" },
unsafe: { type: "integer" },
uppercase: { type: "boolean" },
}),
)
expect(event.input).toEqual({ ...input, number: 1.5, integer: 42, enabled: true, disabled: false })
}),
)
it.effect("wraps compatible scalars after repairing array items", () =>
Effect.gen(function* () {
const event = yield* run(
{
text: "one",
integer: "42",
boolean: "false",
item: '{"count":"2"}',
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
},
object({
text: { type: "array", items: { type: "string" } },
integer: { type: "array", items: { type: "integer" } },
boolean: { type: "array", items: { type: "boolean" } },
item: { type: "array", items: object({ count: { type: "integer" } }) },
incompatible: { type: "array", items: { type: "string" } },
fractional: { type: "array", items: { type: "integer" } },
unconstrained: { type: "array" },
}),
)
expect(event.input).toEqual({
text: ["one"],
integer: [42],
boolean: [false],
item: [{ count: 2 }],
incompatible: 2,
fractional: 1.5,
unconstrained: "4",
})
}),
)
it.effect("repairs nested question-like inputs without mutating original containers", () =>
Effect.gen(function* () {
const question = { question: "Pick one", multiple: "false", options: { label: "First", description: null } }
const input = { questions: [question] }
const event = yield* run(
input,
object({
questions: {
type: "array",
items: object({
question: { type: "string" },
multiple: { type: "boolean" },
options: {
type: "array",
items: object({ label: { type: "string" }, description: { type: "string" } }, ["label"]),
},
}),
},
}),
)
expect(event.input).toEqual({
questions: [{ question: "Pick one", multiple: false, options: [{ label: "First" }] }],
})
expect(input).toEqual({ questions: [question] })
expect(question.options.description).toBeNull()
}),
)
it.effect("repairs unique nullable alternatives while preserving accepted union values", () =>
Effect.gen(function* () {
const input = {
number: "2",
boolean: "false",
nullable: null,
typed: "3",
typedBoolean: "true",
accepted: "4",
valid: 5,
}
const event = yield* run(
input,
object({
number: { anyOf: [{ type: "number" }, { type: "null" }] },
boolean: { oneOf: [{ type: "boolean" }, { type: "null" }] },
nullable: { anyOf: [{ type: "number" }, { type: "null" }] },
typed: { type: ["integer", "null"] },
typedBoolean: { type: ["boolean", "null"] },
accepted: { anyOf: [{ type: "string" }, { type: "number" }] },
valid: { type: ["number", "null"] },
}),
)
expect(event.input).toEqual({ ...input, number: 2, boolean: false, typed: 3, typedBoolean: true })
}),
)
it.effect("repairs tuple positions and rest items while preserving valid array identity", () =>
Effect.gen(function* () {
const valid = [2, false]
const input = { prefix: ["2", "false", "3"], draft: '["4","true"]', valid, scalar: "5" }
const event = yield* run(
input,
object({
prefix: {
type: "array",
prefixItems: [{ type: "integer" }, { type: "boolean" }],
items: { type: "number" },
},
draft: { type: "array", items: [{ type: "integer" }, { type: "boolean" }] },
valid: { type: "array", prefixItems: [{ type: "integer" }, { type: "boolean" }] },
scalar: { type: "array", prefixItems: [{ type: "integer" }] },
}),
)
expect(event.input).toEqual({ prefix: [2, false, 3], draft: [4, true], valid, scalar: "5" })
expect((event.input as typeof input).valid).toBe(valid)
expect(input.prefix).toEqual(["2", "false", "3"])
}),
)
it.effect("repairs typed dictionaries and straightforward local references", () =>
Effect.gen(function* () {
const input = {
modern: "2",
legacy: "false",
nested: { count: "3" },
dictionary: { first: "4" },
missing: "5",
pointer: "6",
escaped: "7",
}
const event = yield* run(input, {
...object({
modern: { $ref: "#/$defs/integer" },
legacy: { $ref: "#/definitions/boolean" },
nested: { $ref: "#/$defs/nested" },
dictionary: { type: "object", additionalProperties: { $ref: "#/$defs/integer" } },
missing: { $ref: "#/$defs/missing" },
pointer: { $ref: "#/$defs/nested/properties/count" },
escaped: { $ref: "#/$defs/a~1b~0c" },
}),
$defs: {
integer: { type: "integer" },
"a/b~c": { type: "integer" },
nested: object({ count: { $ref: "#/$defs/integer" } }),
},
definitions: { boolean: { type: "boolean" } },
})
expect(event.input).toEqual({
modern: 2,
legacy: false,
nested: { count: 3 },
dictionary: { first: 4 },
missing: "5",
pointer: "6",
escaped: 7,
})
expect(input.nested.count).toBe("3")
expect(input.dictionary.first).toBe("4")
}),
)
it.effect("leaves ambiguous unions, compositions, and unsupported roots unchanged", () =>
Effect.gen(function* () {
const input = { numeric: "2", objects: { value: "3" }, both: "4", composed: "5", unknown: "6" }
const event = yield* run(
input,
object({
numeric: { anyOf: [{ type: "number" }, { type: "integer" }] },
objects: {
oneOf: [
object({ value: { type: "integer" } }, ["value"]),
object({ value: { type: "number" } }, ["value"]),
],
},
both: { anyOf: [{ type: "integer" }], oneOf: [{ type: "integer" }] },
composed: { allOf: [{ type: "integer" }] },
unknown: {},
}),
)
expect(event.input).toBe(input)
expect((yield* run(input, { properties: { numeric: { type: "integer" } } })).input).toBe(input)
expect((yield* run(input, { allOf: [object({ numeric: { type: "integer" } })] })).input).toBe(input)
}),
)
})
+25 -2
View File
@@ -28,10 +28,33 @@ describe("Provider", () => {
}
})
test("passes flat settings to native packages without opencode transport keys", () => {
expect(Provider.nativeSettings({ apiKey: "secret", reasoningEffort: "high", chunkTimeout: 1000 })).toEqual({
test("passes flat settings to native packages without Core settings", () => {
expect(
Provider.nativeSettings({
apiKey: "secret",
reasoningEffort: "high",
chunkTimeout: 1000,
compaction: { type: "native" },
transport: "websocket",
}),
).toEqual({
apiKey: "secret",
reasoningEffort: "high",
})
})
test("inherits shared and loose settings without provider-only policies", () => {
expect(
Provider.modelSettings({
timeout: 60_000,
chunkTimeout: 30_000,
transport: "websocket",
compaction: { type: "native" },
reasoningEffort: "high",
}),
).toEqual({
compaction: { type: "native" },
reasoningEffort: "high",
})
})
})
+3 -10
View File
@@ -207,20 +207,13 @@ it.effect("auto compaction estimates current content against the buffered prompt
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const native = (
tokens: number,
limit: { context: number; input?: number; output: number } = inputLimited,
threshold?: number,
) => {
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
@@ -164,7 +164,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
compaction: { mode: "provider" },
compaction: { type: "native" },
},
)
const sessionID = SessionSchema.ID.create()
+4 -2
View File
@@ -2863,7 +2863,8 @@ describe("SessionRunnerLLM", () => {
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.defaultID, (agent) => {
@@ -2914,7 +2915,8 @@ describe("SessionRunnerLLM", () => {
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
modelLimits.set("native", { context: 42_000, output: 32_000 })
s.compaction = { type: "native" }
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
+3
View File
@@ -106,6 +106,9 @@ test("Core reuses the canonical shared schemas", async () => {
[coreModel.Info, Model.Info],
[coreProvider.ID, Provider.ID],
[coreProvider.Request, Provider.Request],
[coreProvider.Compaction, Provider.Compaction],
[coreProvider.Transport, Provider.Transport],
[coreProvider.Settings, Provider.Settings],
[coreProvider.Info, Provider.Info],
[corePermission.Effect, Permission.Effect],
[corePermission.Rule, Permission.Rule],
@@ -59,6 +59,7 @@ describe("SkillInstructions", () => {
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
"<available_skills>",
" <skill>",
" <id>effect</id>",
+15
View File
@@ -855,6 +855,21 @@ describe("Tool", () => {
])
}),
)
it.effect("lists registered tools by effective name", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { codemode: false })
yield* transform(service, { count: { ...constant("1"), name: "count" } }, { namespace: "acme" })
expect((yield* service.list()).map((tool) => [tool.id, tool.name])).toEqual([
["echo", "echo"],
["acme_count", "count"],
])
yield* service.transform((editor) => editor.remove("echo"))
expect((yield* service.list()).map((tool) => tool.id)).toEqual(["acme_count"])
}),
)
;[
{ name: "string", content: "hooked", text: "hooked" },
{ name: "empty string", content: "", text: "" },
+36
View File
@@ -39,3 +39,39 @@ CLI preparation uses these channel rules:
`bun dev` is separate from packaging: it uses local renderer/server mode, the dev app identity, and the CLI source by
default. `bun dev --download-server <version>` instead downloads that CLI version for local development. Neither path
requires `OPENCODE_CLI_DIST` or runs the production prebuild.
## Startup benchmark
`bun run bench:startup` measures a **packaged** build from process spawn to the restored tab being ready and the
renderer going idle, so dev-server and bundling costs are not part of the numbers.
```bash
OPENCODE_CHANNEL=dev bun run build && bunx electron-builder --win --dir --config electron-builder.config.ts
bun run bench:startup -- --runs 5 # warm service (started once, reused by every launch)
bun run bench:startup -- --runs 5 --compare dist/other/OpenCode\ Dev.exe # A/B: alternate launches of two builds
bun run bench:startup -- --service cold # each launch spawns the service
bun run bench:startup -- --fresh # first launch after an install (profile wiped each time)
bun run bench:startup -- --profile-main --profile-renderer --trace # CPU profiles and a Chromium startup trace
bun run bench:startup -- --seed "%APPDATA%\ai.opencode.desktop.dev" # restore tabs and drafts from an existing profile
```
The app runs in an isolated home (`%TEMP%\opencode-bench-startup`): its own `%APPDATA%`, XDG directories, OpenCode
database, config and service registration, with the developer's `OPENCODE_*` and `OTEL_*` environment stripped
(an inherited OTLP endpoint alone adds a network round trip to every CLI exit). It never attaches to or restarts the
developer's live service, and only ever kills the process tree it spawned. `--service cold` stops the service before
each launch so the desktop has to spawn it; the isolated config directory gives that service a private port. One
`--warmup` launch per build is discarded by default because the first launch of a new binary pays the antivirus scan.
`--compare` alternates two builds so machine drift affects both equally; they must bundle the same CLI or the desktop
restarts the service on the version mismatch.
Milestones (ms since spawn) come from the main log, the renderer's performance timeline and DOM readiness polled over
CDP. Node's bootstrap timing is read from the main process after each run over `--inspect` (nothing attaches until the
run is over): `processCreated``nodeStart` is Electron's native init, `nodeStart``nodeBootstrapped` is Node
itself, and `nodeBootstrapped``appStarting` is Electron's JavaScript init plus our main bundle up to its first
log line. Renderer idle is the start of the first 500 ms window with under 10 % main-thread task time that stays quiet
for `--settle-ms`; `rendererTaskMs` is the renderer's total main-thread task time until then. Raw samples are
written to `dist/bench-startup`.
A packaged beta or prod build registers itself as the `opencode://` handler when it starts, even from the bench; the
installed app takes the registration back on its next launch. Those channels run with `HTTPS_PROXY` pointed at a
closed port (`--offline` forces it for dev) so the updater's first check fails fast instead of reaching GitHub.
@@ -91,6 +91,11 @@ for (const channel of channels) {
"js-yaml/dist/js-yaml.min.js",
"js-yaml/dist/js-yaml.mjs.map",
"js-yaml/bin/js-yaml.js",
"unrelated/dist/index.js.map",
"unrelated/dist/index.cjs.map",
"unrelated/dist/index.d.ts",
"unrelated/dist/index.d.cts",
"unrelated/dist/index.d.ts.map",
]) {
expect(filter(path.join(import.meta.dirname, prefix, file), statSync(import.meta.filename))).toBe(false)
}
@@ -112,7 +117,10 @@ for (const channel of channels) {
"js-yaml/lib/loader.js",
"js-yaml/dist/js-yaml.mjs",
"debug/src/index.js",
"unrelated/dist/index.js.map",
"unrelated/dist/index.js",
"unrelated/dist/index.cjs",
"unrelated/dist/data.json",
"unrelated/src/index.ts",
...["@zip.js/zip.js", "electron-updater", "builder-util-runtime", "ajv", "ajv-formats", "js-yaml"].flatMap(
(name) => [`${name}/package.json`, `${name}/LICENSE`],
),
@@ -202,7 +210,7 @@ for (const channel of ["dev", "beta"] as const) {
{
from: "resources/",
to: "",
filter: ["opencode-cli", "opencode-cli.exe"],
filter: ["opencode-cli", "opencode-cli.exe", "opencode-cli.version"],
},
])
})
+9 -4
View File
@@ -77,11 +77,14 @@ const getBase = (appId: string): Configuration => ({
// Log export imports Zip.js as ESM. Keep index.js and lib, including its inline worker.
"!**/node_modules/@zip.js/zip.js/dist{,/**/*}",
"!**/node_modules/@zip.js/zip.js/{index.cjs,index.min.js,index-fflate.js,deno.json,eslint.config.mjs}",
// These packages execute compiled JavaScript, not their sources or source maps.
"!**/node_modules/{electron-updater,builder-util-runtime,lazy-val}/out/**/*.js.map",
// Nothing executes type declarations or source maps, and every entry costs startup time: the
// main process parses the whole asar header before it runs any JavaScript.
"!**/node_modules/**/*.d.{ts,cts,mts}",
"!**/node_modules/**/*.d.{ts,cts,mts}.map",
"!**/node_modules/**/*.{js,cjs,mjs}.map",
// These packages execute compiled JavaScript, not their sources.
"!**/node_modules/ajv/lib{,/**/*}",
"!**/node_modules/ajv-formats/src{,/**/*}",
"!**/node_modules/{ajv,ajv-formats}/dist/**/*.js.map",
// Keep js-yaml's CommonJS sources and dist/js-yaml.mjs ESM entry, not browser bundles or its CLI.
"!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}",
"!**/node_modules/js-yaml/bin{,/**/*}",
@@ -90,7 +93,7 @@ const getBase = (appId: string): Configuration => ({
{
from: "resources/",
to: "",
filter: ["opencode-cli", "opencode-cli.exe"],
filter: ["opencode-cli", "opencode-cli.exe", "opencode-cli.version"],
},
],
afterPack: async (context) => {
@@ -100,6 +103,8 @@ const getBase = (appId: string): Configuration => ({
)
const file = await stat(cli)
if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`)
const version = path.join(path.dirname(cli), "opencode-cli.version")
if ((await stat(version)).size === 0) throw new Error(`Bundled CLI version must be a non-empty file: ${version}`)
},
mac: {
category: "public.app-category.developer-tools",
+2 -3
View File
@@ -20,16 +20,15 @@
"package": "electron-builder --config electron-builder.config.ts",
"package:mac": "electron-builder --mac --config electron-builder.config.ts",
"package:win": "electron-builder --win --config electron-builder.config.ts",
"package:linux": "electron-builder --linux --config electron-builder.config.ts"
"package:linux": "electron-builder --linux --config electron-builder.config.ts",
"bench:startup": "bun ./scripts/bench-startup.ts"
},
"main": "./out/main/index.js",
"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": {
+823
View File
@@ -0,0 +1,823 @@
#!/usr/bin/env bun
// Cold-start benchmark for a packaged desktop build.
//
// bun run bench:startup -- [--exe <path>] [--compare <path>] [--runs 5] [--warmup 1] [--service warm|cold]
// [--fresh] [--offline] [--seed <userData dir>] [--profile-main] [--profile-renderer]
// [--trace] [--out <dir>] [--home <dir>] [--window-at x,y]
//
// The app runs in an isolated home directory (its own %APPDATA%, XDG dirs, OpenCode DB, config and
// service registration) with the developer's OPENCODE_* / OTEL_* environment stripped, so it never
// attaches to, restarts or reads the developer's live service or state and does not inherit their
// telemetry configuration. `warm` starts one service from the bundled CLI before the runs and lets
// every launch reuse it; `cold` stops it before each run so the desktop has to spawn it. `--fresh`
// wipes the profile before each launch to measure the first launch after an install. `--compare`
// alternates launches of a second build so machine drift affects both equally, and `--warmup`
// launches are discarded (the first launch of a new binary pays the antivirus scan).
//
// Milestones come from the main log, the renderer's performance timeline and DOM readiness polled
// over CDP, plus Node's own bootstrap timing read from the main process afterwards over --inspect
// (nothing attaches until the run is over), which splits the time before the first log line into
// Electron/Chromium native init, Node bootstrap and our main bundle. `--profile-main` records a
// main-process CPU profile from the first statement (via --inspect-brk) on the first run,
// `--profile-renderer` records the renderer main thread from the moment its debug target appears,
// and `--trace` records Chromium's startup trace on the last run. Raw samples are written as JSON.
//
// What is on screen is sampled from the screen itself (Windows): a helper pins the window topmost
// without activating it the moment it exists, then records when the window's pixels first differ
// from the background colour (`screenPainted`) and when they stop changing (`screenSettled`).
// Renderer paint timing alone is not enough: Chromium stops painting an occluded window, and a
// splash or a fade reads as "painted" long before the interface is on screen. `--window-at` puts
// the window somewhere the developer's foreground window does not cover. BENCH_SCREEN_DUMP=<dir>
// also saves every sample as PNG, BENCH_EXTRA_ARGS passes extra Chromium switches to the app.
import { execFileSync, spawn } from "node:child_process"
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
import { createServer } from "node:net"
import { tmpdir } from "node:os"
import { basename, dirname, join, relative, resolve } from "node:path"
import { parseArgs } from "node:util"
const args = parseArgs({
args: process.argv.slice(2),
options: {
exe: { type: "string" },
compare: { type: "string" },
runs: { type: "string", default: "5" },
warmup: { type: "string", default: "1" },
service: { type: "string", default: "warm" },
fresh: { type: "boolean", default: false },
offline: { type: "boolean", default: false },
seed: { type: "string" },
home: { type: "string" },
out: { type: "string" },
"profile-main": { type: "boolean", default: false },
"profile-renderer": { type: "boolean", default: false },
trace: { type: "boolean", default: false },
"settle-ms": { type: "string", default: "1500" },
// Restore the bench window at "x,y" (and treat that display as trusted), for example on a display
// that the developer's foreground window does not cover; Chromium stops painting an occluded window.
"window-at": { type: "string" },
},
allowPositionals: true,
})
const packageDir = resolve(import.meta.dirname, "..")
const builds = [
{ label: args.values.compare ? "A" : "", exe: resolve(args.values.exe ?? defaultExe()) },
...(args.values.compare ? [{ label: "B", exe: resolve(args.values.compare) }] : []),
]
const runs = Number(args.values.runs)
const warmup = Number(args.values.warmup)
const service = args.values.service === "cold" ? "cold" : "warm"
const settleMs = Number(args.values["settle-ms"])
const outDir = resolve(args.values.out ?? join(packageDir, "dist", "bench-startup"))
const home = resolve(args.values.home ?? join(tmpdir(), "opencode-bench-startup"))
for (const build of builds) {
if (!existsSync(build.exe))
throw new Error(`Packaged executable not found: ${build.exe}. Run 'bun run build && bun run package:win' (or pass --exe).`)
}
if (!Number.isSafeInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer")
if (!Number.isSafeInteger(warmup) || warmup < 0) throw new Error("--warmup must be a non-negative integer")
mkdirSync(outDir, { recursive: true })
const appId = appIdFor(builds[0].exe)
if (builds.some((build) => appIdFor(build.exe) !== appId)) throw new Error("Compared builds must be the same channel")
const userData = join(home, "AppData", "Roaming", appId)
const paths = {
home,
appData: join(home, "AppData", "Roaming"),
localAppData: join(home, "AppData", "Local"),
temp: join(home, "AppData", "Local", "Temp"),
db: join(home, ".local", "share", "opencode", "opencode.db"),
config: join(home, ".config", "opencode"),
registration: join(home, ".local", "state", "opencode", "service.json"),
logs: join(userData, "logs"),
}
prepareHome()
// The desktop deletes XDG_STATE_HOME on Windows, so isolation goes through the home directory.
// OPENCODE_* and OTEL_* from the developer's shell would otherwise leak into the measured app and
// its service (an OTLP endpoint alone adds a network round trip to every CLI exit).
const env = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|OTEL_|SENTRY_)/.test(key))),
USERPROFILE: home,
HOME: home,
APPDATA: paths.appData,
LOCALAPPDATA: paths.localAppData,
TEMP: paths.temp,
TMP: paths.temp,
XDG_DATA_HOME: join(home, ".local", "share"),
XDG_CONFIG_HOME: join(home, ".config"),
XDG_CACHE_HOME: join(home, ".cache"),
OPENCODE_DB: paths.db,
OPENCODE_CONFIG_DIR: paths.config,
// Beta and prod builds check for updates on start; a closed proxy port fails that fast and offline.
...(args.values.offline || appId !== "ai.opencode.desktop.dev" ? { HTTPS_PROXY: "http://127.0.0.1:9" } : {}),
}
const cdpPort = await freePort()
// A private service port keeps a cold launch's own service away from the developer's service.
const servicePort = await freePort()
writeFileSync(join(paths.config, "service.json"), JSON.stringify({ port: servicePort }))
const inspectPort = await freePort()
let appPid: number | undefined
let serviceProcess: ReturnType<typeof spawn> | undefined
// Renderer readiness, read over CDP: paint timing plus the DOM states the user actually waits for.
const probe = `(() => ({
origin: performance.timeOrigin,
firstPaint: performance.getEntriesByType('paint').find((e) => e.name === 'first-paint')?.startTime,
domInteractive: performance.getEntriesByType('navigation')[0]?.domInteractive,
prepaint: !!document.getElementById('oc-prepaint'),
visible: document.visibilityState === 'visible',
shell: !!document.querySelector('#root [data-titlebar-tab-link], #root [data-action="vertical-tabs-home"]'),
editor: !!document.querySelector('#root [data-component="composer-editor"][contenteditable="true"]'),
rows: document.querySelectorAll('#root [data-timeline-row]').length,
home: !!document.querySelector('#root [data-action="home-new-session"], #root [data-action="home-add-project-row"]'),
url: location.pathname + location.search,
}))()`
// Main-process bootstrap timing, read after the run: when the process was created, when Node
// started inside it, and when Node's own bootstrap finished and handed control to the entry module.
const mainTiming = `JSON.stringify({ created: process.getCreationTime(), origin: performance.timeOrigin, ...performance.nodeTiming.toJSON(), cpu: process.cpuUsage(), rss: process.memoryUsage().rss })`
// Renderer navigation and resource timing plus any marks the app emitted, read once the run is settled.
const rendererTimeline = `(() => {
const nav = performance.getEntriesByType('navigation')[0]
const resources = performance.getEntriesByType('resource')
const scripts = resources.filter((r) => r.initiatorType === 'script' || r.name.endsWith('.js'))
return {
navigation: nav && { fetchStart: nav.fetchStart, responseEnd: nav.responseEnd, domInteractive: nav.domInteractive, domContentLoaded: nav.domContentLoadedEventEnd, load: nav.loadEventEnd },
resources: { count: resources.length, scripts: scripts.length, firstStart: Math.min(...resources.map((r) => r.startTime)), lastEnd: Math.max(...resources.map((r) => r.responseEnd)), bytes: resources.reduce((n, r) => n + (r.encodedBodySize || 0), 0) },
paint: Object.fromEntries(performance.getEntriesByType('paint').map((e) => [e.name, e.startTime])),
marks: performance.getEntriesByType('mark').map((e) => [e.name, Math.round(e.startTime)]),
measures: performance.getEntriesByType('measure').map((e) => [e.name, Math.round(e.startTime), Math.round(e.duration)]),
}
})()`
for (const build of builds) console.log(`bench${build.label ? ` ${build.label}` : ""}: ${build.exe}`)
console.log(`home: ${home}`)
console.log(`service: ${service}, runs: ${runs} (+${warmup} warm-up), cdp ${cdpPort}, inspect ${inspectPort}${args.values.fresh ? ", fresh profile per launch" : ""}`)
if (service === "warm") await warmService()
const samples: Sample[] = []
// A launch that never produces a renderer would otherwise leave an instance behind that every later
// launch hands off to through the single-instance lock.
process.on("uncaughtException", async (error) => {
console.error(error)
await killApp()
await stopService()
process.exit(1)
})
for (let run = 1 - warmup; run <= runs; run++) {
for (const build of builds) {
const sample = await launch(build, run)
if (run < 1) {
console.log(`warm-up${build.label ? ` ${build.label}` : ""}: shell ${sample.msSinceSpawn.shellVisible} ms`)
continue
}
samples.push(sample)
console.log(JSON.stringify(sample))
}
}
await killApp()
if (service === "warm") await stopService()
const summaries = Object.fromEntries(
builds.map((build) => {
const own = samples.filter((s) => s.build === build.label)
const timed = own.filter((s) => !s.profiled && !s.traced)
return [build.label || "A", summarize(timed.length ? timed : own)]
}),
)
// Durations between consecutive checkpoints, so "where did the time go" needs no subtraction.
const phaseOrder = [
["electron native init", "processCreated", "nodeStart"],
["node bootstrap", "nodeStart", "nodeBootstrapped"],
["electron js init → entry", "nodeBootstrapped", "entryStart"],
["entry → chromium ready", "entryStart", "electronReady"],
["ready → window shown", "electronReady", "windowVisible"],
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
["layers → first log line", "bundleEvaluated", "appStarting"],
["layers → renderer process", "appStarting", "rendererProcess"],
["renderer boot → first paint", "rendererProcess", "firstPaint"],
["first paint → shell", "firstPaint", "shellVisible"],
["shell → idle", "shellVisible", "rendererIdle"],
] as const
const phases = Object.fromEntries(
builds.map((build) => {
const own = samples.filter((s) => s.build === build.label && !s.profiled && !s.traced)
const out: Record<string, number> = {}
for (const [name, from, to] of phaseOrder) {
const deltas = own
.map((s) => (s.msSinceSpawn[to] ?? NaN) - (s.msSinceSpawn[from] ?? NaN))
.filter(Number.isFinite)
.sort((a, b) => a - b)
if (deltas.length) out[name] = deltas[Math.floor(deltas.length / 2)]
}
return [build.label || "A", out]
}),
)
const report = { builds, service, runs, warmup, fresh: args.values.fresh, home, summaries, phases, samples }
const reportPath = join(outDir, `startup-${Date.now()}.json`)
writeFileSync(reportPath, JSON.stringify(report, null, 2))
const labels = Object.keys(summaries)
const header = `${"".padEnd(32)}${labels.map((label) => (labels.length > 1 ? label : "").padStart(6).padEnd(22)).join("")}`
console.log(`\n${service} service${args.values.fresh ? ", fresh profile" : ""} — median (min…max) ms since spawn over ${runs} runs`)
console.log(header)
for (const key of [...new Set(labels.flatMap((label) => Object.keys(summaries[label])))]) {
const cells = labels.map((label) => {
const v = summaries[label][key]
return v ? `${String(v.median).padStart(6)} (${v.min}${v.max})`.padEnd(22) : "".padEnd(22)
})
console.log(`${key.padEnd(32)}${cells.join("")}`)
}
console.log(`\nphases — median ms`)
console.log(header)
for (const [name] of phaseOrder) {
const cells = labels.map((label) => String(phases[label][name] ?? "").padStart(6).padEnd(22))
console.log(`${name.padEnd(32)}${cells.join("")}`)
}
const idle = (label: string) => samples.filter((s) => s.build === label && !s.profiled && !s.traced).at(-1)
console.log(`\nmemory once idle (last run) — working set MB per process, main CPU ms`)
for (const label of labels) {
const s = idle(label === "A" && labels.length === 1 ? "" : label)
if (!s) continue
const total = s.processes.reduce((n, p) => n + p.rssMB, 0)
console.log(`${(labels.length > 1 ? label : "").padEnd(4)}total ${total} MB · main cpu ${s.mainCpuMs ?? "?"} ms · renderer heap ${s.renderer.jsHeapMB} MB, ${s.renderer.domNodes} nodes, layout ${s.renderer.layoutMs} ms/${s.renderer.layouts}×, style ${s.renderer.styleMs} ms/${s.renderer.styleRecalcs}×`)
console.log(` ${s.processes.map((p) => `${p.name} ${p.rssMB}`).join(" · ")}`)
}
console.log(`\nreport: ${reportPath}`)
process.exit(0)
// ---------------------------------------------------------------------------------------------
type Probe = {
origin: number
firstPaint?: number
domInteractive?: number
prepaint: boolean
visible: boolean
shell: boolean
editor: boolean
rows: number
home: boolean
url: string
}
type Sample = {
build: string
run: number
profiled: boolean
traced?: string
mainProfile?: string
rendererProfile?: string
msSinceSpawn: Record<string, number | undefined>
final: { url?: string; timelineRows?: number }
rendererCpu: { taskMs: number; scriptMs: number }
// Renderer main-thread breakdown from Performance.getMetrics at the end of the run.
renderer: Record<string, number>
rendererTimeline: unknown
// Working set per process in the app's tree (main, renderer, GPU, utility) once idle, in MB, and
// the main process's CPU time.
processes: { name: string; pid: number; rssMB: number }[]
mainCpuMs?: number
// Every timestamped line from the run's log directory (main, crash, onboarding, window, …) as
// [ms since spawn, file, message].
timeline: [number, string, string][]
}
async function launch(build: { label: string; exe: string }, run: number): Promise<Sample> {
await killApp()
if (service === "cold") await stopService()
if (args.values.fresh) rmSync(userData, { recursive: true, force: true })
const profile = args.values["profile-main"] && run === 1
const trace = args.values.trace && run === runs
const tracePath = join(outDir, `startup-trace-${Date.now()}.json`)
const launchArgs = [
...(process.env.BENCH_EXTRA_ARGS?.split(" ").filter(Boolean) ?? []),
`--remote-debugging-port=${cdpPort}`,
profile ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`,
...(trace
? [
"--trace-startup=*,disabled-by-default-v8.cpu_profiler",
"--trace-startup-duration=6",
"--trace-startup-format=json",
`--trace-startup-file=${tracePath}`,
]
: []),
]
const raiser = await windowRaiser()
const spawnAt = Date.now()
const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" })
child.unref()
appPid = child.pid
raiser.raise(child.pid!)
let mainProfile: Promise<unknown> | undefined
if (profile) mainProfile = profileMain(spawnAt)
const page = await waitFor(
() => targets(cdpPort).then((list) => list.find((t) => t.type === "page" && t.url.startsWith("oc://"))),
60_000,
)
const cdp = await connect(page.webSocketDebuggerUrl)
await cdp.send("Runtime.enable")
await cdp.send("Performance.enable")
const rendererProfile = args.values["profile-renderer"] && run === 1
if (rendererProfile) {
await cdp.send("Profiler.enable")
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
await cdp.send("Profiler.start")
}
// Poll DOM readiness and the renderer's cumulative main-thread task time together. The run ends
// when the shell is up and the main thread has spent under 10 % of any 500 ms window in tasks for `settleMs`.
const seen: Record<string, number> = {}
let last: Probe | undefined
let prepaintSeen = false
let quietSince: number | undefined
let taskMs = 0
let scriptMs = 0
const window: { at: number; task: number }[] = []
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const result = await cdp.send("Runtime.evaluate", { expression: probe, returnByValue: true })
last = result.result?.result?.value as Probe | undefined
const t = Date.now() - spawnAt
if (last?.prepaint) prepaintSeen = true
// Chromium marks a window it considers occluded hidden and the renderer stops painting.
if (last?.visible && !seen.documentVisible) seen.documentVisible = t
if (last?.shell && !seen.shellVisible) seen.shellVisible = t
if (last?.editor && !seen.composerEditable) seen.composerEditable = t
if (last?.rows && !seen.timelineRows) seen.timelineRows = t
if (last?.home && !seen.homeReady) seen.homeReady = t
const metrics = (await cdp.send("Performance.getMetrics")).result?.metrics as { name: string; value: number }[]
const task = (metrics.find((m) => m.name === "TaskDuration")?.value ?? 0) * 1000
scriptMs = (metrics.find((m) => m.name === "ScriptDuration")?.value ?? 0) * 1000
if (process.env.BENCH_DEBUG && task - taskMs > 5) console.log(`busy +${Date.now() - spawnAt} ${Math.round(task - taskMs)} ms`)
taskMs = task
window.push({ at: Date.now(), task })
while (window.length > 1 && window[1].at <= Date.now() - 500) window.shift()
if (task - window[0].task > 50) quietSince = undefined
else quietSince ??= window[0].at
if (seen.shellVisible && quietSince && Date.now() - quietSince >= settleMs) break
await sleep(50)
}
const rendererIdleMs = quietSince ? quietSince - spawnAt : undefined
const rendererProfilePath = rendererProfile ? join(outDir, `renderer-${Date.now()}.cpuprofile`) : undefined
if (rendererProfilePath) {
const stopped = await cdp.send("Profiler.stop")
writeFileSync(rendererProfilePath, JSON.stringify(stopped.result.profile))
console.log("renderer profile:", rendererProfilePath)
}
const finalMetrics = (await cdp.send("Performance.getMetrics")).result?.metrics as { name: string; value: number }[]
const metric = (name: string) => finalMetrics.find((m) => m.name === name)?.value ?? 0
const renderer = {
taskMs: Math.round(metric("TaskDuration") * 1000),
scriptMs: Math.round(metric("ScriptDuration") * 1000),
layoutMs: Math.round(metric("LayoutDuration") * 1000),
styleMs: Math.round(metric("RecalcStyleDuration") * 1000),
layouts: metric("LayoutCount"),
styleRecalcs: metric("RecalcStyleCount"),
domNodes: metric("Nodes"),
jsHeapMB: Math.round(metric("JSHeapUsedSize") / 1048576),
}
const timelineResult = await cdp.send("Runtime.evaluate", { expression: rendererTimeline, returnByValue: true })
cdp.close()
const processes = appPid ? await processTree(appPid) : []
const screenChanges = await raiser.screen()
const boot = profile ? undefined : await mainBootTiming()
await sleep(300)
// Chromium writes the startup trace when --trace-startup-duration elapses; keep the app alive until then.
if (trace) await waitFor(async () => (existsSync(tracePath) && statSync(tracePath).size > 0 ? true : undefined), 20_000)
const main = mainLog()
const origin = last?.origin ? Math.round(last.origin - spawnAt) : undefined
const sample: Sample = {
build: build.label,
run,
profiled: profile || !!rendererProfile,
traced: trace ? tracePath : undefined,
rendererProfile: rendererProfilePath,
msSinceSpawn: {
processCreated: boot && Math.round(boot.created - spawnAt),
nodeStart: boot && Math.round(boot.origin + boot.nodeStart - spawnAt),
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
entryStart: main.marks.entry && main.marks.entry - spawnAt,
electronReady: main.marks.ready && main.marks.ready - spawnAt,
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
appStarting: main.appStarting && main.appStarting - spawnAt,
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt,
serviceReady: main.serviceReady && main.serviceReady - spawnAt,
rendererProcess: origin,
windowVisible: main.windowVisible && main.windowVisible - spawnAt,
domInteractive:
last?.domInteractive !== undefined && origin !== undefined ? Math.round(origin + last.domInteractive) : undefined,
firstPaint: last?.firstPaint !== undefined && origin !== undefined ? Math.round(origin + last.firstPaint) : undefined,
// With a shell snapshot in the early document, first paint is the snapshot, not the app.
prepaintVisible:
prepaintSeen && last?.firstPaint !== undefined && origin !== undefined
? Math.round(origin + last.firstPaint)
: undefined,
...seen,
// First sampled screen change inside the window: the ground truth for "the user sees something".
screenPainted: screenChanges.changed[0],
// When the sampled window content stopped changing: the interface, not a splash, is on screen.
screenSettled: screenChanges.settled,
rendererIdle: rendererIdleMs,
},
final: { url: last?.url, timelineRows: last?.rows },
rendererCpu: { taskMs: Math.round(taskMs), scriptMs: Math.round(scriptMs) },
renderer,
rendererTimeline: timelineResult.result?.result?.value,
processes,
mainCpuMs: boot && Math.round((boot.cpu.user + boot.cpu.system) / 1000),
timeline: main.timeline.map(([at, file, message]) => [at - spawnAt, file, message]),
}
if (mainProfile) {
const profilePath = join(outDir, `main-${Date.now()}.cpuprofile`)
writeFileSync(profilePath, JSON.stringify(await mainProfile))
sample.mainProfile = profilePath
console.log("main profile:", profilePath)
}
return sample
}
// Read after the run, so the inspector attach cannot influence what was measured.
async function mainBootTiming() {
const target = (await targets(inspectPort)).find((t) => t.type === "node")
if (!target) return undefined
const cdp = await connect(target.webSocketDebuggerUrl)
const result = await cdp.send("Runtime.evaluate", { expression: mainTiming, returnByValue: true })
cdp.close()
const value = result.result?.result?.value
if (typeof value !== "string") return undefined
return JSON.parse(value) as {
created: number
origin: number
nodeStart: number
bootstrapComplete: number
cpu: { user: number; system: number }
rss: number
}
}
function defaultExe() {
const unpacked = join(packageDir, "dist", process.platform === "win32" ? "win-unpacked" : process.platform === "darwin" ? "mac" : "linux-unpacked")
if (!existsSync(unpacked)) return join(unpacked, "OpenCode Dev.exe")
const candidate = readdirSync(unpacked).find((f) => (process.platform === "win32" ? f.endsWith(".exe") : f.endsWith(".app") || !f.includes(".")))
return join(unpacked, candidate ?? "OpenCode Dev.exe")
}
function appIdFor(executable: string) {
const name = basename(executable, ".exe")
if (/beta/i.test(name)) return "ai.opencode.desktop.beta"
if (/dev/i.test(name)) return "ai.opencode.desktop.dev"
return "ai.opencode.desktop"
}
function prepareHome() {
for (const dir of [paths.appData, paths.temp, dirname(paths.db), paths.config, dirname(paths.registration), join(home, ".cache")]) mkdirSync(dir, { recursive: true })
if (args.values.seed && !existsSync(userData)) {
// Seed only the app's own state (tabs, drafts, settings, window placement); Chromium profile
// data, caches, logs and the staged CLI are recreated by the app.
const seed = resolve(args.values.seed)
const keep = /^(drafts\.sqlite(-wal|-shm)?|opencode\.[a-z]+|\.?window-state.*\.json|opencode)$/
cpSync(seed, userData, {
recursive: true,
filter: (source) => source === seed || keep.test(relative(seed, source).split(/[\\/]/)[0] ?? ""),
})
}
mkdirSync(paths.logs, { recursive: true })
const at = args.values["window-at"]?.split(",").map(Number)
if (at?.length === 2 && existsSync(userData)) {
for (const file of readdirSync(userData).filter((name) => /^window-state-.*\.json$/.test(name))) {
const state = JSON.parse(readFileSync(join(userData, file), "utf8"))
const bounds = displayAt(at[0], at[1])
writeFileSync(join(userData, file), JSON.stringify({ ...state, x: at[0], y: at[1], isMaximized: false, isFullScreen: false, displayBounds: bounds }))
}
}
}
function displayAt(x: number, y: number) {
if (process.platform !== "win32") return undefined
const out = execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { \"$($_.Bounds.X),$($_.Bounds.Y),$($_.Bounds.Width),$($_.Bounds.Height)\" }"], { encoding: "utf8" })
const displays = out.trim().split(/\r?\n/).map((line) => line.split(",").map(Number))
const hit = displays.find(([dx, dy, dw, dh]) => x >= dx && y >= dy && x < dx + dw && y < dy + dh)
return hit ? { x: hit[0], y: hit[1], width: hit[2], height: hit[3] } : undefined
}
async function freePort() {
return new Promise<number>((resolvePort, reject) => {
const server = createServer()
server.listen(0, "127.0.0.1", () => {
const address = server.address()
server.close(() => (typeof address === "object" && address ? resolvePort(address.port) : reject(new Error("no port"))))
})
})
}
async function targets(port: number) {
return fetch(`http://127.0.0.1:${port}/json`)
.then((r) => r.json() as Promise<{ type: string; url: string; webSocketDebuggerUrl: string }[]>)
.catch(() => [] as { type: string; url: string; webSocketDebuggerUrl: string }[])
}
async function waitFor<T>(fn: () => Promise<T | undefined>, timeout: number) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const value = await fn()
if (value) return value
await sleep(25)
}
throw new Error("Timed out waiting for the renderer debug target")
}
async function connect(url: string) {
const ws = new WebSocket(url)
await new Promise((r) => (ws.onopen = r))
let id = 0
const pending = new Map<number, (v: any) => void>()
const events: any[] = []
ws.onmessage = (ev) => {
const msg = JSON.parse(String(ev.data))
if (msg.method) events.push(msg)
if (msg.id && pending.has(msg.id)) {
pending.get(msg.id)!(msg)
pending.delete(msg.id)
}
}
return {
events,
send: (method: string, params: Record<string, unknown> = {}) =>
new Promise<any>((resolvePromise) => {
const n = ++id
pending.set(n, resolvePromise)
ws.send(JSON.stringify({ id: n, method, params }))
}),
close: () => ws.close(),
}
}
// Attach to the main process while it is paused on its first statement, start sampling, release it.
async function profileMain(spawnAt: number) {
const target = await waitFor(() => targets(inspectPort).then((list) => list.find((t) => t.type === "node")), 30_000)
const cdp = await connect(target.webSocketDebuggerUrl)
await cdp.send("Runtime.enable")
await cdp.send("Debugger.enable")
await cdp.send("Profiler.enable")
await cdp.send("Profiler.setSamplingInterval", { interval: 200 })
await cdp.send("Profiler.start")
await cdp.send("Runtime.runIfWaitingForDebugger")
const until = Date.now() + 3000
while (!cdp.events.some((e) => e.method === "Debugger.paused") && Date.now() < until) await sleep(10)
await cdp.send("Debugger.resume")
console.log(`debugger released at +${Date.now() - spawnAt} ms`)
await sleep(8000)
const result = await cdp.send("Profiler.stop")
cdp.close()
return result.result.profile
}
// Every timestamped line of the run's log directory, merged across the main log and the scoped logs
// (crash, onboarding, window, …) that electron-log writes next to it.
function mainLog() {
const dirs = existsSync(paths.logs) ? readdirSync(paths.logs).sort().reverse() : []
const dir = dirs.map((d) => join(paths.logs, d)).find((d) => existsSync(join(d, "main.log")))
const timeline: [number, string, string][] = []
let windowShownAt: number | undefined
// Epoch marks the entry module recorded before any logger existed, reported with "app starting".
const marks: Record<string, number> = {}
for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) {
const text = readFileSync(join(dir!, name), "utf8")
// electron-log wraps long objects onto continuation lines; read them as part of the entry.
for (const entry of text.split(/\r?\n(?=\[\d{4}-)/)) {
const line = entry.split(/\r?\n/)[0]
const m = line.match(/^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\]\s+\[\w+\]\s+(?:\([\w-]+\)\s+)?(.*)$/)
if (!m) continue
const message = m[2].replace(/\s*\{.*$/, "").trim()
// A window shown before the logger existed reports when it was shown; the line itself is later.
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
if (shown) windowShownAt = Number(shown)
if (/app starting/.test(message))
for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value)
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
}
}
timeline.sort((a, b) => a[0] - b[0])
const at = (pattern: RegExp) => timeline.find(([, , message]) => pattern.test(message))?.[0]
return {
timeline,
appStarting: at(/app starting/),
versionStart: at(/v2 CLI command started/),
versionDone: at(/v2 CLI command completed/),
serviceStarting: at(/v2 CLI background service starting/),
serviceReady: at(/background service ready/),
windowVisible: windowShownAt ?? marks.window ?? at(/main window visible/),
marks,
}
}
// Working set of every process in the launched app's tree once it is idle.
// Windows places a window launched from a background process behind the foreground one, and
// Chromium then marks it occluded and the renderer stops painting, so paint timings would depend on
// what else is on screen. A helper started before the app polls for its main window and pins it
// topmost without activating it, so the user keeps their focus and the bench window is visible.
async function windowRaiser(): Promise<{ raise: (pid: number) => void; screen: () => Promise<number[]> }> {
if (process.platform !== "win32") return { raise: () => {}, screen: async () => [] }
const script = join(outDir, "raise-window.ps1")
writeFileSync(
script,
[
`Add-Type -AssemblyName System.Drawing`,
`Add-Type -Namespace Bench -Name User32 -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int z, uint f); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L; public int T; public int R; public int B; } [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);'`,
`[Console]::Out.WriteLine("ready")`,
`$target = [int][Console]::In.ReadLine()`,
`$clock = [Diagnostics.Stopwatch]::StartNew()`,
`$h = 0`,
`for ($i = 0; $i -lt 600; $i++) {`,
` $h = (Get-Process -Id $target -ErrorAction SilentlyContinue).MainWindowHandle`,
` if ($h -and $h -ne 0) { [Bench.User32]::SetWindowPos($h, [IntPtr](-1), 0, 0, 0, 0, 0x13) | Out-Null; [Console]::Out.WriteLine("raised " + $clock.ElapsedMilliseconds); break }`,
` Start-Sleep -Milliseconds 10`,
`}`,
`if (-not $h -or $h -eq 0) { exit }`,
// Sample a 16x16 grid of pixels inside the window: cheap, and enough to tell the background
// colour, a splash and the interface apart.
`$r = New-Object Bench.User32+RECT`,
`while ($clock.ElapsedMilliseconds -lt 3000) {`,
` [Bench.User32]::GetWindowRect($h, [ref]$r) | Out-Null`,
` $w = $r.R - $r.L; $ht = $r.B - $r.T`,
` if ($w -le 48 -or $ht -le 48) { Start-Sleep -Milliseconds 30; continue }`,
` $t = $clock.ElapsedMilliseconds`,
` $bmp = New-Object System.Drawing.Bitmap $w, $ht`,
` $g = [System.Drawing.Graphics]::FromImage($bmp)`,
` try { $g.CopyFromScreen($r.L, $r.T, 0, 0, $bmp.Size) } catch { $g.Dispose(); $bmp.Dispose(); Start-Sleep -Milliseconds 30; continue }`,
` $sum = 0`,
` for ($i = 1; $i -le 16; $i++) { for ($j = 1; $j -le 16; $j++) { $p = $bmp.GetPixel([int]($w * $j / 17), [int]($ht * $i / 17)); $sum += [int]$p.R + [int]$p.G + [int]$p.B } }`,
` [Console]::Out.WriteLine("screen " + $t + " " + $sum)`,
` if ($env:BENCH_SCREEN_DUMP) { $bmp.Save((Join-Path $env:BENCH_SCREEN_DUMP ("screen-" + $t.ToString().PadLeft(4, "0") + ".png"))) }`,
` $g.Dispose(); $bmp.Dispose()`,
` Start-Sleep -Milliseconds 30`,
`}`,
].join("\n"),
)
const helper = spawn("pwsh", ["-NoProfile", "-NonInteractive", "-File", script], { stdio: ["pipe", "pipe", "pipe"] })
const lines: string[] = []
let buffer = ""
await new Promise<void>((resolve) => {
helper.stdout!.on("data", (chunk: Buffer) => {
buffer += chunk.toString()
const parts = buffer.split(/\r?\n/)
buffer = parts.pop() ?? ""
for (const line of parts) {
if (line === "ready") resolve()
else lines.push(line)
}
})
helper.stderr!.on("data", (chunk: Buffer) => console.error(`raise-window: ${chunk.toString().trim()}`))
helper.on("exit", () => resolve())
})
const exited = new Promise<void>((resolve) => helper.on("exit", () => resolve()))
return {
raise: (pid) => helper.stdin!.write(`${pid}\n`),
// Resolves with the times (ms since the pid was sent, ~spawn) at which the sampled screen
// content differed from the first sample, i.e. when something other than the background colour
// was on screen.
screen: async () => {
await exited
const samples = lines
.filter((line) => line.startsWith("screen "))
.map((line) => line.split(" ").slice(1).map(Number) as [number, number])
if (process.env.BENCH_DEBUG) console.log(lines.filter((line) => line.startsWith("raised")).join(" "), `${samples.length} screen samples`)
const first = samples[0]?.[1]
const last = samples.at(-1)?.[1]
const differs = (a: number, b: number) => Math.abs(a - b) > 16 * 16 * 12
const changed = samples.filter(([, sum]) => differs(sum, first)).map(([t]) => t)
// The last sample that still differed from the final content, i.e. when the window stopped changing.
const settledIndex = samples.findLastIndex(([, sum]) => last !== undefined && differs(sum, last))
return { changed, settled: settledIndex >= 0 ? samples[settledIndex + 1]?.[0] : samples[0]?.[0] }
},
}
}
async function processTree(root: number) {
const script =
process.platform === "win32"
? [
"powershell",
[
"-NoProfile",
"-Command",
`$all = Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, Name, WorkingSetSize, CommandLine; $ids = @(${root}); do { $before = $ids.Count; $ids = @($ids + ($all | Where-Object { $ids -contains $_.ParentProcessId } | ForEach-Object ProcessId) | Sort-Object -Unique) } while ($ids.Count -ne $before); $all | Where-Object { $ids -contains $_.ProcessId } | ForEach-Object { '{0}|{1}|{2}|{3}' -f $_.ProcessId, $_.Name, $_.WorkingSetSize, (($_.CommandLine -split ' ' | Where-Object { $_ -like '--type=*' }) -join '') } `,
],
]
: ["sh", ["-c", `ps -eo pid=,ppid=,rss=,comm= | awk -v r=${root} 'BEGIN{ids[r]=1} {p[$1]=$2; rss[$1]=$3; c[$1]=$4} END{for(k=0;k<8;k++) for(i in p) if(p[i] in ids) ids[i]=1; for(i in ids) if(i in rss) print i "|" c[i] "|" rss[i]*1024 "|"}'`]]
const out = await new Promise<string>((done) => {
const child = spawn(script[0] as string, script[1] as string[], { stdio: ["ignore", "pipe", "ignore"] })
let text = ""
child.stdout?.on("data", (chunk) => (text += chunk))
child.on("close", () => done(text))
})
return out
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
const [pid, name, rss, type] = line.split("|")
return { pid: Number(pid), name: `${name}${type ? ` ${type.replace("--type=", "")}` : ""}`, rssMB: Math.round(Number(rss) / 1048576) }
})
}
function summarize(list: Sample[]) {
const values = (s: Sample): Record<string, number | undefined> => ({
...s.msSinceSpawn,
rendererTaskMs: s.rendererCpu.taskMs,
rendererScriptMs: s.rendererCpu.scriptMs,
})
const keys = [...new Set(list.flatMap((s) => Object.keys(values(s))))]
const out: Record<string, { median: number; min: number; max: number }> = {}
for (const key of keys) {
const sorted = list.map((s) => values(s)[key]).filter((v): v is number => Number.isFinite(v)).sort((a, b) => a - b)
if (!sorted.length) continue
out[key] = { median: sorted[Math.floor(sorted.length / 2)], min: sorted[0], max: sorted[sorted.length - 1] }
}
return out
}
// The service must come from the CLI bundled with the executable: the desktop restarts a service
// whose version differs from its bundled CLI, which would turn a warm run into a cold one.
function bundledCli(exe: string) {
const resources = process.platform === "darwin" ? join(dirname(exe), "..", "Resources") : join(dirname(exe), "resources")
return join(resources, process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli")
}
async function warmService() {
await stopService()
const clis = builds.map((build) => bundledCli(build.exe))
const identity = (cli: string) => {
const version = join(dirname(cli), "opencode-cli.version")
return existsSync(version) ? readFileSync(version, "utf8").trim() : String(statSync(cli).size)
}
if (new Set(clis.map(identity)).size > 1)
throw new Error("The compared builds bundle different CLIs; the desktop would restart the service on the mismatch")
serviceProcess = spawn(clis[0], ["serve", "--service"], { env, detached: true, stdio: "ignore" })
serviceProcess.unref()
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
if (existsSync(paths.registration)) {
const registration = JSON.parse(readFileSync(paths.registration, "utf8")) as { url?: string }
if (registration.url && (await fetch(`${registration.url}/api/info`).then((r) => r.status < 500).catch(() => false))) {
console.log(`service warm at ${registration.url}`)
return
}
}
await sleep(200)
}
throw new Error("The bench service did not become ready")
}
async function stopService() {
if (existsSync(paths.registration)) {
const registration = JSON.parse(readFileSync(paths.registration, "utf8")) as { pid?: number }
if (registration.pid) {
try {
process.kill(registration.pid)
} catch {}
}
rmSync(paths.registration, { force: true })
}
if (serviceProcess?.pid) {
try {
process.kill(serviceProcess.pid)
} catch {}
serviceProcess = undefined
}
await sleep(500)
}
// Only ever touches the process this run spawned (a prod-channel build shares its executable name
// with the developer's installed app). Ask it to quit first so it exits the way a user's session
// ends (Node and Chromium flush their caches on a normal exit); force-kill the tree if it lingers.
async function killApp() {
const pid = appPid
if (!pid) return
appPid = undefined
const running = () =>
new Promise<boolean>((done) => {
const check =
process.platform === "win32"
? spawn("tasklist", ["/FI", `PID eq ${pid}`, "/NH"], { stdio: ["ignore", "pipe", "ignore"] })
: spawn("kill", ["-0", String(pid)], { stdio: "ignore" })
let out = ""
check.stdout?.on("data", (chunk) => (out += chunk))
check.on("close", (code) => done(process.platform === "win32" ? out.includes(String(pid)) : code === 0))
})
if (!(await running())) return
if (process.platform === "win32") spawn("taskkill", ["/PID", String(pid)], { stdio: "ignore" })
else process.kill(pid, "SIGTERM")
const deadline = Date.now() + 5000
while (Date.now() < deadline && (await running())) await sleep(100)
if (await running()) {
if (process.platform === "win32") spawn("taskkill", ["/PID", String(pid), "/F", "/T"], { stdio: "ignore" })
else process.kill(pid, "SIGKILL")
await sleep(1000)
}
await sleep(500)
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms))
}
+16 -8
View File
@@ -1,7 +1,7 @@
import { $ } from "bun"
import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { dirname, join } from "node:path"
const CLI_VERSION = "dev"
@@ -75,10 +75,7 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyCliToResources(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"),
dest,
)
await copyCliToResources(join(directory, "node_modules", cli.package), dest)
} finally {
await rm(directory, { recursive: true, force: true })
}
@@ -89,12 +86,23 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
export async function copyBuiltCliToResources(root: string, dest = windowsify("resources/opencode-cli")) {
const cli = getCurrentCli()
const directory = cli.package.replace("@opencode/", "")
await copyCliToResources(join(root, directory, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"), dest)
await copyCliToResources(join(root, directory), dest)
}
async function copyCliToResources(source: string, dest: string) {
await copyFile(source, dest)
// The package directory is an npm package: its package.json version is the string the executable
// prints for --version. Writing it next to the executable spares the desktop a ~400 ms spawn of the
// 200 MB binary on first launch.
async function copyCliToResources(pkg: string, dest: string) {
const cli = getCurrentCli()
await copyFile(join(pkg, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"), dest)
await prepareCli(dest)
const manifest = (await Bun.file(join(pkg, "package.json")).json()) as { version?: string }
if (!manifest.version) throw new Error(`Bundled CLI package has no version: ${pkg}`)
await Bun.write(versionFile(dest), manifest.version)
}
export function versionFile(cli: string) {
return join(dirname(cli), "opencode-cli.version")
}
async function prepareCli(dest: string) {
+14
View File
@@ -6,3 +6,17 @@ export const CHANNEL: Channel = raw === "local" || raw === "dev" || raw === "bet
export const VERSION = app.isPackaged ? app.getVersion() : (process.env.OPENCODE_VERSION ?? app.getVersion())
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
const appNames: Record<string, string> = {
dev: "OpenCode Dev",
beta: "OpenCode Beta",
prod: "OpenCode",
}
const appIDs: Record<string, string> = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
}
// Local renderer/server mode keeps the dev application identity.
export const APP_NAME = app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev"
export const APP_ID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
+6
View File
@@ -3,15 +3,21 @@ import { app } from "electron"
import { Effect, Layer } from "effect"
import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { installContextMenu } from "./lifecycle/environment"
import { ApplicationLifecycle } from "./lifecycle"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
import { marks } from "./lifecycle/marks"
// Everything above has been loaded and evaluated by now; the layers start below.
marks.bundle = Date.now()
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
yield* installContextMenu
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
+22 -2
View File
@@ -1,3 +1,23 @@
export {}
// Imported first so its evaluation stamps the moment Electron handed control to this module.
import { marks } from "./lifecycle/marks"
import { app } from "electron"
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
import { createEarlyWindow } from "./windows/early"
import { registerRendererScheme } from "./windows/scheme"
await import("./desktop")
// This module stays small on purpose. Electron holds the ready event until the entry module has
// finished, and the first window should be on screen before the rest of the main process — a few
// hundred milliseconds of module evaluation and layers — loads. Configuration and the scheme must
// precede ready; the window is created the moment ready fires; everything else is imported after.
configureApplication()
if (acquireApplicationLock()) {
registerRendererScheme()
// Window first, then the bundle: starting the import before ready delays ready itself, because the
// module graph evaluates on the same thread Chromium needs to finish initialising.
void app.whenReady().then(() => {
marks.ready = Date.now()
createEarlyWindow()
marks.window = Date.now()
return import("./desktop")
})
}
@@ -1,7 +1,6 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { createBrowserPane } from "../browser-pane"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
@@ -14,8 +13,16 @@ export const eventHandlers = EventRpcs.toLayer(
const handoff = yield* IpcPortHandoff
const shutdown = yield* Shutdown.Service
const storage = yield* DesktopStorage.Service
const browser = createBrowserPane(storage.state)
const stop = Effect.promise(() => browser.dispose())
// The browser pane brings the CDP driver and the full RPC client with every protocol schema;
// load it when a renderer first opens a pane instead of at startup.
const load = async () => {
const { createBrowserPane } = await import("../browser-pane")
return createBrowserPane(storage.state)
}
let browser: ReturnType<typeof load> | undefined
const stop = Effect.promise(async () => {
if (browser) await (await browser).dispose()
})
const remove = yield* shutdown.add(stop)
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
return EventRpcs.of({
@@ -27,10 +34,12 @@ export const eventHandlers = EventRpcs.toLayer(
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
throw new Error("browser.pane.owner.invalid")
}
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
return browser.close(win, request.bindingID)
browser ??= load()
const pane = await browser
if (request.type === "register") return pane.register(win, request.bindingID, request.target)
if (request.type === "layout") return pane.layout(win, request.bindingID, request.layout)
if (request.type === "command") return pane.command(win, request.bindingID, request.command)
return pane.close(win, request.bindingID)
}).pipe(Effect.orDie),
})
}),
@@ -0,0 +1,58 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import { homedir, tmpdir } from "node:os"
import path from "node:path"
import { app } from "electron"
import { APP_ID, APP_NAME } from "../constants"
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
// Runs synchronously from the entry module, before Chromium is ready: command-line switches only
// take effect before ready, the single-instance lock is scoped to userData, and the early window
// needs userData to find the persisted window list and state.
export function configureApplication() {
try {
process.chdir(homedir())
} catch {}
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
app.setName(APP_NAME)
app.setAppUserModelId(APP_ID)
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged)
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
const testRoot = createTestRoot()
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), APP_ID))
if (testRoot) {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
}
export function acquireApplicationLock() {
if (app.requestSingleInstanceLock()) return true
app.quit()
return false
}
function createTestRoot() {
const root = testOnboarding
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
: app.isPackaged
? undefined
: process.env.OPENCODE_DESKTOP_TEST_ROOT
if (!root) return undefined
if (testOnboarding) rmSync(root, { recursive: true, force: true })
for (const dir of ["data", "config", "cache", "state", "desktop", "session", "documents"])
mkdirSync(path.join(root, dir), { recursive: true })
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
process.env.XDG_DATA_HOME = path.join(root, "data")
process.env.XDG_CONFIG_HOME = path.join(root, "config")
process.env.XDG_CACHE_HOME = path.join(root, "cache")
process.env.XDG_STATE_HOME = path.join(root, "state")
return root
}
@@ -1,59 +1,18 @@
import { randomUUID } from "node:crypto"
import http from "node:http"
import { homedir, tmpdir } from "node:os"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import { app } from "electron"
import contextMenu from "electron-context-menu"
import { Effect, FileSystem, Path } from "effect"
import { CHANNEL } from "../constants"
import { Effect, Path } from "effect"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { registerRendererProtocol, setDockIcon } from "../windows"
const appNames: Record<string, string> = {
dev: "OpenCode Dev",
beta: "OpenCode Beta",
prod: "OpenCode",
}
const appIDs: Record<string, string> = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
}
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
export const configureApplication = Effect.fn("Application.configure")(function* () {
const path = yield* Path.Path
// electron-context-menu attaches to every existing and future window, so it can load once the first
// window is up instead of holding up startup with its dependency tree.
export const installContextMenu = Effect.gen(function* () {
const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu"))
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
try {
process.chdir(homedir())
} catch {}
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
app.setAppUserModelId(appID)
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged)
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
const testRoot = yield* createTestRoot()
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
if (testRoot) {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
})
export function acquireApplicationLock() {
if (app.requestSingleInstanceLock()) return true
app.quit()
return false
}
export const prepareApplicationEnvironment = Effect.gen(function* () {
yield* loadSystemCertificates
yield* loadProxyEnvironment
@@ -91,29 +50,6 @@ export const loadProxyEnvironment = Effect.gen(function* () {
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error })))
})
const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const root = testOnboarding
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
: app.isPackaged
? undefined
: process.env.OPENCODE_DESKTOP_TEST_ROOT
if (!root) return undefined
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
yield* Effect.forEach(
["data", "config", "cache", "state", "desktop", "session", "documents"],
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
{ discard: true },
)
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
process.env.XDG_DATA_HOME = path.join(root, "data")
process.env.XDG_CONFIG_HOME = path.join(root, "config")
process.env.XDG_CACHE_HOME = path.join(root, "cache")
process.env.XDG_STATE_HOME = path.join(root, "state")
return root
})
const loadSystemCertificates = Effect.try({
try: () => {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
+1 -5
View File
@@ -9,7 +9,6 @@ import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { acquireApplicationLock, configureApplication } from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
@@ -145,7 +144,7 @@ const runtime = Layer.effect(
}),
)
// Storage opens after configureApplication has set userData and before windows exist, so window
// Storage opens after the entry module has set userData and before the renderer loads, so window
// teardown can clear a window's persisted state and every renderer request finds it ready.
const platform = Layer.mergeAll(
DesktopLogging.layer,
@@ -155,9 +154,6 @@ const platform = Layer.mergeAll(
export const layer = Layer.unwrap(
Effect.gen(function* () {
// Electron scopes the single-instance lock to userData.
yield* configureApplication()
if (!acquireApplicationLock()) return yield* Effect.interrupt
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
// otherwise read as evidence of an earlier launch on a fresh install.
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
@@ -0,0 +1,4 @@
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
// layer reports them with "app starting" so the startup benchmark can split the time before the
// first log line into Electron's own initialisation, our entry, and the main bundle.
export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() }
@@ -5,6 +5,7 @@ import { app, crashReporter, netLog, shell } from "electron"
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References, Stream } from "effect"
import { homedir } from "node:os"
import { VERSION } from "../constants"
import { marks } from "../lifecycle/marks"
const MAX_LOG_AGE_DAYS = 7
const TAIL_LINES = 1000
@@ -34,6 +35,7 @@ const serviceLayer = Layer.effect(
version: VERSION,
packaged: app.isPackaged,
onboardingTest: process.env.OPENCODE_TEST_ONBOARDING === "1",
marks,
})
const exportDebug = exportDebugLogsEffect(fs, path).pipe(Effect.orDie)
return Service.of({
@@ -1,15 +1,57 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { Deferred, Effect, Fiber } from "effect"
import { BackgroundServiceState } from "./background-service-state"
test("new consumers receive the latest reconnected service", async () => {
const initial = { url: "http://127.0.0.1:4100", password: "first" }
const replacement = { url: "http://127.0.0.1:4200", password: "second" }
const service = await Effect.runPromise(
BackgroundServiceState.make({ initial: Effect.succeed(initial), reconnect: Effect.succeed(replacement) }),
Effect.scoped(
Effect.gen(function* () {
const state = yield* BackgroundServiceState.make({
initial: Effect.succeed(initial),
reconnect: Effect.succeed(replacement),
})
expect(yield* state.connection).toEqual(initial)
expect(yield* state.reconnect).toEqual(replacement)
return yield* state.connection
}),
),
)
expect(service).toEqual(replacement)
})
expect(await Effect.runPromise(service.connection)).toEqual(initial)
expect(await Effect.runPromise(service.reconnect)).toEqual(replacement)
expect(await Effect.runPromise(service.connection)).toEqual(replacement)
test("the state is ready before the initial connect finishes and consumers wait for it", async () => {
const initial = { url: "http://127.0.0.1:4100", password: "first" }
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const state = yield* BackgroundServiceState.make({
initial: Deferred.await(gate).pipe(Effect.as(initial)),
reconnect: Effect.succeed(initial),
})
const consumer = yield* Effect.forkChild(state.connection)
yield* Effect.sleep("10 millis")
expect(consumer.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(gate, undefined)
expect(yield* Fiber.join(consumer)).toEqual(initial)
}),
),
)
})
test("a failed initial connect fails consumers instead of hanging them", async () => {
const result = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* BackgroundServiceState.make({
initial: Effect.fail(new Error("service unavailable")),
reconnect: Effect.succeed({ url: "http://127.0.0.1:4100", password: "later" }),
})
return yield* state.connection.pipe(Effect.exit)
}),
),
)
expect(result._tag).toBe("Failure")
})
@@ -1,16 +1,30 @@
export * as BackgroundServiceState from "./background-service-state"
import { Effect, Exit, Ref } from "effect"
import { Deferred, Effect, Exit, Ref } from "effect"
import type { SidecarCredentials } from "./sidecar-credentials"
// The initial connect runs in the background: windows are created as soon as the main process
// is up, and the renderer loads its bundle while the CLI service is being resolved. Consumers
// await the first outcome through `connection`, so nothing observes the service before it exists.
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
readonly initial: Effect.Effect<SidecarCredentials.Data, unknown>
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
}) {
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown>>(yield* options.initial.pipe(Effect.exit))
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown> | undefined>(undefined)
const first = yield* Deferred.make<void>()
yield* options.initial.pipe(
Effect.exit,
Effect.flatMap((exit) => Ref.set(current, exit)),
Effect.ensuring(Deferred.succeed(first, undefined)),
Effect.forkScoped,
)
return {
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
connection: Deferred.await(first).pipe(
Effect.flatMap(() => Ref.get(current)),
Effect.flatMap((exit) => exit ?? Exit.die(new Error("background service connect did not resolve"))),
Effect.orDie,
),
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
}
})
@@ -3,9 +3,11 @@ export * as DesktopCli from "./desktop-cli"
import { execFile, spawn } from "node:child_process"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { parseCliVersion } from "./cli-version"
const execFileAsync = promisify(execFile)
@@ -79,11 +81,46 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
? path.join(process.resourcesPath, executableName())
: path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseCliVersion(yield* run(bundled, ["--version"]))
const version = yield* bundledVersion(bundled)
const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled
return { version, binary, command: [binary] }
})
// Spawning the bundled executable for `--version` costs ~400 ms of startup on a 200 MB binary (and
// several seconds on the first launch after an update, while the antivirus scans it). The build
// writes the version next to the executable, so a packaged app never spawns; the per-identity cache
// covers executables that arrived without that file.
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const shipped = yield* fs
.readFileString(path.join(path.dirname(bundled), "opencode-cli.version"))
.pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => ""))
if (shipped) {
yield* Effect.logInfo("v2 CLI version bundled", { version: shipped })
return shipped
}
const stat = yield* fs.stat(bundled).pipe(Effect.orElseSucceed(() => undefined))
const identity = stat ? `${stat.size}:${Option.getOrUndefined(stat.mtime)?.getTime() ?? ""}` : undefined
const store = getStore()
const cached = store.get(BUNDLED_CLI_VERSION_KEY)
if (identity && isVersionCache(cached) && cached.path === bundled && cached.identity === identity) {
yield* Effect.logInfo("v2 CLI version reused", { version: cached.version })
return cached.version
}
const version = parseCliVersion(yield* run(bundled, ["--version"]))
if (identity) store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies VersionCache)
return version
})
type VersionCache = { path: string; identity: string; version: string }
function isVersionCache(value: unknown): value is VersionCache {
if (!value || typeof value !== "object") return false
const cache = value as Record<string, unknown>
return typeof cache.path === "string" && typeof cache.identity === "string" && typeof cache.version === "string"
}
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
@@ -17,17 +17,29 @@ describe("draft store", () => {
expect(database.db.all(sql`SELECT key FROM document`)).toEqual([])
})
test("stores blobs by content hash and collects unreferenced ones on open", () => {
test("stores blobs by content hash and collects unreferenced ones shortly after open", async () => {
const database = openDatabase(":memory:")
const first = createDraftStore(database.db, { delay: 1_000 })
let clock = 0
const first = createDraftStore(database.db, { delay: 1_000, now: () => clock })
const used = first.putBlob(new Uint8Array([1, 2, 3]))
const unused = first.putBlob(new Uint8Array([4, 5, 6]))
expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used)
first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] }))
first.flush()
const second = createDraftStore(database.db, { delay: 1_000 })
expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
expect(second.getBlob(unused)).toBeNull()
first.close()
// The startup collection runs off the window's critical path and keeps the usual grace, so a
// blob from a session that ended within the grace period is still there...
const second = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
await new Promise((resolve) => setTimeout(resolve, 5))
expect(second.getBlob(unused)).toEqual(new Uint8Array([4, 5, 6]))
second.close()
// ...and gone once the grace has passed.
clock = blobGrace + 1
const third = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
await new Promise((resolve) => setTimeout(resolve, 5))
expect(third.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
expect(third.getBlob(unused)).toBeNull()
third.close()
})
test("collects a retired chunk only once it is unreferenced and past the grace period", () => {
+10 -4
View File
@@ -27,11 +27,14 @@ const referenced = (value: unknown) => sql`
export function createDraftStore(
db: Database,
input: { delay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
input: { delay?: number; collectDelay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
) {
const now = input.now ?? Date.now
// Nothing outside this process can hold a blob id at startup, so no grace applies.
collectBlobs(db, Infinity)
// Orphans left by an earlier session are collected once the window is up rather than before it:
// the scan walks every stored document, and the usual grace keeps anything a renderer has
// uploaded in the meantime.
const startup = setTimeout(() => collectBlobs(db, now() - blobGrace), input.collectDelay ?? 10_000)
startup.unref()
let collected = now()
let orphans = false
const byKey = eq(document.key, sql.placeholder("key"))
@@ -104,7 +107,10 @@ export function createDraftStore(
return data ? (data as Uint8Array<ArrayBuffer>) : null
},
flush: writer.flush,
close: writer.close,
close() {
clearTimeout(startup)
writer.close()
},
}
}

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