Compare commits

..
Author SHA1 Message Date
kitlangton ebff3950e8 fix(ci): skip CLI builds after setup failures 2026-08-23 23:30:34 +00:00
40 changed files with 299 additions and 1209 deletions
+3 -2
View File
@@ -48,6 +48,7 @@ jobs:
node-version: "24"
- name: Setup Bun
id: setup-bun
uses: ./.github/actions/setup-bun
- name: Test Effect simplification rules
@@ -85,7 +86,7 @@ jobs:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify compiled service lifecycle
if: always()
if: always() && steps.setup-bun.outcome == 'success'
timeout-minutes: 10
working-directory: packages/cli
env:
@@ -101,7 +102,7 @@ jobs:
node-version: "26.4.0"
- name: Verify Node build
if: always()
if: always() && steps.setup-bun.outcome == 'success'
timeout-minutes: 15
working-directory: packages/cli
env:
+36 -1
View File
@@ -671,7 +671,6 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
@@ -3218,6 +3217,12 @@
"@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=="],
@@ -3976,6 +3981,8 @@
"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=="],
@@ -4250,6 +4257,8 @@
"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=="],
@@ -4614,6 +4623,8 @@
"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=="],
@@ -4698,6 +4709,8 @@
"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=="],
@@ -4814,6 +4827,8 @@
"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=="],
@@ -4838,6 +4853,8 @@
"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=="],
@@ -4852,6 +4869,8 @@
"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=="],
@@ -5434,6 +5453,8 @@
"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=="],
@@ -5826,6 +5847,10 @@
"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=="],
@@ -6398,6 +6423,10 @@
"@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=="],
@@ -6536,6 +6565,10 @@
"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=="],
@@ -7292,6 +7325,8 @@
"@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-mMg7Y8ZTL+SEcX6ciNBDL+H9UT9c85EM/pZmIcsoFtA=",
"aarch64-linux": "sha256-9JtD/rwpmF9hD9MPNLu/fMx8mQu1vpOMLYRN+nHq/eI=",
"aarch64-darwin": "sha256-wLtkY3d5o88bfPriChTCSmUtEnYR6PKj86qQxNqDVOE=",
"x86_64-darwin": "sha256-9bqDFh7YGNK94oRDo9xp0l5r0H17g1aUtnaVPTfFEOE="
"x86_64-linux": "sha256-pnyqHqM8nt1Dh/d6xRm8DploZG9PiaO3HWGVIFyew54=",
"aarch64-linux": "sha256-cWPfoDku1bFN7zwCy6rsLP/WMmp6hEpO3Ous3FPGcfo=",
"aarch64-darwin": "sha256-2S3opWupuF3AncBbZLd5B7IsrUxYxA4S1mKWy58gMlo=",
"x86_64-darwin": "sha256-fu6WCBh9CWqsk8ACvLdeleP55uZ08i8MfEPFqgt7ob0="
}
}
@@ -899,26 +899,8 @@ 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", "company-gateway"],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
@@ -250,43 +250,6 @@ 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: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
modelID: model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
@@ -1,7 +1,6 @@
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"
@@ -31,11 +30,7 @@ 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": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "3fb67508-0196-4bae-b2bd-c08ece7583fd",
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
"ddl": [
{
"name": "account_state",
@@ -1442,7 +1442,7 @@
},
{
"type": "text",
"notNull": false,
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
-2
View File
@@ -44,7 +44,6 @@ 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,
@@ -92,5 +91,4 @@ export const migrations = [
m42,
m43,
m44,
m45,
] satisfies DatabaseMigration.Migration[]
@@ -1,27 +0,0 @@
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,
\`binding\` text NOT NULL,
\`created_at\` integer NOT NULL,
\`last_used_at\` integer NOT NULL
);
+17 -21
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, Deferred, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Clock, Context, 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: Deferred.Deferred<void> | undefined
let refreshing = false
const scan = Effect.gen(function* () {
const next = emptyIndex()
const previous = index
@@ -84,25 +84,21 @@ export const ripgrepLayer = Layer.effect(
})
index = next
initialized = true
}).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))),
)
})
}).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
return Service.of({
find: (input) =>
Effect.gen(function* () {
+1 -8
View File
@@ -147,14 +147,7 @@ export function buildLocationServiceMap(
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{
// 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,
},
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? Duration.infinity : Duration.zero) },
),
(inner) => ({
...inner,
+4
View File
@@ -6,6 +6,7 @@ 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"
@@ -27,9 +28,12 @@ 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")
+1 -32
View File
@@ -44,38 +44,7 @@ export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool
}
})
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) =>
attemptDecodeInput(schema, value).pipe(
Effect.catchTag("Tool.Error", (error) => {
// JSON Schema derived from Effect schemas advertises `X | null` for optional
// fields because JSON cannot express undefined, so callers legitimately send
// null to mean "omitted". Retry with null properties removed: schemas that
// genuinely accept null succeed on the first attempt, and the original error
// is reported when the retry cannot help.
const stripped = withoutNullProperties(value)
if (stripped === value) return error
return attemptDecodeInput(schema, stripped).pipe(Effect.catchTag("Tool.Error", () => error))
}),
)
// Removes null-valued object properties recursively. Null array elements remain
// positional while object properties inside arrays are still normalized.
const withoutNullProperties = (value: unknown): unknown => {
if (Array.isArray(value)) {
const items = value.map(withoutNullProperties)
return items.some((item, index) => item !== value[index]) ? items : value
}
if (typeof value !== "object" || value === null) return value
const entries = Object.entries(value).flatMap(([key, item]) =>
item === null ? [] : [[key, withoutNullProperties(item)] as const],
)
const changed =
entries.length !== Object.keys(value).length ||
entries.some(([key, item]) => (value as Record<string, unknown>)[key] !== item)
return changed ? Object.fromEntries(entries) : value
}
const attemptDecodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
if (Schema.isSchema(schema))
return Schema.decodeUnknownEffect(schema)(value).pipe(
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
+38 -121
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, Deferred, Duration, Effect, Exit, FiberSet, Layer, Ref, Schedule, Schema, Scope } from "effect"
import { Clock, Context, Duration, Effect, Exit, 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,12 +26,7 @@ export class Info extends Schema.Class<Info>("Workspace.Info")({
export class NotFound extends Schema.TaggedError<NotFound>()("Workspace.NotFound", { workspaceID: ID }) {}
export interface Interface {
/** 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 create: (provider: string) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
readonly connect: (
workspaceID: ID,
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
@@ -56,8 +51,6 @@ interface Connection {
readonly scope: Scope.Closeable
}
type ReadinessError = NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound
export const configured = (options: Options = {}) =>
makeGlobalNode({
service: Service,
@@ -73,10 +66,7 @@ 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) {
@@ -90,75 +80,29 @@ 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 persistBinding = (binding: WorkspaceDriver.Binding) => saveBinding(workspaceID, binding)
const saveBinding = (value: WorkspaceDriver.Binding) =>
db
.update(WorkspaceTable)
.set({ binding: value })
.where(eq(WorkspaceTable.id, workspaceID))
.run()
.pipe(Effect.orDie)
const scope = yield* Scope.fork(lifetime)
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 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 now = yield* Clock.currentTimeMillis
const connection: Connection = {
driver,
environment,
saveBinding: persistBinding,
saveBinding,
lastActivity: yield* Ref.make(now),
active: yield* Ref.make(0),
scope,
@@ -185,7 +129,6 @@ 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,
@@ -208,41 +151,37 @@ const layer = (options: Options) =>
return Service.of({
create: Effect.fn("Workspace.create")(function* (provider) {
yield* registry.get(provider)
const driver = 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: null, created_at: now, last_used_at: now })
.values({ id: workspaceID, provider, binding: result.binding, created_at: now, last_used_at: now })
.run()
.pipe(Effect.orDie)
return workspaceID
return new Info({ id: workspaceID, provider, binding: result.binding, createdAt: now, lastUsedAt: now })
}),
provision,
connect: Effect.fn("Workspace.connect")(function* (workspaceID) {
const spawner = make((command) =>
Effect.acquireRelease(
// 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,
}),
),
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
}),
),
(connection) =>
locks.withLock(workspaceID)(
@@ -257,32 +196,14 @@ 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)
// 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,
),
)
const driver = yield* registry.get(row.provider)
yield* driver.destroy({ workspaceID, binding: row.binding })
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
}),
)
@@ -295,8 +216,4 @@ 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 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.
// TODO(workspace-plan): consider RcMap at end-of-series consolidation; idle suspend and destroy need distinct finalizers.
+1 -19
View File
@@ -24,16 +24,6 @@ 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>
@@ -47,17 +37,9 @@ 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 | null
readonly binding: Binding
}) => 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>(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
created_at: integer().notNull(),
last_used_at: integer().notNull(),
})
+6 -4
View File
@@ -86,8 +86,6 @@ 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(
@@ -99,6 +97,7 @@ 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, [
@@ -128,6 +127,7 @@ 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* search.find({ query: "old", type: "file" })
yield* Deferred.await(initial)
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(1)
@@ -163,6 +163,7 @@ 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")
@@ -186,7 +187,7 @@ describe("FileSystemSearch", () => {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
@@ -199,6 +200,7 @@ 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" })
+5 -47
View File
@@ -3,20 +3,7 @@ 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"
@@ -36,7 +23,6 @@ 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"
@@ -75,9 +61,10 @@ 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", () => {
@@ -128,35 +115,6 @@ 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, Option, Stream, Types } from "effect"
import { DateTime, Effect, 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("does not create the Plan directory during activation", () =>
it.effect("creates the Plan directory", () =>
Effect.gen(function* () {
const { files } = yield* run()
expect(Option.isNone(yield* files.stat(planDirectory).pipe(Effect.option))).toBe(true)
expect((yield* files.stat(planDirectory)).type).toBe("directory")
}),
)
-110
View File
@@ -1,110 +0,0 @@
import { expect, test } from "bun:test"
import { Tool } from "@opencode-ai/core/tool"
import { execute } from "@opencode-ai/core/tool/runtime"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Info } from "@opencode-ai/schema/tool"
import { Effect, Schema } from "effect"
const context = {
sessionID: Session.ID.make("ses_null"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_null"),
id: Tool.CallID.make("call_null"),
progress: () => Effect.void,
}
// The JSON Schema advertised for these tools renders optional fields as `X | null`
// (JSON cannot express undefined), so callers legitimately send null to mean
// "omitted". The runtime must accept that without weakening schemas that
// genuinely distinguish null.
const collect = (input: Info["input"]) => {
let received: unknown
const tool: Info = {
name: "probe",
description: "Probe",
input,
execute: (value) => {
received = value
return Effect.succeed({ content: "ok" })
},
}
return {
tool,
run: (value: unknown) => Effect.runPromise(execute(tool, value, context)).then(() => received),
fail: (value: unknown) => Effect.runPromiseExit(execute(tool, value, context)).then((exit) => exit.toString()),
}
}
test("null optional properties decode as omitted", async () => {
const probe = collect(
Schema.Struct({
title: Schema.String,
agent: Schema.optional(Schema.String),
}),
)
expect(await probe.run({ title: "probe", agent: null })).toEqual({ title: "probe" })
})
test("nested null optional properties decode as omitted", async () => {
const probe = collect(
Schema.Struct({
worktree: Schema.optional(
Schema.Struct({
branch: Schema.String,
base: Schema.optional(Schema.String),
}),
),
}),
)
expect(await probe.run({ worktree: { branch: "main", base: null } })).toEqual({ worktree: { branch: "main" } })
})
test("schemas that accept null keep it", async () => {
const probe = collect(Schema.Struct({ next: Schema.NullOr(Schema.String) }))
expect(await probe.run({ next: null })).toEqual({ next: null })
})
test("null array elements survive the retry", async () => {
const probe = collect(
Schema.Struct({
tags: Schema.Array(Schema.NullOr(Schema.String)),
agent: Schema.optional(Schema.String),
}),
)
expect(await probe.run({ tags: ["a", null], agent: null })).toEqual({ tags: ["a", null] })
})
test("unfixable nulls report the original error", async () => {
const probe = collect(Schema.Struct({ title: Schema.String }))
const message = await probe.fail({ title: null })
expect(message).toContain("Invalid tool input")
expect(message).toContain("Expected string")
})
test("standard schema inputs get the same retry", async () => {
const attempts: Array<unknown> = []
const input = {
"~standard": {
version: 1,
vendor: "test",
validate: (value: unknown) => {
attempts.push(value)
const record = value as Record<string, unknown>
if ("agent" in record && record.agent === null) return { issues: [{ message: "Expected string | undefined" }] }
return { value }
},
jsonSchema: {
input: () => ({ type: "object" }),
output: () => ({ type: "object" }),
},
},
} as unknown as Info["input"]
const probe = collect(input)
expect(await probe.run({ title: "probe", agent: null })).toEqual({ title: "probe" })
expect(attempts).toEqual([
{ title: "probe", agent: null },
{ title: "probe" },
])
})
+7 -187
View File
@@ -7,21 +7,19 @@ 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 { Deferred, Effect, Fiber } from "effect"
import { Effect } 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 | null }> = []
const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding }> = []
const memory = makeMemoryDriver()
let failConnect = false
let create: WorkspaceDriver.Interface["create"] = ({ workspaceID }) =>
Effect.succeed({ binding: { workspaceID, generation: 0 } })
const driver = WorkspaceDriver.make({
create: (input) => {
create: ({ workspaceID }) => {
calls.push({ operation: "create" })
return create(input)
return Effect.succeed({ binding: { workspaceID, generation: 0 } })
},
connect: ({ binding }) => {
calls.push({ operation: "connect", binding })
@@ -48,18 +46,6 @@ 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", () =>
@@ -74,178 +60,12 @@ 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 workspaceID = yield* workspace.create("fake")
const created = yield* workspace.provision(workspaceID)
const created = yield* workspace.create("fake")
expect(created.id).toBe(workspaceID)
expect(created.id.startsWith("wrk_")).toBe(true)
expect(created.binding).toEqual({ workspaceID: created.id, generation: 0 })
const environment = yield* workspace.connect(created.id)
@@ -277,7 +97,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.provision(yield* workspace.create("fake"))
const created = yield* workspace.create("fake")
const environment = yield* workspace.connect(created.id)
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("connect"))).pipe(Effect.exit)
-1
View File
@@ -33,7 +33,6 @@
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
-4
View File
@@ -52,9 +52,6 @@ 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"]
@@ -109,7 +106,6 @@ 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:
+2 -216
View File
@@ -1,14 +1,9 @@
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, Fiber, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { Deferred, Effect, 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"
@@ -460,11 +455,7 @@ it.live("configures workspace providers through the SDK facade", () =>
},
})
const opencode = yield* fixture.sdk.OpenCode.create({ workspaceProviders: { fake: driver } })
const workspaceID = yield* opencode.workspace.create({ provider: "fake" })
expect(calls).toEqual([])
const workspace = yield* opencode.workspace.provision({ workspaceID })
const workspace = yield* opencode.workspace.create({ provider: "fake" })
expect(workspace.provider).toBe("fake")
expect(workspace.binding).toEqual({ externalID: workspace.id })
@@ -486,211 +477,6 @@ 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,6 +263,33 @@
}
}
[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,2 +1,7 @@
export { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "../message/message-content"
export {
AssistantReasoningContent,
AssistantTextContent,
CurrentUserMessageDisplay,
MessageDivider,
} from "../message/message-content"
export { CurrentContextToolGroup, SessionShellMessage, ToolDisplay } from "../tools/tool-renderer"
@@ -1,33 +0,0 @@
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,94 +1,22 @@
[data-component="card"][data-kind="tool-error-card"] {
--card-pad-y: 0px;
--card-line-pad: 0px;
--card-pad-r: 0px;
--card-line-pad: 4px;
&::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 {
/* 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;
}
gap: 0px;
> [data-slot="collapsible-content"] {
border-inline-start: none;
@@ -103,8 +31,7 @@
}
> [data-component="collapsible"].tool-collapsible[data-open="true"] {
/* The compact line box alone yields Figma's spacing under the header. */
gap: 0px;
gap: 4px;
}
[data-component="tool-error-card-icon"] [data-slot="icon-svg"] {
@@ -114,19 +41,11 @@
[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,18 +12,7 @@ Tool call failure summary styled like a tool trigger.
- Collapsible; click header to expand/collapse.
`
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,
},
const samples = [
{
tool: "patch",
error:
@@ -73,9 +62,8 @@ export default {
},
},
args: {
tool: samples[0].tool,
tool: "patch",
error: samples[0].error,
subtitle: samples[0].subtitle,
},
argTypes: {
tool: {
@@ -85,12 +73,9 @@ export default {
error: {
control: "text",
},
subtitle: {
control: "text",
},
},
render: (props: { tool: string; error: string; subtitle?: string }) => {
return <ToolErrorCard tool={props.tool} error={props.error} subtitle={props.subtitle} />
render: (props: { tool: string; error: string }) => {
return <ToolErrorCard tool={props.tool} error={props.error} />
},
}
@@ -98,16 +83,7 @@ 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}
subtitle={item.subtitle}
defaultOpen={item.defaultOpen}
/>
)}
</For>
<For each={samples}>{(item) => <ToolErrorCard tool={item.tool} error={item.error} />}</For>
</div>
)
},
@@ -70,16 +70,19 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
return value
})
const summary = createMemo(() => {
const head = (tail().split(": ")[0] ?? "").trim()
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()
if (!head) return i18n.t("ui.toolErrorCard.failed")
return head[0].toUpperCase() + head.slice(1)
return head[0] ? head[0].toUpperCase() + head.slice(1) : i18n.t("ui.toolErrorCard.failed")
})
const detail = createMemo(() => {
const body = createMemo(() => {
const parts = tail().split(": ")
if (parts.length <= 1) return ""
return parts.slice(1).join(": ").trim()
if (parts.length <= 1) return cleaned()
return parts.slice(1).join(": ").trim() || cleaned()
})
const copy = async () => {
@@ -97,36 +100,27 @@ 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">
{/* 20px-viewBox path at 16px: 1.25 renders the 1px stroke Figma specifies. */}
<Icon name="circle-ban-sign" style={{ "stroke-width": 1.25 }} />
<Icon name="circle-ban-sign" size="small" style={{ "stroke-width": 1.5 }} />
</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.subtitle}>
<Show
when={split.href}
fallback={<span data-slot="basic-tool-tool-subtitle">{split.subtitle}</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)
}}
>
<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>
{subtitle()}
</a>
</Show>
</div>
</div>
@@ -135,38 +129,33 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<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.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>
</Collapsible>
</Card>
)
@@ -29,23 +29,20 @@ describe("current content default open", () => {
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true)
})
test("collapses errored tools regardless of disclosure preferences", () => {
const errored = (name: string): SessionMessageAssistantTool => ({
test("collapses failed patches", () => {
const patch: SessionMessageAssistantTool = {
type: "tool",
id: `tool_${name}`,
name,
id: "tool_patch",
name: "patch",
state: {
status: "error",
input: {},
error: { type: "ToolError", message: "Tool execution interrupted" },
error: { type: "ToolError", message: "Verification failed" },
metadata: {},
},
time: { created: 1, completed: 2 },
})
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)
}
expect(currentContentDefaultOpen(patch, false, false)).toBe(false)
})
test("opens deletion-only patches", () => {
@@ -10,6 +10,7 @@ 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,10 +35,8 @@ 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 true
if (content.name === "patch") return content.state.status !== "error"
if (content.name !== "edit" && content.name !== "write") return undefined
if (!editExpanded) return false
const files = currentToolMetadata(content).files
@@ -8,7 +8,6 @@ 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"
@@ -372,6 +371,20 @@ 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)
@@ -382,9 +395,7 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
return (
<div data-component="session-compaction-message">
<div class="py-2">
<TimelineSeparator label={i18n.t("ui.messagePart.compaction")} />
</div>
<MessageDivider label={i18n.t("ui.messagePart.compaction")} />
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
<div data-slot="text-part-body">
@@ -12,9 +12,8 @@ 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,
@@ -53,7 +52,6 @@ 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) => {
@@ -185,7 +183,11 @@ 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 undefined
if (message.type === "model-switched")
return {
label: i18n.t("ui.sessionTimeline.notice.model"),
data: `${message.model.providerID}/${message.model.id}`,
}
if (message.type === "skill") return { label: i18n.t("ui.tool.skill"), data: message.name }
if (message.type === "system") {
const prefix = "Instructions updated: "
@@ -326,18 +328,6 @@ 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
@@ -356,69 +346,50 @@ export function createSessionTimelineRowRenderer(input: {
<Show
when={moved()}
fallback={
<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>
}
<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()}`}
>
<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="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) => (
<bdi
dir="auto"
class="shrink-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
class="min-w-0 truncate text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{content().label}
{item}
</bdi>
<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>
)}
)}
</For>
</div>
</div>
</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>
}
@@ -459,9 +430,7 @@ export function createSessionTimelineRowRenderer(input: {
<Frame row={current()}>
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<div data-slot="session-turn-compaction">
<div class="py-2">
<TimelineSeparator label={i18n.t("ui.message.interrupted")} />
</div>
<MessageDivider label={i18n.t("ui.message.interrupted")} />
</div>
</div>
</Frame>
@@ -772,7 +772,6 @@ 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)
@@ -800,7 +799,7 @@ export function ToolDisplay(
defaultOpen={props.defaultOpen}
open={props.open}
onOpenChange={props.onOpenChange}
subtitle={taskSubtitle() ?? errorSubtitle()}
subtitle={taskSubtitle()}
href={taskHref()}
onSubtitleClick={(event) => {
if (!data.navigateToSession) return
@@ -823,32 +822,6 @@ 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 : "synthetic"))
const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "session.synthetic"))
return (
<svg
data-component="provider-icon"
-1
View File
@@ -211,7 +211,6 @@ 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",