Compare commits

...
Author SHA1 Message Date
Kit Langton 57d70a3edf refactor(plugin): narrow cross-instance schema fix 2026-08-23 22:40:16 -04:00
Kit Langton a5c2458a5b fix(plugin): keep tool schema validation in the authoring effect instance
Plugins load their own copy of effect, so their live Effect schemas cannot be
interpreted by the host instance: parser sentinels and AST class identity are
per-instance, making checks false-fail on valid input (bogus minLength errors)
and branded IDs die as defects surfaced as bare 'Tool execution failed'.

Plugin.define now converts tool input/output schemas to detached Standard
Schema wrappers at the draft.add boundary, so validation and JSON Schema
generation run as closures bound to the instance that created the schema. The
core tool runtime detects still-foreign live schemas from older plugin
packages and skips validation with a warning instead of misvalidating, and
standard-schema validation errors now include the issue path.
2026-08-23 20:24:08 -04:00
usrnk1andLukeParkerDev d3e5d6d268 feat(desktop): add model change notices (#43288)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-08-24 00:17:35 +00:00
Kit Langton f9bc948912 fix(core): skip local liveness check for workspace locations (#44560) 2026-08-23 20:17:04 -04:00
opencode-agent[bot] 6f238feeff chore: update nix node_modules hashes 2026-08-24 00:07:29 +00:00
Kit Langton e867a21ea5 feat(core): decouple workspace identity from provisioning (#44526) 2026-08-23 23:49:51 +00:00
Luke Parker 64c0411edb fix(session-ui): align tool error card with figma (#44543) 2026-08-23 23:31:45 +00:00
40 changed files with 1194 additions and 297 deletions
+1 -36
View File
@@ -671,6 +671,7 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
@@ -3217,12 +3218,6 @@
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/cli-config": ["@vercel/cli-config@0.2.3", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A=="],
"@vercel/cli-exec": ["@vercel/cli-exec@1.0.1", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ=="],
"@vercel/functions": ["@vercel/functions@3.9.3", "", { "dependencies": { "@vercel/oidc": "3.8.4" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -3981,8 +3976,6 @@
"eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
@@ -4257,8 +4250,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -4623,8 +4614,6 @@
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"mermaid": ["mermaid@11.16.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g=="],
@@ -4709,8 +4698,6 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
@@ -4827,8 +4814,6 @@
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -4853,8 +4838,6 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
@@ -4869,8 +4852,6 @@
"opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
"os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="],
"own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="],
"oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="],
@@ -5453,8 +5434,6 @@
"strip-comments": ["strip-comments@2.0.1", "", {}, "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"stripe": ["stripe@18.0.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA=="],
@@ -5847,10 +5826,6 @@
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
@@ -6423,10 +6398,6 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.4", "", { "dependencies": { "@vercel/cli-config": "0.2.3", "@vercel/cli-exec": "1.0.1", "jose": "^5.9.6" } }, "sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA=="],
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitejs/plugin-react/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
@@ -6565,10 +6536,6 @@
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
@@ -7325,8 +7292,6 @@
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-pnyqHqM8nt1Dh/d6xRm8DploZG9PiaO3HWGVIFyew54=",
"aarch64-linux": "sha256-cWPfoDku1bFN7zwCy6rsLP/WMmp6hEpO3Ous3FPGcfo=",
"aarch64-darwin": "sha256-2S3opWupuF3AncBbZLd5B7IsrUxYxA4S1mKWy58gMlo=",
"x86_64-darwin": "sha256-fu6WCBh9CWqsk8ACvLdeleP55uZ08i8MfEPFqgt7ob0="
"x86_64-linux": "sha256-mMg7Y8ZTL+SEcX6ciNBDL+H9UT9c85EM/pZmIcsoFtA=",
"aarch64-linux": "sha256-9JtD/rwpmF9hD9MPNLu/fMx8mQu1vpOMLYRN+nHq/eI=",
"aarch64-darwin": "sha256-wLtkY3d5o88bfPriChTCSmUtEnYR6PKj86qQxNqDVOE=",
"x86_64-darwin": "sha256-9bqDFh7YGNK94oRDo9xp0l5r0H17g1aUtnaVPTfFEOE="
}
}
@@ -899,8 +899,26 @@ function provider() {
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
{
id: "company-gateway",
name: "Company Gateway",
models: {
"fast-nano": {
id: "fast-nano",
api: { id: "openai/gpt-5.4-nano" },
name: "GPT-5.4 nano",
limit: { context: 128_000 },
},
"long-context": {
id: "long-context",
api: { id: "company/long-context" },
name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts",
limit: { context: 128_000 },
},
},
},
],
connected: ["opencode"],
connected: ["opencode", "company-gateway"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
@@ -250,6 +250,43 @@ test.describe("session timeline projection", () => {
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
sessionMessages: [
{
id: "msg_model_fast_nano",
type: "model-switched",
time: { created: 1700000000000 },
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
},
{
id: "msg_model_long_context",
type: "model-switched",
time: { created: 1700000001000 },
model: { providerID: "company-gateway", id: "long-context" },
},
userMessage(),
assistantMessage(),
],
})
const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(shortNotice).toBeVisible()
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice).toBeVisible()
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
+1 -1
View File
@@ -501,7 +501,7 @@ function currentModels(value: unknown) {
return [
{
id: model.id,
modelID: model.id,
modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
@@ -1,6 +1,7 @@
import { DataProvider } from "@opencode-ai/session-ui/context"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, type ParentProps } from "solid-js"
import { useProviders } from "@/providers/catalog/providers"
import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
@@ -30,7 +31,11 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const providers = useProviders(directory)
const sessionUIData = createMemo(() => ({
provider: providers.ready()
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
: undefined,
session: data.session.list(),
session_status: Object.fromEntries(
data.session
+3 -3
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "3fb67508-0196-4bae-b2bd-c08ece7583fd",
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"ddl": [
{
"name": "account_state",
@@ -1442,7 +1442,7 @@
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
+2
View File
@@ -44,6 +44,7 @@ import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
export const migrations = [
m00,
@@ -91,4 +92,5 @@ export const migrations = [
m42,
m43,
m44,
m45,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,27 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260823191254_nullable_workspace_binding",
foreignKeys: false,
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`__new_workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text,
\`created_at\` integer NOT NULL,
\`last_used_at\` integer NOT NULL
);
`)
yield* tx.run(
`INSERT INTO \`__new_workspace\`(\`id\`, \`provider\`, \`binding\`, \`created_at\`, \`last_used_at\`) SELECT \`id\`, \`provider\`, \`binding\`, \`created_at\`, \`last_used_at\` FROM \`workspace\`;`,
)
yield* tx.run(`DROP TABLE \`workspace\`;`)
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
})
},
}
export default migration
+1 -1
View File
@@ -223,7 +223,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text NOT NULL,
\`binding\` text,
\`created_at\` integer NOT NULL,
\`last_used_at\` integer NOT NULL
);
+21 -17
View File
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Clock, Context, Deferred, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
@@ -61,7 +61,7 @@ export const ripgrepLayer = Layer.effect(
let index = emptyIndex()
let initialized = false
let settledAt = Number.NEGATIVE_INFINITY
let refreshing = false
let refreshing: Deferred.Deferred<void> | undefined
const scan = Effect.gen(function* () {
const next = emptyIndex()
const previous = index
@@ -84,21 +84,25 @@ export const ripgrepLayer = Layer.effect(
})
index = next
initialized = true
}).pipe(
Effect.orDie,
Effect.ensuring(
Effect.sync(() => {
settledAt = clock.currentTimeMillisUnsafe()
refreshing = false
}),
),
)
const refresh = Effect.sync(() => {
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
refreshing = true
return scan
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
yield* refresh
}).pipe(Effect.orDie)
const refresh = Effect.suspend(() => {
if (refreshing) return initialized ? Effect.void : Deferred.await(refreshing)
if (initialized && clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return Effect.void
const attempt = Deferred.makeUnsafe<void>()
refreshing = attempt
return scan.pipe(
Effect.onExit((exit) =>
Effect.sync(() => {
settledAt = clock.currentTimeMillisUnsafe()
if (refreshing === attempt) refreshing = undefined
Deferred.doneUnsafe(attempt, exit)
}),
),
Effect.forkIn(scope),
Effect.flatMap(() => (initialized ? Effect.void : Deferred.await(attempt))),
)
})
return Service.of({
find: (input) =>
Effect.gen(function* () {
+8 -1
View File
@@ -147,7 +147,14 @@ export function buildLocationServiceMap(
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? Duration.infinity : Duration.zero) },
{
// Workspace-placed directories exist only inside the workspace, so a
// local stat consults the wrong filesystem. Workspace liveness is
// owned by placement; do not probe the sandbox here, which would
// provision lazily-idle workspaces.
idleTimeToLive: (ref) =>
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
},
),
(inner) => ({
...inner,
-4
View File
@@ -6,7 +6,6 @@ import { Global } from "@opencode-ai/util/global"
import { Effect, Stream } from "effect"
import path from "path"
import { Agent } from "../agent.js"
import { Environment } from "../environment/index.js"
import { Permission } from "../permission.js"
import { SessionEvent } from "../session/event.js"
@@ -28,12 +27,9 @@ You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply.
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
const environment = yield* Environment.Service
const global = yield* Global.Service
const directory = path.join(global.home, ".opencode", "plan")
const enterReminder = enter(directory)
yield* environment.files.mkdir(directory).pipe(Effect.orDie)
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
+121 -38
View File
@@ -3,7 +3,7 @@ export * as Workspace from "./workspace.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { eq } from "drizzle-orm"
import { Clock, Context, Duration, Effect, Exit, Layer, Ref, Schedule, Schema, Scope } from "effect"
import { Clock, Context, Deferred, Duration, Effect, Exit, FiberSet, Layer, Ref, Schedule, Schema, Scope } from "effect"
import { systemError } from "effect/PlatformError"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver as EnvironmentDriver } from "./environment/driver.js"
@@ -26,7 +26,12 @@ export class Info extends Schema.Class<Info>("Workspace.Info")({
export class NotFound extends Schema.TaggedError<NotFound>()("Workspace.NotFound", { workspaceID: ID }) {}
export interface Interface {
readonly create: (provider: string) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
/** Instantly commits a logical workspace ID. No provider work happens here. */
readonly create: (provider: string) => Effect.Effect<ID, WorkspaceDriver.ProviderNotFound>
/** Starts or joins the shared attempt that makes the backing resource real, then returns it. */
readonly provision: (
workspaceID: ID,
) => Effect.Effect<Info, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
readonly connect: (
workspaceID: ID,
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
@@ -51,6 +56,8 @@ interface Connection {
readonly scope: Scope.Closeable
}
type ReadinessError = NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound
export const configured = (options: Options = {}) =>
makeGlobalNode({
service: Service,
@@ -66,7 +73,10 @@ const layer = (options: Options) =>
const registry = yield* WorkspaceDriver.RegistryService
const lifetime = yield* Scope.Scope
const connections = new Map<ID, Connection>()
// Destroy cancels the racing provision body by settling the deferred.
const attempts = new Map<ID, Deferred.Deferred<Info, ReadinessError>>()
const locks = KeyedMutex.makeUnsafe<ID>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const idleThreshold = Duration.toMillis(options.idleThreshold ?? Duration.minutes(20))
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
@@ -80,29 +90,75 @@ const layer = (options: Options) =>
return row
})
const saveBinding = (workspaceID: ID, binding: WorkspaceDriver.Binding) =>
db.update(WorkspaceTable).set({ binding }).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
const info = (row: typeof WorkspaceTable.$inferSelect, binding: WorkspaceDriver.Binding) =>
new Info({
id: row.id,
provider: row.provider,
binding,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
})
const provision = Effect.fn("Workspace.provision")((workspaceID: ID) =>
Effect.suspend(() => {
const existing = attempts.get(workspaceID)
if (existing) return Deferred.await(existing)
const attempt = Deferred.makeUnsafe<Info, ReadinessError>()
attempts.set(workspaceID, attempt)
fork(
locks
.withLock(workspaceID)(
Effect.gen(function* () {
const row = yield* load(workspaceID)
if (row.binding) return info(row, row.binding)
const driver = yield* registry.get(row.provider)
const result = yield* driver.create({ workspaceID })
yield* saveBinding(workspaceID, result.binding)
return info(row, result.binding)
}),
)
.pipe(
Effect.raceFirst(Deferred.await(attempt)),
Effect.onExit((exit) =>
Effect.sync(() => {
if (attempts.get(workspaceID) === attempt) attempts.delete(workspaceID)
Deferred.doneUnsafe(attempt, exit)
}),
),
Effect.exit,
Effect.asVoid,
),
)
return Deferred.await(attempt)
}),
)
const open = Effect.fn("Workspace.open")(function* (workspaceID: ID) {
const existing = connections.get(workspaceID)
if (existing) return existing
const row = yield* load(workspaceID)
// Bindings are persisted before provision resolves and never nulled; a raced
// destroy deletes the whole row and surfaces as NotFound from load above.
if (!row.binding) return yield* Effect.die(`workspace ${workspaceID} has no binding after provision`)
const driver = yield* registry.get(row.provider)
const saveBinding = (value: WorkspaceDriver.Binding) =>
db
.update(WorkspaceTable)
.set({ binding: value })
.where(eq(WorkspaceTable.id, workspaceID))
.run()
.pipe(Effect.orDie)
const persistBinding = (binding: WorkspaceDriver.Binding) => saveBinding(workspaceID, binding)
const scope = yield* Scope.fork(lifetime)
const environment = yield* driver.connect({ workspaceID, binding: row.binding, saveBinding }).pipe(
Effect.provideService(Scope.Scope, scope),
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
)
const environment = yield* driver
.connect({ workspaceID, binding: row.binding, saveBinding: persistBinding })
.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
)
const now = yield* Clock.currentTimeMillis
const connection: Connection = {
driver,
environment,
saveBinding,
saveBinding: persistBinding,
lastActivity: yield* Ref.make(now),
active: yield* Ref.make(0),
scope,
@@ -129,6 +185,7 @@ const layer = (options: Options) =>
const lastActivity = yield* Ref.get(connection.lastActivity)
if (now - lastActivity < idleThreshold) return
const row = yield* load(workspaceID)
if (!row.binding) return
// Deliberate: a racing spawn blocks, then wakes cleanly. Unlocking mid-suspend could reattach a sandbox being terminated.
yield* connection.driver.suspendForIdle({
workspaceID,
@@ -151,37 +208,41 @@ const layer = (options: Options) =>
return Service.of({
create: Effect.fn("Workspace.create")(function* (provider) {
const driver = yield* registry.get(provider)
yield* registry.get(provider)
const workspaceID = ID.create()
const result = yield* driver.create({ workspaceID })
const now = yield* Clock.currentTimeMillis
yield* db
.insert(WorkspaceTable)
.values({ id: workspaceID, provider, binding: result.binding, created_at: now, last_used_at: now })
.values({ id: workspaceID, provider, binding: null, created_at: now, last_used_at: now })
.run()
.pipe(Effect.orDie)
return new Info({ id: workspaceID, provider, binding: result.binding, createdAt: now, lastUsedAt: now })
return workspaceID
}),
provision,
connect: Effect.fn("Workspace.connect")(function* (workspaceID) {
const spawner = make((command) =>
Effect.acquireRelease(
locks.withLock(workspaceID)(
Effect.gen(function* () {
const connection = yield* open(workspaceID).pipe(
Effect.mapError((cause) =>
systemError({
_tag: "Unknown",
module: "Workspace",
method: "spawn",
description: `Failed to wake workspace ${workspaceID}`,
cause,
}),
),
)
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
yield* Ref.update(connection.active, (active) => active + 1)
return connection
}),
// A live connection implies the binding is already persisted, so skip the provision hop.
Effect.suspend(() => (connections.has(workspaceID) ? Effect.void : provision(workspaceID))).pipe(
Effect.andThen(
locks.withLock(workspaceID)(
Effect.gen(function* () {
const connection = yield* open(workspaceID)
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
yield* Ref.update(connection.active, (active) => active + 1)
return connection
}),
),
),
Effect.mapError((cause) =>
systemError({
_tag: "Unknown",
module: "Workspace",
method: "spawn",
description: `Failed to wake workspace ${workspaceID}`,
cause,
}),
),
),
(connection) =>
locks.withLock(workspaceID)(
@@ -196,14 +257,32 @@ const layer = (options: Options) =>
return { spawner }
}),
destroy: Effect.fn("Workspace.destroy")(function* (workspaceID) {
// Settling the shared attempt cancels its racing provision body and fails
// waiters with NotFound before teardown commits. Accepted tradeoffs: if the
// locked teardown below fails, those waiters saw NotFound for a workspace
// that still exists (the next provision retries it), and a provision racing
// this window may briefly succeed before teardown destroys its fresh binding.
const attempt = attempts.get(workspaceID)
if (attempt) {
attempts.delete(workspaceID)
Deferred.doneUnsafe(attempt, Exit.fail(new NotFound({ workspaceID })))
}
yield* locks.withLock(workspaceID)(
Effect.gen(function* () {
const row = yield* load(workspaceID)
const connection = connections.get(workspaceID)
connections.delete(workspaceID)
if (connection) yield* Scope.close(connection.scope, Exit.void)
const driver = yield* registry.get(row.provider)
yield* driver.destroy({ workspaceID, binding: row.binding })
// Null binding still reaches the driver: an interrupted or crashed
// provision may have created a resource that was never persisted. A
// provider missing from the registry cannot block deleting a
// never-provisioned row.
yield* registry.get(row.provider).pipe(
Effect.flatMap((driver) => driver.destroy({ workspaceID, binding: row.binding })),
Effect.catchTag("WorkspaceDriver.ProviderNotFound", (error) =>
row.binding ? Effect.fail(error) : Effect.void,
),
)
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
}),
)
@@ -216,4 +295,8 @@ export const node = configured()
// TODO(workspace-plan): add the boot janitor and ~23h safety snapshot rotation in a later PR.
// TODO(workspace-plan): make cold wake interruptible with a re-pin loop against janitor races.
// TODO(workspace-plan): consider RcMap at end-of-series consolidation; idle suspend and destroy need distinct finalizers.
// TODO(workspace-plan): consider extracting a keyed shared-attempt helper (join/cancel, drop-on-settle) beside
// KeyedMutex at end-of-series consolidation; filesystem/search.ts and session/run-coordinator.ts hand-roll the same
// shape. Audited stdlib alternatives (rc.111): RcMap fails twice (refcount release cancels in-flight work when the
// last waiter leaves, and one finalizer path cannot express idle-suspend vs destroy); Cache interrupts the shared
// lookup when its last awaiter is interrupted and cannot fail waiters with NotFound on invalidation.
+19 -1
View File
@@ -24,6 +24,16 @@ export class ProviderNotFound extends Schema.TaggedError<ProviderNotFound>()("Wo
}) {}
export interface Interface {
/**
* Get-or-create the provider resource backing this logical workspace.
*
* MUST be idempotent per `workspaceID`: core retries the same ID after
* failures and process crashes, including a crash between a successful
* create and the binding being persisted, and another process may race the
* same ID. Key the resource by `workspaceID` (a provider tag or a
* deterministic name) and adopt an existing match instead of creating a
* duplicate.
*/
readonly create: (input: {
readonly workspaceID: Workspace.ID
}) => Effect.Effect<{ readonly binding: Binding }, Error>
@@ -37,9 +47,17 @@ export interface Interface {
readonly binding: Binding
readonly saveBinding: (binding: Binding) => Effect.Effect<void>
}) => Effect.Effect<void, Error>
/**
* Release the provider resource for this workspace.
*
* `binding` is null when none was persisted: the workspace was never
* provisioned, or provisioning was interrupted mid-create. Look up any
* resource previously created for `workspaceID` and clean it up, treating
* absence as success.
*/
readonly destroy: (input: {
readonly workspaceID: Workspace.ID
readonly binding: Binding
readonly binding: Binding | null
}) => Effect.Effect<void, Error>
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { WorkspaceDriver } from "./driver.js"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
provider: text().notNull(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>(),
created_at: integer().notNull(),
last_used_at: integer().notNull(),
})
+4 -6
View File
@@ -86,6 +86,8 @@ describe("FileSystemSearch", () => {
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Effect.sleep("10 millis")
expect(observed).toBeUndefined()
yield* search.find({ query: "src", type: "directory" })
expect(observed?.limit).toBe(100_000)
expect(observed?.exclude).toEqual([...Protected.names()].map((name) => `${name}/**`))
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
@@ -97,7 +99,6 @@ describe("FileSystemSearch", () => {
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
let scans = 0
const initial = Effect.runSync(Deferred.make<void>())
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
@@ -127,7 +128,6 @@ describe("FileSystemSearch", () => {
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
if (scans === 1) yield* Deferred.succeed(initial, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
@@ -140,7 +140,7 @@ describe("FileSystemSearch", () => {
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(initial)
yield* search.find({ query: "old", type: "file" })
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(1)
@@ -163,7 +163,6 @@ describe("FileSystemSearch", () => {
test("reuses location-owned fuzzy targets across index refreshes", async () => {
let scans = 0
const first = Effect.runSync(Deferred.make<void>())
const second = Effect.runSync(Deferred.make<void>())
const prepare = spyOn(fuzzysort, "prepare")
const cleanup = spyOn(fuzzysort, "cleanup")
@@ -187,7 +186,7 @@ describe("FileSystemSearch", () => {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
@@ -200,7 +199,6 @@ describe("FileSystemSearch", () => {
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(first)
yield* search.find({ query: "index", type: "file" })
yield* TestClock.adjust("10 seconds")
yield* search.find({ query: "index", type: "file" })
+47 -5
View File
@@ -3,7 +3,20 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Deferred, Duration, Effect, Equal, Fiber, Hash, Layer, LayerMap, RcMap, Schema, Stream } from "effect"
import {
DateTime,
Deferred,
Duration,
Effect,
Equal,
Fiber,
Hash,
Layer,
LayerMap,
RcMap,
Schema,
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
@@ -23,6 +36,7 @@ import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Workspace } from "@opencode-ai/core/workspace"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
@@ -61,10 +75,9 @@ const activityLocations = Layer.effect(
),
)
const itWithActivity = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]),
[[LocationServiceMap.node, activityLocations]],
),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [
[LocationServiceMap.node, activityLocations],
]),
)
describe("LocationServiceMap", () => {
@@ -115,6 +128,35 @@ describe("LocationServiceMap", () => {
),
)
it.live("keeps a workspace location admitted when its directory does not exist locally", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = AbsolutePath.make(path.join(dir.path, "workspace-only"))
const workspaceRef = Location.Ref.make({ directory, workspaceID: Workspace.ID.make("wrk_liveness") })
const localRef = Location.Ref.make({ directory })
// The directory only exists inside the workspace, so liveness must
// not consult the local filesystem. Boot outcome is not the subject
// here: parts of the location graph still read the host filesystem,
// so only assert that the entry survives release instead of being
// evicted by a zero idle time-to-live.
yield* Location.Service.pipe(Effect.provide(locations.get(workspaceRef)), Effect.scoped, Effect.exit)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
// A local ref with the same missing directory keeps the existing
// behavior: dropped as soon as it goes idle so a retry can rebuild it.
yield* Location.Service.pipe(Effect.provide(locations.get(localRef)), Effect.scoped, Effect.exit)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
}),
),
),
)
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { DateTime, Effect, Stream, Types } from "effect"
import { DateTime, Effect, Option, Stream, Types } from "effect"
import type { SessionContext } from "@opencode-ai/plugin/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Agent } from "@opencode-ai/core/agent"
@@ -242,10 +242,10 @@ describe("plan plugin reminders", () => {
})
describe("plan plugin mutations", () => {
it.effect("creates the Plan directory", () =>
it.effect("does not create the Plan directory during activation", () =>
Effect.gen(function* () {
const { files } = yield* run()
expect((yield* files.stat(planDirectory)).type).toBe("directory")
expect(Option.isNone(yield* files.stat(planDirectory).pipe(Effect.option))).toBe(true)
}),
)
+187 -7
View File
@@ -7,19 +7,21 @@ import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { WorkspaceTable } from "@opencode-ai/core/workspace/sql"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { Deferred, Effect, Fiber } from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess } from "effect/unstable/process"
import { testEffect } from "./lib/effect"
const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding }> = []
const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding | null }> = []
const memory = makeMemoryDriver()
let failConnect = false
let create: WorkspaceDriver.Interface["create"] = ({ workspaceID }) =>
Effect.succeed({ binding: { workspaceID, generation: 0 } })
const driver = WorkspaceDriver.make({
create: ({ workspaceID }) => {
create: (input) => {
calls.push({ operation: "create" })
return Effect.succeed({ binding: { workspaceID, generation: 0 } })
return create(input)
},
connect: ({ binding }) => {
calls.push({ operation: "connect", binding })
@@ -46,6 +48,18 @@ const it = testEffect(
beforeEach(() => {
calls.splice(0)
failConnect = false
create = ({ workspaceID }) => Effect.succeed({ binding: { workspaceID, generation: 0 } })
})
const gateCreate = Effect.fnUntraced(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
create = ({ workspaceID }) =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({ binding: { workspaceID, generation: 0 } }),
)
return { started, release }
})
it.effect("rejects unregistered workspace providers", () =>
@@ -60,12 +74,178 @@ it.effect("rejects unregistered workspace providers", () =>
}),
)
it.effect("creates and persists an ID without provisioning", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
expect(workspaceID.startsWith("wrk_")).toBe(true)
expect(calls).toEqual([])
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(),
).pipe(Effect.orDie),
).toMatchObject({ id: workspaceID, provider: "fake", binding: null })
}),
)
it.effect("destroys an unprovisioned workspace through the driver with a null binding", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
yield* workspace.destroy(workspaceID)
expect(calls).toEqual([{ operation: "destroy", binding: null }])
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(),
).pipe(Effect.orDie),
).toBeUndefined()
}),
)
it.effect("starts eager provisioning in the background and lets callers join it", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const eager = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(gate.started)
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
expect(calls.map((call) => call.operation)).toEqual(["create"])
yield* Deferred.succeed(gate.release, undefined)
const [eagerResult, waiterResult] = yield* Effect.all([Fiber.join(eager), Fiber.join(waiter)])
expect(eagerResult).toEqual(waiterResult)
expect(eagerResult.binding).toEqual({ workspaceID, generation: 0 })
}),
)
it.effect("starts lazy provisioning on the first spawn", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const environment = yield* workspace.connect(workspaceID)
const gate = yield* gateCreate()
expect(calls).toEqual([])
const spawned = yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("lazy"))).pipe(
Effect.forkScoped({ startImmediately: true }),
)
yield* Deferred.await(gate.started)
expect(calls.map((call) => call.operation)).toEqual(["create"])
yield* Deferred.succeed(gate.release, undefined)
yield* Fiber.await(spawned)
expect(calls.map((call) => call.operation)).toEqual(["create", "connect"])
}),
)
it.effect("shares provisioning between concurrent first spawns", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const environment = yield* workspace.connect(workspaceID)
const gate = yield* gateCreate()
const spawned = yield* Effect.all(
["first", "second"].map((command) =>
Effect.scoped(environment.spawner.spawn(ChildProcess.make(command))).pipe(
Effect.forkScoped({ startImmediately: true }),
),
),
)
yield* Deferred.await(gate.started)
yield* Effect.yieldNow
expect(calls.map((call) => call.operation)).toEqual(["create"])
yield* Deferred.succeed(gate.release, undefined)
yield* Effect.forEach(spawned, Fiber.await)
expect(calls.map((call) => call.operation)).toEqual(["create", "connect"])
}),
)
it.effect("keeps shared provisioning alive when a waiter is interrupted", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const owner = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(gate.started)
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Fiber.interrupt(waiter)
expect(calls.map((call) => call.operation)).toEqual(["create"])
yield* Deferred.succeed(gate.release, undefined)
expect((yield* Fiber.join(owner)).binding).toEqual({ workspaceID, generation: 0 })
expect(calls.map((call) => call.operation)).toEqual(["create"])
}),
)
it.effect("interrupts in-flight provisioning on destroy and fails waiters with NotFound", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(gate.started)
yield* workspace.destroy(workspaceID)
expect(yield* Fiber.join(waiter).pipe(Effect.flip)).toEqual(new Workspace.NotFound({ workspaceID }))
expect(calls.map((call) => call.operation)).toEqual(["create", "destroy"])
expect(calls.at(-1)?.binding).toBeNull()
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(),
).pipe(Effect.orDie),
).toBeUndefined()
}),
)
it.effect("shares a failed attempt and retries the same workspace ID", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let fail = true
create = ({ workspaceID }) =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(
Effect.suspend(() =>
fail
? Effect.fail(new WorkspaceDriver.Error({ message: "create failed" }))
: Effect.succeed({ binding: { workspaceID, generation: 0 } }),
),
),
)
const first = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
const second = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.succeed(release, undefined)
const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)])
expect(firstExit._tag).toBe("Failure")
expect(secondExit._tag).toBe("Failure")
expect(calls.map((call) => call.operation)).toEqual(["create"])
fail = false
expect((yield* workspace.provision(workspaceID)).binding).toEqual({ workspaceID, generation: 0 })
expect(calls.map((call) => call.operation)).toEqual(["create", "create"])
}),
)
it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const created = yield* workspace.create("fake")
const workspaceID = yield* workspace.create("fake")
const created = yield* workspace.provision(workspaceID)
expect(created.id.startsWith("wrk_")).toBe(true)
expect(created.id).toBe(workspaceID)
expect(created.binding).toEqual({ workspaceID: created.id, generation: 0 })
const environment = yield* workspace.connect(created.id)
@@ -97,7 +277,7 @@ it.effect("persists the workspace lifecycle and reconnects after idle suspension
it.effect("surfaces wake failures through the spawn error channel", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const created = yield* workspace.create("fake")
const created = yield* workspace.provision(yield* workspace.create("fake"))
const environment = yield* workspace.connect(created.id)
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("connect"))).pipe(Effect.exit)
+20 -2
View File
@@ -1,5 +1,6 @@
import type { PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect"
import { instanceSafeTool } from "./tool-schema.js"
import type { PluginOptions } from "../options.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
@@ -43,6 +44,23 @@ export interface Plugin<R = Scope.Scope> {
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
export function define<R = Scope.Scope>(plugin: Plugin<R>) {
return plugin
export function define<R = Scope.Scope>(plugin: Plugin<R>): Plugin<R> {
return {
...plugin,
effect: (context) => plugin.effect(instanceSafeContext(context)),
}
}
// Tool schemas cross from the plugin's module world into the host at `draft.add`;
// convert them while authoring-instance code is still on the stack so the host never
// interprets a foreign Effect schema. See `instanceSafeTool`.
function instanceSafeContext(context: Context): Context {
return {
...context,
tool: {
...context.tool,
transform: (callback) =>
context.tool.transform((draft) => callback({ add: (tool) => draft.add(instanceSafeTool(tool)) })),
},
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Schema } from "effect"
import type { Tool } from "@opencode-ai/schema/tool"
/**
* Converts a tool's Effect schemas into detached Standard Schema wrappers so they
* survive the crossing from the plugin's module world into the host.
*
* Plugins often load their own copy of `effect` (for example from the config
* directory's node_modules) while the host bundles a different instance. A live
* Effect schema cannot be interpreted across that boundary: schema parsing relies on
* per-instance sentinels and class identity, so the host misvalidates checks and
* turns branded-type failures into defects. A Standard Schema wrapper instead carries
* validation and JSON Schema generation as closures bound to the instance that
* created the schema, which the host invokes as-is.
*/
export function instanceSafeTool(tool: Tool.Info<any, any>): Tool.Info<any, any> {
const input = instanceSafeValueSchema(tool.input, "input")
const output = tool.output === undefined ? undefined : instanceSafeValueSchema(tool.output, "output")
if (input === tool.input && output === tool.output) return tool
return { ...tool, input, ...(output === undefined ? {} : { output }) }
}
function instanceSafeValueSchema(schema: Tool.ValueSchema<any>, direction: "input" | "output"): Tool.ValueSchema<any> {
if (!Schema.isSchema(schema)) return schema
// Inputs are decoded (Encoded -> Type) but outputs are encoded (Type -> Encoded),
// so outputs use the flipped schema: its standard `validate` runs in the encode
// direction and its `jsonSchema.output` still describes the encoded shape.
const oriented = direction === "input" ? (schema as Schema.Top) : Schema.flip(schema as Schema.Top)
// Both converters augment the schema object in place and return it; the host must
// receive a plain wrapper instead, because the augmented object still satisfies
// `Schema.isSchema` and would route back into cross-instance interpretation.
const augmented = Schema.toStandardJSONSchemaV1(
Schema.toStandardSchemaV1(oriented as never) as never,
) as unknown as StandardWrapper
return { "~standard": augmented["~standard"] } as Tool.ValueSchema<any>
}
type StandardWrapper = { readonly "~standard": Record<string, unknown> }
@@ -0,0 +1,71 @@
import { expect, test } from "bun:test"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, Schema } from "effect"
import { Plugin } from "../src/effect/index.js"
import type { Tool } from "@opencode-ai/schema/tool"
// `define` must hand the host detached Standard Schema wrappers instead of live
// Effect schemas: hosts may run a different `effect` instance, which cannot
// interpret foreign schemas (checks false-fail and branded types die as defects).
const collectTool = async (tool: Tool.Info<any, any>) => {
const added: Array<Tool.Info<any, any>> = []
const context = {
tool: {
transform: (callback: (draft: { add: (tool: Tool.Info<any, any>) => void }) => void) => {
callback({ add: (item) => added.push(item) })
return Effect.succeed({ dispose: Effect.void })
},
},
} as unknown as Plugin.Context
const plugin = Plugin.define({
id: "test.instance-safe",
effect: (ctx) => ctx.tool.transform((draft) => draft.add(tool)).pipe(Effect.asVoid),
})
await Effect.runPromise(Effect.scoped(plugin.effect(context)))
expect(added).toHaveLength(1)
return added[0]
}
type StandardValue = StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>
test("define converts Effect schemas to detached standard wrappers", async () => {
const execute = (input: { title?: string }) => Effect.succeed({ output: { id: `ses_${input.title}` } })
const registered = await collectTool({
name: "create",
description: "Create",
input: Schema.Struct({ title: Schema.optional(Schema.String.check(Schema.isMinLength(1))) }),
output: Schema.Struct({ id: Schema.String }),
execute,
})
expect(registered.execute).toBe(execute)
expect(Schema.isSchema(registered.input)).toBe(false)
expect(Schema.isSchema(registered.output)).toBe(false)
const input = registered.input as StandardValue
expect(await input["~standard"].validate({ title: "probe" })).toEqual({ value: { title: "probe" } })
const invalid = await input["~standard"].validate({ title: "" })
expect(invalid.issues?.[0]?.message).toContain("a value with a length of at least 1")
expect(input["~standard"].jsonSchema.input({ target: "draft-2020-12" })).toMatchObject({ type: "object" })
// Outputs validate in the encode direction (Type -> Encoded) and describe the
// encoded shape.
const output = registered.output as StandardValue
expect(await output["~standard"].validate({ id: "ses_x" })).toEqual({ value: { id: "ses_x" } })
expect(output["~standard"].jsonSchema.output({ target: "draft-2020-12" })).toMatchObject({
type: "object",
required: ["id"],
})
})
test("define leaves non-Effect schemas untouched", async () => {
const input = { type: "object" as const }
const registered = await collectTool({
name: "raw",
description: "Raw",
input,
execute: () => Effect.succeed({ content: "ok" }),
})
expect(registered.input).toBe(input)
expect(registered.output).toBeUndefined()
})
+1
View File
@@ -33,6 +33,7 @@
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
+4
View File
@@ -52,6 +52,9 @@ export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
readonly events: OpenCodeClient["event"]
readonly workspace: {
readonly create: (options: { readonly provider: string }) => ReturnType<Workspace.Interface["create"]>
readonly provision: (options: {
readonly workspaceID: Workspace.ID
}) => ReturnType<Workspace.Interface["provision"]>
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
}
readonly plugin: SdkPlugins.Interface["register"] & OpenCodeClient["plugin"]
@@ -106,6 +109,7 @@ export const create: (
events: client.event,
workspace: {
create: ({ provider }: { readonly provider: string }) => workspace.create(provider),
provision: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => workspace.provision(workspaceID),
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => workspace.destroy(workspaceID),
},
// The embedded host contributes plugins through the ordinary discovery flow:
+216 -2
View File
@@ -1,9 +1,14 @@
import fs from "fs/promises"
import path from "path"
import { expect } from "bun:test"
import { LanguageModel, LLMClient, LLMResponse, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { TestLLM } from "@opencode-ai/ai/testing"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { Deferred, Effect, Fiber, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src"
@@ -455,7 +460,11 @@ it.live("configures workspace providers through the SDK facade", () =>
},
})
const opencode = yield* fixture.sdk.OpenCode.create({ workspaceProviders: { fake: driver } })
const workspace = yield* opencode.workspace.create({ provider: "fake" })
const workspaceID = yield* opencode.workspace.create({ provider: "fake" })
expect(calls).toEqual([])
const workspace = yield* opencode.workspace.provision({ workspaceID })
expect(workspace.provider).toBe("fake")
expect(workspace.binding).toEqual({ externalID: workspace.id })
@@ -477,6 +486,211 @@ it.live("configures workspace providers through the SDK facade", () =>
),
)
const workspaceModelScenario = (fixture: Fixture, policy: "eager" | "lazy") =>
Effect.gen(function* () {
const calls: string[] = []
const createStarted = yield* Deferred.make<void>()
const createRelease = yield* Deferred.make<void>()
const modelStarted = yield* Deferred.make<void>()
yield* Effect.addFinalizer(() => Deferred.succeed(createRelease, undefined).pipe(Effect.asVoid))
const model = LanguageModel.make({ id: "workspace-test", provider: "test", route: OpenAIChat.route })
const client = TestLLM.clientLayer.pipe(
Layer.provide(
TestLLM.layer({
fallback: TestLLM.text("ready", "answer"),
transformRequest: (request) => {
Deferred.doneUnsafe(modelStarted, Effect.void)
return request
},
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service, {
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 100_000, output: 1_000 },
}),
),
})
const driver = WorkspaceDriver.make({
create: ({ workspaceID }) => {
calls.push("create")
return Deferred.succeed(createStarted, undefined).pipe(
Effect.andThen(Deferred.await(createRelease)),
Effect.as({ binding: { workspaceID } }),
)
},
connect: () => {
calls.push("connect")
return Effect.succeed(makeMemoryDriver())
},
suspendForIdle: () => Effect.void,
destroy: () => Effect.void,
})
const configDirectory = path.join(fixture.directory, "config")
yield* Effect.promise(() => fs.mkdir(configDirectory))
const opencode = yield* fixture.sdk.OpenCode.create(
{
config: { directory: configDirectory, project: false, content: "{}" },
workspaceProviders: { fake: driver },
},
{
overrides: [
[llmClient, client],
[SessionRunnerModel.node, models],
],
},
)
const workspaceID = yield* opencode.workspace.create({ provider: "fake" })
const provisioning =
policy === "eager"
? yield* opencode.workspace.provision({ workspaceID }).pipe(Effect.forkScoped({ startImmediately: true }))
: undefined
if (provisioning) {
yield* Deferred.await(createStarted).pipe(
Effect.timeoutOrElse({ duration: "4 seconds", orElse: () => Effect.die("provider create did not start") }),
)
}
const session = yield* opencode.sessions.create({
location: fixture.sdk.Location.Ref.make({
directory: fixture.sdk.AbsolutePath.make(fixture.directory),
workspaceID,
}),
})
yield* opencode.sessions.prompt({ sessionID: session.id, text: "Answer without using tools" })
yield* Deferred.await(modelStarted).pipe(
Effect.timeoutOrElse({ duration: "8 seconds", orElse: () => Effect.die("model stream did not start") }),
)
if (!provisioning) {
expect(calls).toEqual([])
return
}
expect(provisioning.pollUnsafe()).toBeUndefined()
expect(calls).toEqual(["create"])
yield* Deferred.succeed(createRelease, undefined)
expect((yield* Fiber.join(provisioning)).binding).toEqual({ workspaceID })
expect(calls).toEqual(["create"])
})
it.live(
"starts model execution while eager workspace provisioning is blocked",
() => withEmbedded("opencode-embedded-workspace-eager-", (fixture) => workspaceModelScenario(fixture, "eager")),
15_000,
)
it.live(
"starts model execution without provisioning a lazy workspace",
() => withEmbedded("opencode-embedded-workspace-lazy-", (fixture) => workspaceModelScenario(fixture, "lazy")),
15_000,
)
it.live(
"blocks the model-selected first tool on lazy provisioning",
() =>
withEmbedded("opencode-embedded-workspace-tool-", (fixture) =>
Effect.gen(function* () {
const calls: string[] = []
const createStarted = yield* Deferred.make<void>()
const createRelease = yield* Deferred.make<void>()
yield* Effect.addFinalizer(() => Deferred.succeed(createRelease, undefined).pipe(Effect.asVoid))
const model = LanguageModel.make({ id: "workspace-tool-test", provider: "test", route: OpenAIChat.route })
// The first tool-advertising request selects the shell tool; everything else
// (including title generation, which carries no tools) answers with text.
let toolIssued = false
const respond = (request: LLMRequest) => {
const wantsTool = !toolIssued && request.tools.some((tool) => tool.name === "shell")
if (!wantsTool) return TestLLM.text("done", "answer")
toolIssued = true
return TestLLM.tool("call-shell", "shell", { command: "echo hi" })
}
const client = Layer.succeed(
LLMClient.Service,
LLMClient.Service.of({
stream: (request) => Stream.fromIterable(respond(request)),
generate: (request) =>
Stream.fromIterable(respond(request)).pipe(
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
Effect.flatMap((state) => {
const response = LLMResponse.complete(state)
if (response) return Effect.succeed(response)
return Effect.die("test response ended without a terminal finish event")
}),
),
}),
)
const models = Layer.mock(SessionRunnerModel.Service, {
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 100_000, output: 1_000 },
}),
),
})
const driver = WorkspaceDriver.make({
create: ({ workspaceID }) => {
calls.push("create")
return Deferred.succeed(createStarted, undefined).pipe(
Effect.andThen(Deferred.await(createRelease)),
Effect.as({ binding: { workspaceID } }),
)
},
connect: () => {
calls.push("connect")
return Effect.succeed(makeMemoryDriver())
},
suspendForIdle: () => Effect.void,
destroy: () => Effect.void,
})
const configDirectory = path.join(fixture.directory, "config")
yield* Effect.promise(() => fs.mkdir(configDirectory))
const opencode = yield* fixture.sdk.OpenCode.create(
{
config: { directory: configDirectory, project: false, content: "{}" },
workspaceProviders: { fake: driver },
},
{
overrides: [
[llmClient, client],
[SessionRunnerModel.node, models],
],
},
)
const workspaceID = yield* opencode.workspace.create({ provider: "fake" })
const session = yield* opencode.sessions.create({
location: fixture.sdk.Location.Ref.make({
directory: fixture.sdk.AbsolutePath.make(fixture.directory),
workspaceID,
}),
})
expect(calls).toEqual([])
yield* opencode.sessions.prompt({ sessionID: session.id, text: "Run echo" })
// The model-selected shell tool is the first execution-plane demand: it alone
// starts provisioning and blocks inside the tool call until the provider is ready.
yield* Deferred.await(createStarted).pipe(
Effect.timeoutOrElse({ duration: "8 seconds", orElse: () => Effect.die("first tool did not provision") }),
)
expect(calls).toEqual(["create"])
yield* Deferred.succeed(createRelease, undefined)
yield* opencode.sessions.wait({ sessionID: session.id })
// Provisioning settled, the workspace connected, and the turn completed. The
// memory driver rejects the actual spawn, which surfaces to the model as an
// ordinary tool error before the final text response.
expect(calls).toEqual(["create", "connect"])
expect(toolIssued).toBe(true)
}),
),
15_000,
)
it.live("preserves unknown workspace provider errors", () =>
withEmbedded("opencode-embedded-workspace-provider-", (fixture) =>
Effect.gen(function* () {
@@ -263,33 +263,6 @@
}
}
[data-component="compaction-part"] {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
[data-slot="compaction-part-divider"] {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
width: 100%;
}
[data-slot="compaction-part-line"] {
flex: 1 1 auto;
height: 1px;
background: var(--v2-border-border-base);
}
[data-slot="compaction-part-label"] {
flex: 0 0 auto;
white-space: nowrap;
text-align: center;
}
}
[data-component="reasoning-part"] {
width: 100%;
color: var(--v2-text-text-muted);
@@ -1,7 +1,2 @@
export {
AssistantReasoningContent,
AssistantTextContent,
CurrentUserMessageDisplay,
MessageDivider,
} from "../message/message-content"
export { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "../message/message-content"
export { CurrentContextToolGroup, SessionShellMessage, ToolDisplay } from "../tools/tool-renderer"
@@ -0,0 +1,33 @@
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Show } from "solid-js"
export function TimelineSeparator(props: { label: string; providerID?: string; variant?: string }) {
return (
<div class="flex h-8 w-full items-center gap-3 text-v2-text-text-faint">
<span class="h-px min-w-0 flex-1 bg-v2-border-border-strong" />
<span class="flex min-w-0 items-center gap-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px]">
<Show when={props.providerID}>
{(providerID) => <ProviderIcon id={providerID()} class="text-v2-icon-icon-faint" aria-hidden="true" />}
</Show>
<span class="flex min-w-0 items-center gap-1.5">
<bdi dir="auto" class="truncate" title={props.label}>
{props.label}
</bdi>
<Show when={props.variant && props.variant !== "default" ? props.variant : undefined}>
{(variant) => (
<>
<span class="flex size-1.5 shrink-0 items-center justify-center" aria-hidden="true">
<span class="size-[2.25px] rounded-full bg-current" />
</span>
<span data-slot="session-timeline-notice-variant" class="shrink-0 capitalize">
{variant()}
</span>
</>
)}
</Show>
</span>
</span>
<span class="h-px min-w-0 flex-1 bg-v2-border-border-strong" />
</div>
)
}
@@ -1,22 +1,94 @@
[data-component="card"][data-kind="tool-error-card"] {
--card-pad-y: 0px;
--card-line-pad: 4px;
--card-line-pad: 0px;
--card-pad-r: 0px;
&::before {
border-radius: 0;
}
/* Figma's leading-none would clip Inter descenders at 13px; keep the compact metric. */
[data-slot="basic-tool-tool-title"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
}
[data-slot="basic-tool-tool-subtitle"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
}
[data-slot="tool-error-card-dot"] {
flex-shrink: 0;
font-family: var(--v2-font-family-sans);
font-size: 11px;
font-weight: 530;
line-height: var(--line-height-tight);
letter-spacing: 0.05px;
color: var(--v2-text-text-muted);
}
[data-slot="tool-error-card-summary"],
[data-slot="tool-error-card-message"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-state-fg-danger);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-slot="tool-error-card-summary"] {
flex-shrink: 1;
min-width: 0;
}
[data-slot="tool-error-card-message"] {
/* Text column indent: 16px icon + 8px gap. */
padding-inline-start: 24px;
}
[data-slot="basic-tool-tool-info-main"] {
align-items: center;
gap: 6px;
}
[data-slot="basic-tool-tool-trigger-content"] {
/* Reserve the 14px arrow + 6px gap. */
max-width: calc(100% - 20px);
}
[data-slot="collapsible-arrow"] {
width: 14px;
height: 14px;
margin-inline-start: 6px;
/* Always visible; the base collapsible reveals arrows only on hover. */
opacity: 1;
}
[data-slot="collapsible-arrow"],
[data-slot="collapsible-arrow-icon"] {
color: var(--v2-text-text-faint);
}
> [data-component="collapsible"].tool-collapsible {
gap: 0px;
/* Figma's 4px gap minus the 1.5px the compact line box adds above the summary em. */
gap: 2.5px;
> [data-slot="collapsible-trigger"] {
height: 24px;
}
> [data-slot="collapsible-content"] {
border-inline-start: none;
@@ -31,7 +103,8 @@
}
> [data-component="collapsible"].tool-collapsible[data-open="true"] {
gap: 4px;
/* The compact line box alone yields Figma's spacing under the header. */
gap: 0px;
}
[data-component="tool-error-card-icon"] [data-slot="icon-svg"] {
@@ -41,11 +114,19 @@
[data-slot="tool-error-card-content"] {
position: relative;
padding-left: 24px;
margin-bottom: 8px;
-webkit-user-select: text;
user-select: text;
}
[data-slot="tool-error-card-content"] [data-slot="card-description"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-faint);
}
> [data-component="collapsible"].tool-collapsible[data-open="true"] [data-slot="tool-error-card-content"] {
padding-right: 40px;
}
@@ -12,7 +12,18 @@ Tool call failure summary styled like a tool trigger.
- Collapsible; click header to expand/collapse.
`
const samples = [
const samples: { tool: string; error: string; subtitle?: string; defaultOpen?: boolean }[] = [
{
tool: "shell",
subtitle: "sleep 30",
error: "Tool execution interrupted",
},
{
tool: "shell",
subtitle: "sleep 30",
error: "Tool execution interrupted",
defaultOpen: true,
},
{
tool: "patch",
error:
@@ -62,8 +73,9 @@ export default {
},
},
args: {
tool: "patch",
tool: samples[0].tool,
error: samples[0].error,
subtitle: samples[0].subtitle,
},
argTypes: {
tool: {
@@ -73,9 +85,12 @@ export default {
error: {
control: "text",
},
subtitle: {
control: "text",
},
},
render: (props: { tool: string; error: string }) => {
return <ToolErrorCard tool={props.tool} error={props.error} />
render: (props: { tool: string; error: string; subtitle?: string }) => {
return <ToolErrorCard tool={props.tool} error={props.error} subtitle={props.subtitle} />
},
}
@@ -83,7 +98,16 @@ export const All = {
render: () => {
return (
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 720px;">
<For each={samples}>{(item) => <ToolErrorCard tool={item.tool} error={item.error} />}</For>
<For each={samples}>
{(item) => (
<ToolErrorCard
tool={item.tool}
error={item.error}
subtitle={item.subtitle}
defaultOpen={item.defaultOpen}
/>
)}
</For>
</div>
)
},
@@ -70,19 +70,16 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
return value
})
const subtitle = createMemo(() => {
if (split.subtitle) return split.subtitle
const parts = tail().split(": ")
if (parts.length <= 1) return i18n.t("ui.toolErrorCard.failed")
const head = (parts[0] ?? "").trim()
const summary = createMemo(() => {
const head = (tail().split(": ")[0] ?? "").trim()
if (!head) return i18n.t("ui.toolErrorCard.failed")
return head[0] ? head[0].toUpperCase() + head.slice(1) : i18n.t("ui.toolErrorCard.failed")
return head[0].toUpperCase() + head.slice(1)
})
const body = createMemo(() => {
const detail = createMemo(() => {
const parts = tail().split(": ")
if (parts.length <= 1) return cleaned()
return parts.slice(1).join(": ").trim() || cleaned()
if (parts.length <= 1) return ""
return parts.slice(1).join(": ").trim()
})
const copy = async () => {
@@ -100,27 +97,36 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
<Icon name="circle-ban-sign" size="small" style={{ "stroke-width": 1.5 }} />
{/* 20px-viewBox path at 16px: 1.25 renders the 1px stroke Figma specifies. */}
<Icon name="circle-ban-sign" style={{ "stroke-width": 1.25 }} />
</span>
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">{name()}</span>
<Show
when={split.href && split.subtitle}
fallback={<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
>
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={split.href!}
onClick={(event) => {
event.stopPropagation()
split.onSubtitleClick?.(event)
}}
<Show when={split.subtitle}>
<Show
when={split.href}
fallback={<span data-slot="basic-tool-tool-subtitle">{split.subtitle}</span>}
>
{subtitle()}
</a>
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={split.href!}
onClick={(event) => {
event.stopPropagation()
split.onSubtitleClick?.(event)
}}
>
{split.subtitle}
</a>
</Show>
</Show>
<Show when={open()}>
<span data-slot="tool-error-card-dot" aria-hidden="true">
·
</span>
<span data-slot="tool-error-card-summary">{summary()}</span>
</Show>
</div>
</div>
@@ -129,33 +135,38 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div data-slot="tool-error-card-content">
<Show when={open()}>
<div data-slot="tool-error-card-copy">
<Tooltip
appearance="standard"
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
placement="top"
gutter={4}
>
<IconButton
icon={<Icon name={copied() ? "check" : "copy"} />}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation()
void copy()
}}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
/>
</Tooltip>
</div>
</Show>
<Show when={body()}>{(value) => <CardDescription>{value()}</CardDescription>}</Show>
</div>
</Collapsible.Content>
<Show when={!open()}>
<div data-slot="tool-error-card-message">{summary()}</div>
</Show>
<Show when={detail()}>
<Collapsible.Content>
<div data-slot="tool-error-card-content">
<Show when={open()}>
<div data-slot="tool-error-card-copy">
<Tooltip
appearance="standard"
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
placement="top"
gutter={4}
>
<IconButton
icon={<Icon name={copied() ? "check" : "copy"} />}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation()
void copy()
}}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
/>
</Tooltip>
</div>
</Show>
<CardDescription>{detail()}</CardDescription>
</div>
</Collapsible.Content>
</Show>
</Collapsible>
</Card>
)
@@ -29,20 +29,23 @@ describe("current content default open", () => {
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true)
})
test("collapses failed patches", () => {
const patch: SessionMessageAssistantTool = {
test("collapses errored tools regardless of disclosure preferences", () => {
const errored = (name: string): SessionMessageAssistantTool => ({
type: "tool",
id: "tool_patch",
name: "patch",
id: `tool_${name}`,
name,
state: {
status: "error",
input: {},
error: { type: "ToolError", message: "Verification failed" },
error: { type: "ToolError", message: "Tool execution interrupted" },
metadata: {},
},
time: { created: 1, completed: 2 },
}
expect(currentContentDefaultOpen(patch, false, false)).toBe(false)
})
expect(currentContentDefaultOpen(errored("shell"), true, true)).toBe(false)
expect(currentContentDefaultOpen(errored("execute"), true, true)).toBe(false)
expect(currentContentDefaultOpen(errored("edit"), true, true)).toBe(false)
expect(currentContentDefaultOpen(errored("patch"), false, false)).toBe(false)
})
test("opens deletion-only patches", () => {
@@ -10,7 +10,6 @@ import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../t
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserComment } from "../actions"
export { MessageDivider } from "./message-content"
export { SessionShellMessage } from "../tools/tool-renderer"
export { currentContentDefaultOpen } from "./current-tool-state"
@@ -35,8 +35,10 @@ export function currentContentDefaultOpen(
editExpanded: boolean,
) {
if (content.type !== "tool") return undefined
// Errored tools render the error card, which starts collapsed.
if (content.state.status === "error") return false
if (content.name === "shell" || content.name === "execute") return shellExpanded
if (content.name === "patch") return content.state.status !== "error"
if (content.name === "patch") return true
if (content.name !== "edit" && content.name !== "write") return undefined
if (!editExpanded) return false
const files = currentToolMetadata(content).files
@@ -8,6 +8,7 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { getFilename } from "@opencode-ai/util/path"
import { AttachmentCard } from "./attachment-card"
import { CommentCard } from "./comment-card"
import { TimelineSeparator } from "../components/timeline-separator"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -371,20 +372,6 @@ function CurrentHighlightedText(props: {
type HighlightSegment = { text: string; type?: "file" | "agent" }
export function MessageDivider(props: { label: string }) {
return (
<div data-component="compaction-part">
<div data-slot="compaction-part-divider">
<span data-slot="compaction-part-line" />
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
{props.label}
</span>
<span data-slot="compaction-part-line" />
</div>
</div>
)
}
export function SessionCompactionMessage(props: { message: SessionMessageCompaction; error: string }) {
const i18n = useI18n()
const summary = () => (props.message.status === "failed" ? "" : props.message.summary)
@@ -395,7 +382,9 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
return (
<div data-component="session-compaction-message">
<MessageDivider label={i18n.t("ui.messagePart.compaction")} />
<div class="py-2">
<TimelineSeparator label={i18n.t("ui.messagePart.compaction")} />
</div>
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
<div data-slot="text-part-body">
@@ -12,8 +12,9 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import { BasicTool } from "../components/basic-tool"
import { useData } from "../context"
import { TimelineSeparator } from "../components/timeline-separator"
import {
MessageDivider,
SessionAssistantContent,
SessionContextToolGroup,
SessionFileToolGroup,
@@ -52,6 +53,7 @@ export function createSessionTimelineRowRenderer(input: {
anchor?: (messageID: string) => string | undefined
}) {
const i18n = useI18n()
const data = useData()
const workingTurn = (messageID: string) =>
input.status().type !== "idle" && input.projection.activeMessageID() === messageID
const duration = (messageID: string) => {
@@ -183,11 +185,7 @@ export function createSessionTimelineRowRenderer(input: {
label: i18n.t("ui.tool.agent.default"),
data: message.previous ? `${message.previous}${message.agent}` : message.agent,
}
if (message.type === "model-switched")
return {
label: i18n.t("ui.sessionTimeline.notice.model"),
data: `${message.model.providerID}/${message.model.id}`,
}
if (message.type === "model-switched") return undefined
if (message.type === "skill") return { label: i18n.t("ui.tool.skill"), data: message.name }
if (message.type === "system") {
const prefix = "Instructions updated: "
@@ -328,6 +326,18 @@ export function createSessionTimelineRowRenderer(input: {
const value = message()
return value?.type === "location-switched" ? value : undefined
})
const model = createMemo(() => {
const value = message()
if (value?.type !== "model-switched") return undefined
const match = data.store.provider?.all?.get(value.model.providerID)
return {
providerID: value.model.providerID,
variant: value.model.variant,
label: i18n.t("ui.sessionTimeline.notice.modelSwitched", {
model: match?.models?.[value.model.id]?.name ?? value.model.id,
}),
}
})
const content = createMemo(() => {
const value = message()
return value ? notice(value) : undefined
@@ -346,50 +356,69 @@ export function createSessionTimelineRowRenderer(input: {
<Show
when={moved()}
fallback={
<Show when={content()}>
{(content) => (
<Show
when={content().items?.length}
fallback={
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
<Show
when={model()}
fallback={
<Show when={content()}>
{(content) => (
<Show
when={content().items?.length}
fallback={
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
>
<bdi dir="auto" class="text-13-medium">
{content().label}
</bdi>
<Show when={content().data}>
{(data) => (
<span>
{" "}
· <bdi dir="auto">{data()}</bdi>
</span>
)}
</Show>
</div>
}
>
<bdi dir="auto" class="text-13-medium">
{content().label}
</bdi>
<Show when={content().data}>
{(data) => (
<span>
{" "}
· <bdi dir="auto">{data()}</bdi>
</span>
)}
</Show>
</div>
}
>
<div data-slot="session-timeline-notice" class={`w-full py-1 ${padding()}`}>
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
<bdi
dir="auto"
class="shrink-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{content().label}
</bdi>
<For each={content().items}>
{(item) => (
<div data-slot="session-timeline-notice" class={`w-full py-1 ${padding()}`}>
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
<bdi
dir="auto"
class="min-w-0 truncate text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
class="shrink-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{item}
{content().label}
</bdi>
)}
</For>
</div>
</div>
<For each={content().items}>
{(item) => (
<bdi
dir="auto"
class="min-w-0 truncate text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{item}
</bdi>
)}
</For>
</div>
</div>
</Show>
)}
</Show>
}
>
{(model) => (
<div
data-slot="session-timeline-notice"
data-type="model-switched"
class={`w-full py-2 ${padding()}`}
>
<TimelineSeparator
label={model().label}
providerID={model().providerID}
variant={model().variant}
/>
</div>
)}
</Show>
}
@@ -430,7 +459,9 @@ export function createSessionTimelineRowRenderer(input: {
<Frame row={current()}>
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<div data-slot="session-turn-compaction">
<MessageDivider label={i18n.t("ui.message.interrupted")} />
<div class="py-2">
<TimelineSeparator label={i18n.t("ui.message.interrupted")} />
</div>
</div>
</div>
</Frame>
@@ -772,6 +772,7 @@ export function ToolDisplay(
if (typeof value === "string" && value) return value
return taskId()
})
const errorSubtitle = createMemo(() => toolErrorSubtitle(props, i18n))
const error = createMemo(() => toolDisplayError(props, i18n.t("ui.toolErrorCard.failed")))
const render = createMemo(() => ToolRegistry.render(props.tool) ?? GenericTool)
@@ -799,7 +800,7 @@ export function ToolDisplay(
defaultOpen={props.defaultOpen}
open={props.open}
onOpenChange={props.onOpenChange}
subtitle={taskSubtitle()}
subtitle={taskSubtitle() ?? errorSubtitle()}
href={taskHref()}
onSubtitleClick={(event) => {
if (!data.navigateToSession) return
@@ -822,6 +823,32 @@ export function ToolDisplay(
)
}
// Each branch must stay in sync with its tool trigger's subtitle expression so
// failed rows read like their non-error counterparts ("Shell sleep 30").
function toolErrorSubtitle(props: ToolProps, i18n: UiI18n) {
const text = (value: unknown) => (typeof value === "string" && value ? value : undefined)
if (props.tool === "shell") return text(props.input.command) ?? text(props.metadata.command)
if (props.tool === "execute") return text(props.input.code)
if (props.tool === "read") return getFilename(readToolPath(props.input) ?? "")
if (props.tool === "edit" || props.tool === "write") return getFilename(text(props.input.path) ?? "")
if (props.tool === "list" || props.tool === "glob" || props.tool === "grep")
return displayDirectory(text(props.input.path) ?? "/")
if (props.tool === "webfetch") return text(props.input.url)
if (props.tool === "websearch") return text(props.input.query)
if (props.tool === "skill") return skillToolName(props.input, props.metadata)
if (props.tool === "patch") {
const count = patchFileGroups(props.metadata.files).length
if (count === 0) return undefined
return `${count} ${i18n.plural("ui.common.file", count)}`
}
if (props.tool === "question") {
const count = Array.isArray(props.input.questions) ? props.input.questions.filter(questionInfo).length : 0
if (count === 0) return undefined
return `${count} ${i18n.plural("ui.common.question", count)}`
}
return undefined
}
function toolDisplayError(props: ToolProps & { error?: string }, fallback: string) {
if (props.status === "error") return props.error
if (props.tool !== "execute") return undefined
+1 -1
View File
@@ -9,7 +9,7 @@ export type ProviderIconProps = JSX.SVGElementTags["svg"] & {
export const ProviderIcon: Component<ProviderIconProps> = (props) => {
const [local, rest] = splitProps(props, ["id", "class", "classList"])
const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "session.synthetic"))
const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "synthetic"))
return (
<svg
data-component="provider-icon"
+1
View File
@@ -211,6 +211,7 @@ const source = {
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.interrupted": "Interrupted",
"ui.sessionTimeline.notice.model": "Model",
"ui.sessionTimeline.notice.modelSwitched": "Switched to {{model}}",
"ui.sessionTimeline.notice.movedTo": "Moved to",
"ui.sessionTimeline.notice.movedTooltip": "Session working directory changed",
"ui.sessionTimeline.notice.failed": "{{actor}} failed",