mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 18:46:26 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e45e79aa61 | ||
|
|
d3e5d6d268 | ||
|
|
f9bc948912 | ||
|
|
6f238feeff | ||
|
|
e867a21ea5 | ||
|
|
64c0411edb | ||
|
|
7da10aa65d | ||
|
|
d80b0a1e7e | ||
|
|
481125f617 | ||
|
|
59d0d64a6b | ||
|
|
e0cdf1ed09 | ||
|
|
9e50d76416 | ||
|
|
771c0f5850 | ||
|
|
b1a0ef91bb | ||
|
|
2e67cee75b | ||
|
|
890735c1d7 | ||
|
|
049f0b0c3b | ||
|
|
6020f36862 | ||
|
|
0d24ebdbbe | ||
|
|
b22c182406 | ||
|
|
c7f2f367e3 | ||
|
|
ccd2135e7a | ||
|
|
c7c22b9d7e | ||
|
|
67e99993f5 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
|
||||
@@ -54,12 +54,12 @@
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.15",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
@@ -191,7 +191,7 @@
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.110",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -525,7 +525,7 @@
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.15",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -671,6 +671,7 @@
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -1078,10 +1079,10 @@
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-rc.110",
|
||||
"@effect/platform-node": "4.0.0-rc.110",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
|
||||
"@effect/opentelemetry": "4.0.0-rc.111",
|
||||
"@effect/platform-node": "4.0.0-rc.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -1118,7 +1119,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"hono": "4.10.7",
|
||||
@@ -1685,13 +1686,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.110", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.110" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-8Uum1ikIAK2DjATeLseS71HZKVI/dduFYypvMhiO68RTli1xq9yaoTI1Y0IThPn2sI0i86ZdpFaKQx1hdw2tWw=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.111" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.110", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.110", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110", "redis": ">=5.0.0 <7.0.0" } }, "sha512-poj6VxTc2kRDowUHgjGXAZL2nWHTyGi/18607jK+pV9A9CH/wqZ4NeYj38rij95j5SiZ3U7Y1iOsk2Vfnr4GEw=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.111", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.111", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.111", "redis": ">=5.0.0 <7.0.0" } }, "sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.110", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-P8EZloxS7RtCOSL3VSBPyqSenUm93xLbXf9jkWoy67cibZ2wgZ9nAou9iN8f1i4vzgxUsWtDuy6BEmg67np6Zw=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.111", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.110", "", { "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-Lam3gY1xszjQS/cebdLbWbtR21FtQI4ke7QHqbBQ6AyVJF0CGUtS/ePL9zNq6wHNmJ95FUDGS6W+Qx/l6RPSzA=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -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=="],
|
||||
@@ -3853,7 +3848,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-rc.110", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-ega6FTJ8CS2of7tHZiADvgyJyV999Q6tZ9juE56V81O0jw6flRwydaPNtyfvP2a5LL9PZrse8A1jnNUD5sWVHg=="],
|
||||
"effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -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=="],
|
||||
@@ -6173,6 +6148,8 @@
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -6421,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=="],
|
||||
@@ -6563,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=="],
|
||||
@@ -7323,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
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-P8AZ2THuXiET4hiSYTwGGdTgbm2l3XGZGJxQwg6lusE=",
|
||||
"aarch64-linux": "sha256-73LxDeDPQspY4Bn/gbGU43j/k7gBZ5ywMMpUjDl3elg=",
|
||||
"aarch64-darwin": "sha256-sc9YyTAFlbWxlFb9vwFYLs6IYd3SRoju9mQufzeQdd8=",
|
||||
"x86_64-darwin": "sha256-nnk637DELjzGB3t9aLDYbHz5h3Dd3nvWcWGdaQk8yPM="
|
||||
"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="
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -39,10 +39,10 @@
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-rc.110",
|
||||
"@effect/platform-node": "4.0.0-rc.110",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
|
||||
"@effect/opentelemetry": "4.0.0-rc.111",
|
||||
"@effect/platform-node": "4.0.0-rc.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -73,7 +73,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
|
||||
@@ -213,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
|
||||
- Input failed the `parameters` Schema.
|
||||
- The handler returned a `ToolFailure`.
|
||||
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
|
||||
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
|
||||
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
|
||||
|
||||
@@ -41,6 +41,7 @@ const SSE_EVENTS = new Set([
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"ping",
|
||||
"error",
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
@@ -1004,11 +1005,13 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
}
|
||||
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
})
|
||||
Effect.fail(
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
}),
|
||||
)
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -82,10 +82,15 @@ export type ProviderOptionsInput = OptionsInput
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
// Gemini is known to send explicit `null` for optional streaming fields
|
||||
// (usage counts, flags, whole subtrees), so every response-side optional uses
|
||||
// `optionalNull` instead of bare `Schema.optional`. The same part/content
|
||||
// schemas lower the outbound request body; encoding drops `undefined` keys,
|
||||
// so the shared schemas stay safe there.
|
||||
const GeminiTextPart = Schema.Struct({
|
||||
text: Schema.String,
|
||||
thought: Schema.optional(Schema.Boolean),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
thought: optionalNull(Schema.Boolean),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiInlineDataPart = Schema.Struct({
|
||||
@@ -98,11 +103,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||
|
||||
const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
id: optionalNull(Schema.String),
|
||||
name: Schema.String,
|
||||
args: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiFunctionResponsePart = Schema.Struct({
|
||||
@@ -122,8 +127,8 @@ const GeminiContentPart = Schema.Union([
|
||||
])
|
||||
|
||||
const GeminiContent = Schema.Struct({
|
||||
role: Schema.Literals(["user", "model"]),
|
||||
parts: Schema.Array(GeminiContentPart),
|
||||
role: optionalNull(Schema.Literals(["user", "model"])),
|
||||
parts: optionalNull(Schema.Array(GeminiContentPart)),
|
||||
})
|
||||
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
|
||||
|
||||
@@ -186,33 +191,33 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
|
||||
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
|
||||
|
||||
const GeminiUsage = Schema.Struct({
|
||||
cachedContentTokenCount: Schema.optional(Schema.Number),
|
||||
thoughtsTokenCount: Schema.optional(Schema.Number),
|
||||
promptTokenCount: Schema.optional(Schema.Number),
|
||||
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||
totalTokenCount: Schema.optional(Schema.Number),
|
||||
cachedContentTokenCount: optionalNull(Schema.Number),
|
||||
thoughtsTokenCount: optionalNull(Schema.Number),
|
||||
promptTokenCount: optionalNull(Schema.Number),
|
||||
candidatesTokenCount: optionalNull(Schema.Number),
|
||||
totalTokenCount: optionalNull(Schema.Number),
|
||||
})
|
||||
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
|
||||
|
||||
const GeminiCandidate = Schema.Struct({
|
||||
content: Schema.optional(GeminiContent),
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
content: optionalNull(GeminiContent),
|
||||
finishReason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
blockReason: optionalNull(Schema.String),
|
||||
blockReasonMessage: optionalNull(Schema.String),
|
||||
safetyRatings: optionalNull(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
candidates: optionalArray(GeminiCandidate),
|
||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||
usageMetadata: Schema.optional(GeminiUsage),
|
||||
candidates: optionalNull(Schema.Array(GeminiCandidate)),
|
||||
promptFeedback: optionalNull(GeminiPromptFeedback),
|
||||
usageMetadata: optionalNull(GeminiUsage),
|
||||
})
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
@@ -305,8 +310,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
const previous = contents.at(-1)
|
||||
// Gemini rejects a continuation whose function-response turn carries extra
|
||||
// parts, so an update after a tool result starts its own user turn.
|
||||
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
|
||||
if (previous?.role === "user" && !(previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), { text: part.text }] }
|
||||
else contents.push({ role: "user", parts: [{ text: part.text }] })
|
||||
continue
|
||||
}
|
||||
@@ -397,8 +402,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
// Gemini requires every response to a parallel call batch in one user turn,
|
||||
// so consecutive tool results join the open function-response turn.
|
||||
const previous = contents.at(-1)
|
||||
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
|
||||
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
|
||||
else contents.push({ role: "user", parts })
|
||||
}
|
||||
|
||||
@@ -488,21 +493,25 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.cachedContentTokenCount
|
||||
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
|
||||
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
|
||||
// token arithmetic below treats them like absent counts.
|
||||
const promptTokens = usage.promptTokenCount ?? undefined
|
||||
const cached = usage.cachedContentTokenCount ?? undefined
|
||||
const thoughts = usage.thoughtsTokenCount ?? undefined
|
||||
const visible = usage.candidatesTokenCount ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
|
||||
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
|
||||
// inclusive `outputTokens` the contract expects. Only compute the total
|
||||
// when the visible component is reported — otherwise we'd fabricate an
|
||||
// inclusive number from a partial breakdown.
|
||||
const outputTokens =
|
||||
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
|
||||
const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
|
||||
return new Usage({
|
||||
inputTokens: usage.promptTokenCount,
|
||||
inputTokens: promptTokens,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
@@ -537,7 +546,10 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
}
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||
// `?? undefined` normalizes an explicit `null` blockReason back to absent so
|
||||
// the "nothing to finish" check below keeps its meaning.
|
||||
const promptBlockReason =
|
||||
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
@@ -586,7 +598,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||
const seenCallIds = new Set(nextState.seenCallIds)
|
||||
|
||||
for (const part of candidate.content.parts) {
|
||||
for (const part of candidate.content.parts ?? []) {
|
||||
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
|
||||
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
|
||||
// each block kind must retain the signature attached to its own parts.
|
||||
@@ -625,7 +637,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
// Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
|
||||
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
|
||||
const supplied = part.functionCall.id
|
||||
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
|
||||
const supplied = part.functionCall.id ?? undefined
|
||||
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
@@ -642,7 +655,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata:
|
||||
part.thoughtSignature === undefined ? undefined : googleMetadata({ thoughtSignature: part.thoughtSignature }),
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
ProviderInternalReason,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
@@ -428,7 +429,7 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
...(url ? { file_url: url } : { file_data: media.base64 }),
|
||||
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
@@ -580,8 +581,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (store !== false && id && !hostedToolReferences.has(id))
|
||||
input.push({ type: "item_reference", id })
|
||||
if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
@@ -1105,11 +1105,19 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
: typeof event.status_code === "number"
|
||||
? event.status_code
|
||||
: undefined
|
||||
const reason =
|
||||
event.type === "error" &&
|
||||
event.error === undefined &&
|
||||
event.response === undefined &&
|
||||
summary === undefined &&
|
||||
status === undefined
|
||||
? new ProviderInternalReason({ message })
|
||||
: classifyProviderFailure({ message, code, status, rawBody: body })
|
||||
return new AIError({
|
||||
module: id,
|
||||
method: "stream",
|
||||
body,
|
||||
reason: classifyProviderFailure({ message, code, status, rawBody: body }),
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1126,7 +1134,8 @@ export const step = (state: ParserState, event: Event) => {
|
||||
}
|
||||
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
|
||||
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
|
||||
if (!event.item_id || typeof value !== "string") return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
if (!event.item_id || typeof value !== "string")
|
||||
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.refusal.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
|
||||
@@ -156,6 +156,9 @@ const OpenAIChatUsage = Schema.StructWithRest(
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
|
||||
cached_tokens: optionalNull(Schema.Number),
|
||||
prompt_cache_hit_tokens: optionalNull(Schema.Number),
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -204,11 +207,16 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
const OpenAIChatChoice = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
@@ -509,6 +517,17 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
return messages
|
||||
})
|
||||
|
||||
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
|
||||
// whenever the conversation history contains tool calls/results. Send an
|
||||
// explicit empty array when we have history but no active tools.
|
||||
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool") return true
|
||||
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
return {
|
||||
@@ -532,12 +551,15 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||
const hasHistory = hasToolHistory(request.messages)
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request, options),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
? hasHistory
|
||||
? []
|
||||
: undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
@@ -581,11 +603,18 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||
// through and derive the non-cached breakdown so the `AI.Usage` contract is
|
||||
// satisfied on both sides.
|
||||
// Providers differ on cache-hit location: OpenAI uses
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
const cached =
|
||||
(usage.prompt_tokens_details?.cached_tokens ??
|
||||
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
|
||||
(usage as { cached_tokens?: number | null }).cached_tokens ??
|
||||
undefined) as number | undefined
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
@@ -691,8 +720,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
const choice = event.choices?.[0]
|
||||
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
|
||||
// `choice.usage` instead of the top-level `usage` field.
|
||||
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
|
||||
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason =
|
||||
rawFinishReason !== undefined && rawFinishReason !== null
|
||||
|
||||
@@ -134,13 +134,12 @@ const HOSTED_TOOLS = {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
|
||||
@@ -28,7 +28,11 @@ export const ResponseIncludables = [
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
|
||||
export const ServiceTier = Schema.declare<ServiceTier>(
|
||||
(value): value is ServiceTier => typeof value === "string",
|
||||
{ title: "ServiceTier" },
|
||||
)
|
||||
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
@@ -38,7 +42,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
||||
(value): value is ResponseIncludable => typeof value === "string",
|
||||
{ title: "ResponseIncludable" },
|
||||
)
|
||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export const ServiceTierSchema = ServiceTier
|
||||
export const TruncationSchema = Schema.Literals(Truncations)
|
||||
|
||||
export const AllowedTools = Schema.Struct({
|
||||
|
||||
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
||||
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
|
||||
|
||||
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
|
||||
@@ -42,7 +42,7 @@ const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LL
|
||||
// unlike AI Studio, so history minted there cannot be lowered verbatim.
|
||||
const contents = body.contents.map((content) => ({
|
||||
...content,
|
||||
parts: content.parts.map((part) => {
|
||||
parts: (content.parts ?? []).map((part) => {
|
||||
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
|
||||
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
|
||||
return part
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,10 +4,4 @@ import { GoogleVertexChat } from "../../src/providers.js"
|
||||
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
|
||||
providerOptions: { serviceTier: "premium" },
|
||||
})
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
|
||||
@@ -8,6 +8,8 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
|
||||
|
||||
@@ -1492,6 +1492,73 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("survives explicit null usage counts", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] } }] },
|
||||
{ usageMetadata: { promptTokenCount: null, candidatesTokenCount: 5 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(response.usage).toMatchObject({ outputTokens: 5, totalTokens: 5 })
|
||||
expect(response.usage?.inputTokens).toBeUndefined()
|
||||
expect(response.usage?.nonCachedInputTokens).toBeUndefined()
|
||||
expect(response.usage?.cacheReadInputTokens).toBeUndefined()
|
||||
expect(response.usage?.reasoningTokens).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("survives null candidates, content, parts, and finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ candidates: null },
|
||||
{ candidates: [{ content: { role: "model", parts: null } }] },
|
||||
{ candidates: [{ content: null, finishReason: null }] },
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a null thought flag on a text part as visible output", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Visible", thought: null }] }, finishReason: "STOP" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const reasoningStart = response.events.find((event) => event.type === "reasoning-start")
|
||||
|
||||
expect(reasoningStart).toBeUndefined()
|
||||
expect(response.reasoning ?? "").toBe("")
|
||||
expect(response.text).toBe("Visible")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails invalid stream events", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -342,6 +342,7 @@ describe("OpenAI Chat route", () => {
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
|
||||
],
|
||||
tools: [],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
@@ -78,6 +78,45 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const pdf = "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([{ type: "media", mediaType: "application/pdf", data: pdf, filename: "input.pdf" }]),
|
||||
Message.assistant({ type: "tool-call", id: "call_1", name: "read", input: {} }),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
resultType: "content",
|
||||
result: [{ type: "file", uri: pdf, mime: "application/pdf", name: "result.pdf" }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_file", filename: "input.pdf", file_data: pdf }],
|
||||
},
|
||||
{ type: "function_call", call_id: "call_1", name: "read", arguments: "{}" },
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_1",
|
||||
output: [{ type: "input_file", filename: "result.pdf", file_data: pdf }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects OpenAI-native tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -114,7 +153,12 @@ describe("Open Responses-compatible route", () => {
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }], phase: null },
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
@@ -189,6 +233,7 @@ describe("Open Responses-compatible route", () => {
|
||||
streamOptions: { includeObfuscation: false },
|
||||
topLogprobs: 3,
|
||||
truncation: "auto",
|
||||
serviceTier: "provider-tier",
|
||||
allowedTools: { toolNames: ["lookup"] },
|
||||
maxToolCalls: 2,
|
||||
parallelToolCalls: false,
|
||||
@@ -213,6 +258,7 @@ describe("Open Responses-compatible route", () => {
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: -0.1,
|
||||
truncation: "auto",
|
||||
service_tier: "provider-tier",
|
||||
tool_choice: {
|
||||
type: "allowed_tools",
|
||||
mode: "auto",
|
||||
|
||||
@@ -188,13 +188,11 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
it.effect("passes through provider-defined service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("service_tier")
|
||||
expect(prepared.body.service_tier).toBe("scale")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1273,7 +1271,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1300,7 +1298,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "report.pdf", file_data: base64 },
|
||||
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1333,7 +1331,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1358,7 +1356,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "file", file_data: "AAECAw==" },
|
||||
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2652,6 +2650,47 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "computer_call",
|
||||
id: "computer_1",
|
||||
call_id: "call_1",
|
||||
status: "completed",
|
||||
action: { type: "click", x: 100, y: 200 },
|
||||
}
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
input: { type: "click", x: 100, y: 200 },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes image generation output as image content", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
@@ -2765,7 +2804,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2796,7 +2835,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2821,7 +2860,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "file",
|
||||
file_data: "AAECAw==",
|
||||
file_data: "data:application/x-tar;base64,AAECAw==",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -3029,14 +3068,14 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the raw payload when both error and response are absent", () =>
|
||||
it.effect("classifies a detail-free error event as a transient provider failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", sequence_number: 2 }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason.message).toContain('"type":"error"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
}),
|
||||
|
||||
@@ -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">[] = [
|
||||
|
||||
@@ -131,8 +131,11 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
await resetTerminalBottomMotion(page)
|
||||
await resetTerminalAnchorGaps(page)
|
||||
await resetPanelGaps(page)
|
||||
const reviewContent = page.locator('[data-component="session-review-v2"]')
|
||||
await reviewContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await expect(page.locator("#review-panel")).toBeHidden()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectHeightMotions(page, "session-side-region", 2)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 2)
|
||||
@@ -142,6 +145,7 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
await expectPanelGapHeld(page)
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectHeightMotions(page, "session-side-region", 3)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 3)
|
||||
|
||||
@@ -161,14 +165,18 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await expectWidthMotions(page, 2)
|
||||
await expectSideSlideSettled(page, 2)
|
||||
await expectHiddenSideAligned(page)
|
||||
|
||||
await resetHeightMotions(page)
|
||||
await resetHorizontalScrolls(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expectWidthMotions(page, 3)
|
||||
await expectSideMotionSettled(page)
|
||||
await expectSideSlideSettled(page, 3)
|
||||
await expectNoHeightMotion(page)
|
||||
await expectNoHorizontalScroll(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeHidden()
|
||||
@@ -209,6 +217,8 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
|
||||
type MotionProbe = {
|
||||
widths: number
|
||||
widthEnds: number
|
||||
horizontalScrolls: number[]
|
||||
reviewWidths: number[]
|
||||
paintGaps: { review: number; terminalSurface: number }[]
|
||||
terminalContentSizes: { width: number; height: number }[]
|
||||
@@ -225,6 +235,8 @@ async function installMotionProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe: MotionProbe = {
|
||||
widths: 0,
|
||||
widthEnds: 0,
|
||||
horizontalScrolls: [],
|
||||
reviewWidths: [],
|
||||
paintGaps: [],
|
||||
terminalContentSizes: [],
|
||||
@@ -299,10 +311,16 @@ async function installMotionProbe(page: Page) {
|
||||
probe.heights.push(slot)
|
||||
}
|
||||
})
|
||||
document.addEventListener("transitionend", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.propertyName === "width" && event.target.getAttribute("data-slot") === "session-chat-panel")
|
||||
probe.widthEnds++
|
||||
})
|
||||
document.addEventListener("animationstart", (event) => {
|
||||
if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return
|
||||
probe.animations.push(event.animationName)
|
||||
})
|
||||
window.addEventListener("scroll", () => probe.horizontalScrolls.push(window.scrollX))
|
||||
;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe
|
||||
})
|
||||
}
|
||||
@@ -320,11 +338,10 @@ async function resetHeightMotions(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
async function expectSideMotionSettled(page: Page) {
|
||||
const side = page.locator('[data-slot="session-side-panel-presence"]')
|
||||
async function expectSideSlideSettled(page: Page, count: number) {
|
||||
await expect
|
||||
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
|
||||
.toBe(true)
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function expectNoHeightMotion(page: Page) {
|
||||
@@ -334,6 +351,40 @@ async function expectNoHeightMotion(page: Page) {
|
||||
expect(heights).toEqual([])
|
||||
}
|
||||
|
||||
async function expectHiddenSideAligned(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const chat = document.querySelector<HTMLElement>('[data-slot="session-chat-panel"]')
|
||||
const side = document.querySelector<HTMLElement>('[data-slot="session-side-panel-presence"]')
|
||||
if (!chat?.parentElement || !side) return Number.POSITIVE_INFINITY
|
||||
const row = chat.parentElement.getBoundingClientRect()
|
||||
const hidden = side.getBoundingClientRect()
|
||||
return Math.max(
|
||||
Math.abs(row.top - hidden.top),
|
||||
Math.abs(row.right - hidden.right),
|
||||
Math.abs(row.bottom - hidden.bottom),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetHorizontalScrolls(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.horizontalScrolls = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoHorizontalScroll(page: Page) {
|
||||
const scrolls = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.horizontalScrolls ?? [],
|
||||
)
|
||||
expect(Math.max(0, ...scrolls)).toBe(0)
|
||||
expect(await page.evaluate(() => window.scrollX)).toBe(0)
|
||||
}
|
||||
|
||||
async function expectReviewWidthStable(page: Page) {
|
||||
const side = page.locator('[data-slot="session-side-panel-presence"]')
|
||||
await expect
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createOpenCodeEventSource } from "./client"
|
||||
import { createOpenCodeEventSource, createServerTransport } from "./client"
|
||||
|
||||
const permission = {
|
||||
id: "evt_permission",
|
||||
@@ -84,3 +84,39 @@ describe("server event stream", () => {
|
||||
second.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("rotates HTTP and PTY clients together", async () => {
|
||||
const requests: Array<{ url: string; authorization: string | null }> = []
|
||||
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
|
||||
return Response.json({ healthy: true, version: "2.0.0-test", pid: 1 })
|
||||
}) as typeof globalThis.fetch
|
||||
const transport = createServerTransport({
|
||||
http: { url: "http://127.0.0.1:4100", username: "opencode", password: "first" },
|
||||
fetch,
|
||||
})
|
||||
const initialPty = transport.pty
|
||||
|
||||
await transport.api.health.get()
|
||||
const replacement = transport.update({
|
||||
url: "http://127.0.0.1:4200",
|
||||
username: "opencode",
|
||||
password: "second",
|
||||
})
|
||||
await transport.api.health.get()
|
||||
|
||||
expect(replacement).toBe(transport.api)
|
||||
expect(transport.pty).not.toBe(initialPty)
|
||||
expect(transport.url).toBe("http://127.0.0.1:4200")
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: "http://127.0.0.1:4100/api/health",
|
||||
authorization: `Basic ${btoa("opencode:first")}`,
|
||||
},
|
||||
{
|
||||
url: "http://127.0.0.1:4200/api/health",
|
||||
authorization: `Basic ${btoa("opencode:second")}`,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -72,11 +72,12 @@ type ServerSDKBase = {
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const pty = createPtyClient(api, { url: server.http.url })
|
||||
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
|
||||
const events = createOpenCodeEventSource()
|
||||
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
|
||||
|
||||
const connection = createClientConnection(api, {
|
||||
const connection = createClientConnection(transport.api, {
|
||||
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
@@ -85,7 +86,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
log: {
|
||||
info(message, data) {
|
||||
if (message !== "event stream disconnected") return
|
||||
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
||||
console.info("[global-sdk] event stream disconnected", { url: transport.url, managed: !!reconnect, ...data })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -93,14 +94,48 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
url: server.http.url,
|
||||
api,
|
||||
pty,
|
||||
get url() {
|
||||
return transport.url
|
||||
},
|
||||
get api() {
|
||||
return transport.api
|
||||
},
|
||||
get pty() {
|
||||
return transport.pty
|
||||
},
|
||||
connection,
|
||||
event: events.event,
|
||||
}
|
||||
}
|
||||
|
||||
export function createServerTransport(input: { http: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }): {
|
||||
update(http: ServerConnection.HttpBase): ServerApi
|
||||
readonly url: string
|
||||
readonly api: ServerApi
|
||||
readonly pty: ReturnType<typeof createPtyClient>
|
||||
} {
|
||||
const build = (http: ServerConnection.HttpBase) => {
|
||||
const api = createApiForServer({ server: http, fetch: input.fetch })
|
||||
return { http, api, pty: createPtyClient(api, { url: http.url }) }
|
||||
}
|
||||
const state = { current: build(input.http) }
|
||||
return {
|
||||
update(http: ServerConnection.HttpBase) {
|
||||
state.current = build(http)
|
||||
return state.current.api
|
||||
},
|
||||
get url() {
|
||||
return state.current.http.url
|
||||
},
|
||||
get api() {
|
||||
return state.current.api
|
||||
},
|
||||
get pty() {
|
||||
return state.current.pty
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ export namespace ServerConnection {
|
||||
http: HttpBase
|
||||
} & (
|
||||
| // Regular desktop server
|
||||
{ variant: "base" }
|
||||
{ variant: "base"; reconnect?: (signal: AbortSignal) => Promise<HttpBase> }
|
||||
// WSL server (windows only)
|
||||
| {
|
||||
variant: "wsl"
|
||||
|
||||
@@ -36,10 +36,10 @@ export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||
queryFn: async () => [],
|
||||
})
|
||||
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: () => ServerApi) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||
path: () => loadPathQuery(scope, null, serverAPI.location),
|
||||
path: () => loadPathQuery(scope, null, serverAPI().location),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.api)
|
||||
const connected = () => serverSDK.connection.status() === "connected"
|
||||
|
||||
const [configQuery, pathQuery] = useQueries(() => ({
|
||||
|
||||
@@ -101,11 +101,11 @@ export const focusTerminalById = (id: string) => {
|
||||
|
||||
const textarea = terminal.querySelector("textarea")
|
||||
if (textarea instanceof HTMLTextAreaElement) {
|
||||
textarea.focus()
|
||||
textarea.focus({ preventScroll: true })
|
||||
return true
|
||||
}
|
||||
|
||||
terminal.focus()
|
||||
terminal.focus({ preventScroll: true })
|
||||
terminal.dispatchEvent(
|
||||
typeof PointerEvent === "function"
|
||||
? new PointerEvent("pointerdown", { bubbles: true, cancelable: true })
|
||||
|
||||
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<>
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div
|
||||
classList={{
|
||||
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
@@ -241,6 +241,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "side-region-presence-out") return
|
||||
if (screen.side.region.open()) return
|
||||
if (sideTerminalVisible()) return
|
||||
setStore("sideRegionPresent", false)
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
|
||||
@@ -347,9 +347,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const focusTerminal = () => {
|
||||
const t = term
|
||||
if (!t) return
|
||||
t.focus()
|
||||
t.textarea?.focus()
|
||||
setTimeout(() => t.textarea?.focus(), 0)
|
||||
const focus = () => (t.textarea ? t.textarea.focus({ preventScroll: true }) : t.focus())
|
||||
focus()
|
||||
setTimeout(focus, 0)
|
||||
}
|
||||
const handlePointerDown = () => {
|
||||
const activeElement = document.activeElement
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@opencode-ai/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.110",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormCancelInput,
|
||||
FormInfo,
|
||||
FormReplyInput,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
LocationGetOutput,
|
||||
@@ -37,7 +39,12 @@ import type {
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
|
||||
import {
|
||||
isFormAlreadySettledError,
|
||||
isFormNotFoundError,
|
||||
isPermissionNotFoundError,
|
||||
type SessionPromptInput,
|
||||
} from "../promise"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
@@ -120,6 +127,16 @@ function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
}
|
||||
|
||||
function formRequestOptions(sessionID: string, ref?: LocationRef) {
|
||||
if (sessionID !== "global" || !ref) return undefined
|
||||
return {
|
||||
headers: {
|
||||
"x-opencode-directory": encodeURIComponent(ref.directory),
|
||||
...(ref.workspaceID ? { "x-opencode-workspace": ref.workspaceID } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createSync() {
|
||||
type Pending = { promise: Promise<void>; invalidated: boolean }
|
||||
const state = new Map<string, true | Pending>()
|
||||
@@ -228,6 +245,32 @@ export function createData(config: CreateDataInput) {
|
||||
)
|
||||
}
|
||||
|
||||
function removeForm(sessionID: string, formID: string, ref?: LocationRef) {
|
||||
const forms = store.session.form[sessionID]
|
||||
if (!forms) return false
|
||||
const location = ref && locationKey(ref)
|
||||
const next = forms.filter((form) => {
|
||||
if (form.id !== formID) return true
|
||||
if (sessionID !== "global" || !location) return false
|
||||
return !form.location || locationKey(form.location) !== location
|
||||
})
|
||||
if (next.length === forms.length) return false
|
||||
setStore("session", "form", sessionID, next)
|
||||
return true
|
||||
}
|
||||
|
||||
function settleForm(input: FormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
|
||||
return request
|
||||
.catch((error: unknown) => {
|
||||
if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID) throw error
|
||||
})
|
||||
.then(() => {
|
||||
if (!removeForm(input.sessionID, input.formID, ref)) return
|
||||
result.session.form.invalidate(input.sessionID, ref)
|
||||
void result.session.form.sync(input.sessionID, ref).catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
function updatePending(sessionID: string, inboxID: string, delivery: SessionInbox.Delivery) {
|
||||
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inboxID) ?? -1
|
||||
const item = store.session.pending[sessionID]?.[index]
|
||||
@@ -998,12 +1041,7 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "form.replied":
|
||||
case "form.cancelled":
|
||||
setStore(
|
||||
"session",
|
||||
"form",
|
||||
event.data.sessionID,
|
||||
(store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id),
|
||||
)
|
||||
removeForm(event.data.sessionID, event.data.id, event.location)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1420,6 +1458,12 @@ export function createData(config: CreateDataInput) {
|
||||
`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`,
|
||||
)
|
||||
},
|
||||
reply(input: FormReplyInput, ref?: LocationRef) {
|
||||
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)))
|
||||
},
|
||||
cancel(input: FormCancelInput, ref?: LocationRef) {
|
||||
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)))
|
||||
},
|
||||
},
|
||||
},
|
||||
project: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,7 +48,7 @@ async function generate() {
|
||||
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
|
||||
),
|
||||
)
|
||||
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
|
||||
await Bun.write(snapshot, await formatJson(await Bun.file(path.join(incremental, name, "snapshot.json")).text()))
|
||||
}
|
||||
|
||||
await fs.mkdir(full)
|
||||
@@ -192,6 +192,19 @@ async function formatTypescript(input: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// Drizzle emits every array multi-line; format the snapshot so regeneration
|
||||
// diffs stay minimal against the prettier-styled checked-in copy.
|
||||
async function formatJson(input: string) {
|
||||
const prettier = await import("prettier")
|
||||
const babel = await import("prettier/plugins/babel")
|
||||
const estree = await import("prettier/plugins/estree")
|
||||
return prettier.format(input, {
|
||||
parser: "json",
|
||||
plugins: [babel.default, estree.default],
|
||||
printWidth: 120,
|
||||
})
|
||||
}
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration.js"
|
||||
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}.js"`).join("\n")}
|
||||
|
||||
+2
@@ -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
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types.js"
|
||||
import { localShellInputSchema, localShellOutputSchema } from "./tool/local-shell.js"
|
||||
|
||||
/**
|
||||
* Check if a string is a file ID based on the given prefixes
|
||||
@@ -23,13 +22,11 @@ export async function convertToOpenAIResponsesInput({
|
||||
systemMessageMode,
|
||||
fileIdPrefixes,
|
||||
store,
|
||||
hasLocalShellTool = false,
|
||||
}: {
|
||||
prompt: LanguageModelV3Prompt
|
||||
systemMessageMode: "system" | "developer" | "remove"
|
||||
fileIdPrefixes?: readonly string[]
|
||||
store: boolean
|
||||
hasLocalShellTool?: boolean
|
||||
}): Promise<{
|
||||
input: OpenAIResponsesInput
|
||||
warnings: Array<SharedV3Warning>
|
||||
@@ -138,25 +135,6 @@ export async function convertToOpenAIResponsesInput({
|
||||
break
|
||||
}
|
||||
|
||||
if (hasLocalShellTool && part.toolName === "local_shell") {
|
||||
const parsedInput = localShellInputSchema.parse(part.input)
|
||||
input.push({
|
||||
type: "local_shell_call",
|
||||
call_id: part.toolCallId,
|
||||
id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined,
|
||||
action: {
|
||||
type: "exec",
|
||||
command: parsedInput.action.command,
|
||||
timeout_ms: parsedInput.action.timeoutMs,
|
||||
user: parsedInput.action.user,
|
||||
working_directory: parsedInput.action.workingDirectory,
|
||||
env: parsedInput.action.env,
|
||||
},
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
input.push({
|
||||
type: "function_call",
|
||||
call_id: part.toolCallId,
|
||||
@@ -261,15 +239,6 @@ export async function convertToOpenAIResponsesInput({
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLocalShellTool && part.toolName === "local_shell" && output.type === "json") {
|
||||
input.push({
|
||||
type: "local_shell_call_output",
|
||||
call_id: part.toolCallId,
|
||||
output: localShellOutputSchema.parse(output.value).output,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
let contentValue: string
|
||||
switch (output.type) {
|
||||
case "text":
|
||||
|
||||
@@ -9,8 +9,6 @@ export type OpenAIResponsesInputItem =
|
||||
| OpenAIResponsesFunctionCall
|
||||
| OpenAIResponsesFunctionCallOutput
|
||||
| OpenAIResponsesComputerCall
|
||||
| OpenAIResponsesLocalShellCall
|
||||
| OpenAIResponsesLocalShellCallOutput
|
||||
| OpenAIResponsesReasoning
|
||||
| OpenAIResponsesItemReference
|
||||
| OpenAIResponsesMcpApprovalResponse
|
||||
@@ -69,26 +67,6 @@ export type OpenAIResponsesComputerCall = {
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type OpenAIResponsesLocalShellCall = {
|
||||
type: "local_shell_call"
|
||||
id?: string
|
||||
call_id: string
|
||||
action: {
|
||||
type: "exec"
|
||||
command: string[]
|
||||
timeout_ms?: number
|
||||
user?: string
|
||||
working_directory?: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAIResponsesLocalShellCallOutput = {
|
||||
type: "local_shell_call_output"
|
||||
call_id: string
|
||||
output: string
|
||||
}
|
||||
|
||||
export type OpenAIResponsesItemReference = {
|
||||
type: "item_reference"
|
||||
id: string
|
||||
@@ -199,9 +177,6 @@ export type OpenAIResponsesTool =
|
||||
quality: "auto" | "low" | "medium" | "high" | undefined
|
||||
size: "auto" | "1024x1024" | "1024x1536" | "1536x1024" | undefined
|
||||
}
|
||||
| {
|
||||
type: "local_shell"
|
||||
}
|
||||
|
||||
export type OpenAIResponsesReasoning = {
|
||||
type: "reasoning"
|
||||
|
||||
@@ -29,7 +29,6 @@ import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-rea
|
||||
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
|
||||
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
|
||||
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
|
||||
import { localShellInputSchema } from "./tool/local-shell.js"
|
||||
|
||||
const webSearchCallItem = z.object({
|
||||
type: z.literal("web_search_call"),
|
||||
@@ -86,20 +85,6 @@ const codeInterpreterCallItem = z.object({
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
const localShellCallItem = z.object({
|
||||
type: z.literal("local_shell_call"),
|
||||
id: z.string(),
|
||||
call_id: z.string(),
|
||||
action: z.object({
|
||||
type: z.literal("exec"),
|
||||
command: z.array(z.string()),
|
||||
timeout_ms: z.number().optional(),
|
||||
user: z.string().optional(),
|
||||
working_directory: z.string().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const imageGenerationCallItem = z.object({
|
||||
type: z.literal("image_generation_call"),
|
||||
id: z.string(),
|
||||
@@ -205,7 +190,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
systemMessageMode: modelConfig.systemMessageMode,
|
||||
fileIdPrefixes: this.config.fileIdPrefixes,
|
||||
store,
|
||||
hasLocalShellTool: hasOpenAITool("openai.local_shell"),
|
||||
})
|
||||
|
||||
warnings.push(...inputWarnings)
|
||||
@@ -462,7 +446,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
fileSearchCallItem,
|
||||
codeInterpreterCallItem,
|
||||
imageGenerationCallItem,
|
||||
localShellCallItem,
|
||||
z.object({
|
||||
type: z.literal("function_call"),
|
||||
call_id: z.string(),
|
||||
@@ -560,22 +543,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
break
|
||||
}
|
||||
|
||||
case "local_shell_call": {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.call_id,
|
||||
toolName: "local_shell",
|
||||
input: JSON.stringify({ action: part.action } satisfies z.infer<typeof localShellInputSchema>),
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
itemId: part.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case "message": {
|
||||
for (const contentPart of part.content) {
|
||||
if (options.providerOptions?.copilot?.logprobs && contentPart.logprobs) {
|
||||
@@ -1093,27 +1060,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
result: value.item.result,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
})
|
||||
} else if (value.item.type === "local_shell_call") {
|
||||
ongoingToolCalls[value.output_index] = undefined
|
||||
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.call_id,
|
||||
toolName: "local_shell",
|
||||
input: JSON.stringify({
|
||||
action: {
|
||||
type: "exec",
|
||||
command: value.item.action.command,
|
||||
timeoutMs: value.item.action.timeout_ms,
|
||||
user: value.item.action.user,
|
||||
workingDirectory: value.item.action.working_directory,
|
||||
env: value.item.action.env,
|
||||
},
|
||||
} satisfies z.infer<typeof localShellInputSchema>),
|
||||
providerMetadata: {
|
||||
copilot: { itemId: value.item.id },
|
||||
},
|
||||
})
|
||||
} else if (value.item.type === "message") {
|
||||
if (currentTextId) {
|
||||
controller.enqueue({
|
||||
@@ -1528,7 +1474,6 @@ const responseOutputItemDoneSchema = z.object({
|
||||
imageGenerationCallItem,
|
||||
webSearchCallItem,
|
||||
fileSearchCallItem,
|
||||
localShellCallItem,
|
||||
z.object({
|
||||
type: z.literal("computer_call"),
|
||||
id: z.string(),
|
||||
|
||||
@@ -70,12 +70,6 @@ export function prepareResponsesTools({
|
||||
|
||||
break
|
||||
}
|
||||
case "openai.local_shell": {
|
||||
openaiTools.push({
|
||||
type: "local_shell",
|
||||
})
|
||||
break
|
||||
}
|
||||
case "openai.web_search_preview": {
|
||||
const args = webSearchPreviewArgsSchema.parse(tool.args)
|
||||
openaiTools.push({
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export const localShellInputSchema = z.object({
|
||||
action: z.object({
|
||||
type: z.literal("exec"),
|
||||
command: z.array(z.string()),
|
||||
timeoutMs: z.number().optional(),
|
||||
user: z.string().optional(),
|
||||
workingDirectory: z.string().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const localShellOutputSchema = z.object({
|
||||
output: z.string(),
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -273,28 +273,53 @@ export const layer = (options?: Options) =>
|
||||
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
let current: Credential.OAuth | undefined = found.value
|
||||
const integrationID = entry.integrationID
|
||||
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
|
||||
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
|
||||
let presented = found.value.refresh
|
||||
const readOAuthCredential = async () => {
|
||||
const stored = await Effect.runPromise(credentials.list(integrationID))
|
||||
const match = stored.find((credential) => credential.id === credentialID)
|
||||
return match && match.value.type === "oauth" ? match.value : undefined
|
||||
}
|
||||
return MCPOAuth.provider({
|
||||
...base,
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
|
||||
// credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
|
||||
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
|
||||
// connection may have already replaced ours; deleting then would discard the newer valid credential and
|
||||
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
|
||||
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
current = undefined
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth || oauth.refresh !== presented) return
|
||||
await Effect.runPromise(credentials.remove(credentialID))
|
||||
},
|
||||
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
|
||||
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
|
||||
// token fails with invalid_grant.
|
||||
store: {
|
||||
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
|
||||
tokens: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth) return undefined
|
||||
presented = oauth.refresh
|
||||
return MCPOAuth.toTokens(oauth)
|
||||
},
|
||||
saveTokens: async (tokens) => {
|
||||
current = MCPOAuth.toCredential({
|
||||
const previous = await readOAuthCredential()
|
||||
const value = MCPOAuth.toCredential({
|
||||
methodID,
|
||||
serverUrl: remote.url,
|
||||
tokens,
|
||||
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
|
||||
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
|
||||
})
|
||||
await Effect.runPromise(credentials.update(credentialID, { value: current }))
|
||||
presented = value.refresh
|
||||
await Effect.runPromise(credentials.update(credentialID, { value }))
|
||||
},
|
||||
clientInformation: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
|
||||
},
|
||||
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
|
||||
saveClientInformation: async () => {},
|
||||
codeVerifier: async () => undefined,
|
||||
saveCodeVerifier: async () => {},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -30,13 +30,7 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
* with this execution's exit.
|
||||
*/
|
||||
type Execution<E, Reason> = {
|
||||
/**
|
||||
* Resolves with the execution's exit as a success value. Success-valued on purpose:
|
||||
* completing a Deferred with an interrupted exit interrupts suspended waiters as it
|
||||
* resumes them, and can starve later waiters of their resume entirely
|
||||
* (Effect-TS/effect#7364). Joiners flatten the exit; idleness waiters just await.
|
||||
*/
|
||||
readonly done: Deferred.Deferred<Exit.Exit<void, E>>
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void>
|
||||
scope: Promotable
|
||||
pendingWake?: Promotable
|
||||
@@ -84,7 +78,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
|
||||
const start = (key: Key, force: boolean, scope: Promotable) => {
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<Exit.Exit<void, E>>(),
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
scope,
|
||||
stopping: false,
|
||||
}
|
||||
@@ -114,7 +108,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, Exit.succeed(exit))
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
@@ -122,10 +116,11 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
// A stopping execution refuses joiners: wait out its cleanup, then run fresh.
|
||||
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done).pipe(Effect.flatten)
|
||||
if (execution.stopping)
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done)
|
||||
}
|
||||
return Deferred.await(start(key, true, "input").done).pipe(Effect.flatten)
|
||||
return Deferred.await(start(key, true, "input").done)
|
||||
})
|
||||
|
||||
const wake = (key: Key, scope: Promotable = "input") =>
|
||||
@@ -167,7 +162,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined) return Effect.void
|
||||
return Deferred.await(execution.done).pipe(Effect.andThen(awaitIdle(key)))
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Cause, Config, Data, Effect, Exit, Fiber, FiberMap, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
@@ -165,9 +165,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
// The in-flight set coalesces overlapping prompts while title presence records success durably.
|
||||
const titlesRunning = new Set<SessionSchema.ID>()
|
||||
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
|
||||
/**
|
||||
* Drains eligible manual compaction and user input until the Session becomes idle.
|
||||
* Execution lifecycle is published per busy period by SessionExecution, not here.
|
||||
@@ -334,7 +332,10 @@ const layer = Layer.effect(
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
|
||||
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
|
||||
if (promoted > 0) yield* startTitle(sessionID)
|
||||
if (promoted > 0)
|
||||
yield* FiberMap.run(titles, sessionID, title.generateForFirstPrompt(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
// Promoted input opens a fresh step allowance.
|
||||
const currentStep = promoted > 0 ? 1 : step
|
||||
const loaded = yield* context.load(selected)
|
||||
@@ -715,22 +716,6 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
/** Starts one title request at a time after a successful step makes user input visible. */
|
||||
const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
if (titlesRunning.has(sessionID)) return
|
||||
titlesRunning.add(sessionID)
|
||||
forkTitle(
|
||||
title.generateForFirstPrompt(sessionID).pipe(
|
||||
Effect.ignore,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
titlesRunning.delete(sessionID)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
|
||||
@@ -90,6 +90,7 @@ const layer = Layer.effect(
|
||||
) {
|
||||
const beforeEvent: PluginHooks.Domains["tool"]["execute.before"] = {
|
||||
tool: name,
|
||||
inputSchema: definition(tool).inputSchema,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
|
||||
@@ -44,7 +44,38 @@ export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool
|
||||
}
|
||||
})
|
||||
|
||||
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
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) => {
|
||||
if (Schema.isSchema(schema))
|
||||
return Schema.decodeUnknownEffect(schema)(value).pipe(
|
||||
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
|
||||
|
||||
+121
-38
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -455,7 +455,7 @@ describe("Plugin", () => {
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
const seen: {
|
||||
before?: unknown
|
||||
before?: { input: unknown; inputSchema: unknown }
|
||||
after?: { input: unknown; status: string; content: unknown; metadata: unknown }
|
||||
} = {}
|
||||
|
||||
@@ -480,7 +480,7 @@ describe("Plugin", () => {
|
||||
yield* ctx.tool
|
||||
.hook("execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.before = event.input
|
||||
seen.before = { input: event.input, inputSchema: event.inputSchema }
|
||||
event.input = { text: "before-mutated" }
|
||||
}),
|
||||
)
|
||||
@@ -526,7 +526,15 @@ describe("Plugin", () => {
|
||||
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
|
||||
})
|
||||
|
||||
expect(seen.before).toEqual({ text: "original" })
|
||||
expect(seen.before).toEqual({
|
||||
input: { text: "original" },
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
required: ["text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ text: "before-mutated" }])
|
||||
expect(seen.after).toEqual({
|
||||
input: { text: "before-mutated" },
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -309,14 +309,20 @@ describe("SessionRunCoordinator", () => {
|
||||
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||
})
|
||||
|
||||
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
const second = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* coordinator.wake("session")
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* Deferred.await(interrupted)
|
||||
|
||||
const exit = yield* Fiber.await(resumed)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
const exits = yield* Fiber.awaitAll([first, second, idle])
|
||||
expect(
|
||||
exits.slice(0, 2).every((exit) => Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)),
|
||||
).toBeTrue()
|
||||
expect(exits.slice(2).every(Exit.isSuccess)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(runs).toBe(1)
|
||||
expect(reasons).toEqual(["user"])
|
||||
|
||||
@@ -897,30 +897,38 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
])
|
||||
})
|
||||
|
||||
const prepareTitleGeneration = Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "Generate a title."
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const watchRename = Effect.fnUntraced(function* (sessionID: Session.ID) {
|
||||
const bus = yield* Bus.Service
|
||||
return yield* bus.subscribe(SessionEvent.Renamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("generates the title while the first model step is still running", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* Agent.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "Generate a title."
|
||||
}),
|
||||
)
|
||||
yield* prepareTitleGeneration
|
||||
|
||||
yield* admit(session, "First prompt")
|
||||
yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never)
|
||||
const bus = yield* Bus.Service
|
||||
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const renamed = yield* watchRename(sessionID)
|
||||
const runner = yield* SessionRunner.Service
|
||||
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
yield* Fiber.join(renamed)
|
||||
@@ -930,19 +938,46 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces title generation while a request is active", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* prepareTitleGeneration
|
||||
|
||||
const titleStarted = yield* Deferred.make<void>()
|
||||
const releaseTitle = yield* Deferred.make<void>()
|
||||
yield* Effect.gen(function* () {
|
||||
yield* admit(session, "First prompt")
|
||||
yield* TestLLM.push(
|
||||
Stream.unwrap(
|
||||
Deferred.succeed(titleStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseTitle)),
|
||||
Effect.as(Stream.fromIterable(TestLLM.text("Generated title", "text-title"))),
|
||||
),
|
||||
),
|
||||
TestLLM.text("First response", "text-first"),
|
||||
TestLLM.text("Second response", "text-second"),
|
||||
)
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(titleStarted).pipe(Effect.timeout("5 seconds"))
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Generate a title.")
|
||||
yield* Fiber.join(first)
|
||||
yield* admit(session, "Second prompt")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
const renamed = yield* watchRename(sessionID)
|
||||
yield* Deferred.succeed(releaseTitle, undefined)
|
||||
yield* Fiber.join(renamed)
|
||||
expect((yield* session.get(sessionID)).title).toBe("Generated title")
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(releaseTitle, undefined)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries title generation from the first prompt after title and execution failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* Agent.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "Generate a title."
|
||||
}),
|
||||
)
|
||||
yield* prepareTitleGeneration
|
||||
|
||||
yield* admit(session, "First prompt")
|
||||
yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest()))
|
||||
@@ -961,13 +996,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* Effect.yieldNow
|
||||
expect((yield* session.get(sessionID)).title).toBeUndefined()
|
||||
|
||||
const bus = yield* Bus.Service
|
||||
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const renamed = yield* watchRename(sessionID)
|
||||
yield* admit(session, "Third prompt")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("Generated title", "text-title"),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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" },
|
||||
])
|
||||
})
|
||||
@@ -47,7 +47,7 @@ test("Effect tool schemas use exact optional keys and flatten compatible constra
|
||||
type: "object",
|
||||
properties: {
|
||||
offset: { type: "integer", minimum: 0 },
|
||||
code: { type: "string", allOf: [{ pattern: "^a" }, { pattern: "z$" }] },
|
||||
code: { type: "string", pattern: "^a", allOf: [{ pattern: "z$" }] },
|
||||
},
|
||||
required: ["code"],
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
return AppRpcs.of({
|
||||
AppAwaitInitialization: () => background.connection,
|
||||
AppReconnectService: () => background.reconnect,
|
||||
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
|
||||
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
|
||||
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),
|
||||
|
||||
@@ -106,6 +106,7 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
supportsSpanPropagation: false,
|
||||
supportsNotifications: true,
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { BackgroundServiceState } from "./background-service-state"
|
||||
|
||||
test("new consumers receive the latest reconnected service", async () => {
|
||||
const initial = { url: "http://127.0.0.1:4100", username: "opencode", password: "first" }
|
||||
const replacement = { url: "http://127.0.0.1:4200", username: "opencode", password: "second" }
|
||||
const service = await Effect.runPromise(
|
||||
BackgroundServiceState.make({ initial: Effect.succeed(initial), reconnect: Effect.succeed(replacement) }),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(service.connection)).toEqual(initial)
|
||||
expect(await Effect.runPromise(service.reconnect)).toEqual(replacement)
|
||||
expect(await Effect.runPromise(service.connection)).toEqual(replacement)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export * as BackgroundServiceState from "./background-service-state"
|
||||
|
||||
import { Effect, Exit, Ref } from "effect"
|
||||
import type { ServerReadyData } from "../../shared/ipc-contract"
|
||||
|
||||
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
|
||||
readonly initial: Effect.Effect<ServerReadyData, unknown>
|
||||
readonly reconnect: Effect.Effect<ServerReadyData>
|
||||
}) {
|
||||
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
|
||||
const current = yield* Ref.make<Exit.Exit<ServerReadyData, unknown>>(yield* options.initial.pipe(Effect.exit))
|
||||
return {
|
||||
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
|
||||
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
|
||||
}
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, Exit, Layer, Path } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import type { ServerReadyData } from "../../shared/ipc-contract"
|
||||
import { BackgroundServiceState } from "./background-service-state"
|
||||
import { cleanStages, DesktopCli } from "./desktop-cli"
|
||||
|
||||
export * as BackgroundService from "./background-service"
|
||||
|
||||
export interface Interface {
|
||||
readonly connection: Effect.Effect<ServerReadyData>
|
||||
readonly reconnect: Effect.Effect<ServerReadyData>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/BackgroundService") {}
|
||||
@@ -14,22 +16,24 @@ export class Service extends Context.Service<Service, Interface>()("opencode/des
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const result = yield* start().pipe(Effect.exit)
|
||||
return Service.of({
|
||||
connection: Exit.isSuccess(result)
|
||||
? Effect.succeed(result.value)
|
||||
: Effect.failCause(result.cause).pipe(Effect.orDie),
|
||||
})
|
||||
const context = yield* Effect.context<FileSystem.FileSystem | Path.Path | DesktopCli.Service>()
|
||||
return Service.of(
|
||||
yield* BackgroundServiceState.make({
|
||||
initial: connect("initial").pipe(Effect.provide(context)),
|
||||
reconnect: connect("reconnect").pipe(Effect.provide(context), Effect.orDie),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const start = Effect.fn("BackgroundService.start")(function* () {
|
||||
const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial" | "reconnect") {
|
||||
yield* Effect.logInfo("starting v2 background service")
|
||||
const path = yield* Path.Path
|
||||
const desktopCli = yield* DesktopCli.Service
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
|
||||
const cli = yield* desktopCli.resolve
|
||||
const version = mode === "initial" ? cli.version : undefined
|
||||
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
|
||||
const client = yield* Effect.promise(() => import("@opencode-ai/client/service"))
|
||||
const service = yield* Effect.tryPromise(() =>
|
||||
@@ -38,7 +42,7 @@ const start = Effect.fn("BackgroundService.start")(function* () {
|
||||
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
|
||||
? path.join(app.getPath("userData"), "opencode", "service-local.json")
|
||||
: undefined,
|
||||
version: cli.version,
|
||||
version,
|
||||
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
|
||||
onStart: (reason, previousVersion) =>
|
||||
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
|
||||
@@ -49,10 +53,10 @@ const start = Effect.fn("BackgroundService.start")(function* () {
|
||||
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
|
||||
yield* Effect.logInfo("v2 CLI background service ready", {
|
||||
username: service.auth.username,
|
||||
version: cli.version,
|
||||
version,
|
||||
...endpoint(url.origin),
|
||||
})
|
||||
if (isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
|
||||
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
|
||||
return {
|
||||
url: url.origin,
|
||||
username: service.auth.username,
|
||||
|
||||
@@ -22,6 +22,7 @@ export type UpdaterAPI = {
|
||||
|
||||
export type ElectronAPI = {
|
||||
awaitInitialization(): Promise<ServerReadyData>
|
||||
reconnectService(): Promise<ServerReadyData>
|
||||
wslServers: WslServersAPI
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks(): Promise<string[]>
|
||||
|
||||
@@ -24,6 +24,7 @@ const updaterHandler = (state: UpdaterState) => {
|
||||
|
||||
export const api: ElectronAPI = {
|
||||
awaitInitialization: () => invoke("AppAwaitInitialization"),
|
||||
reconnectService: () => invoke("AppReconnectService"),
|
||||
wslServers: {
|
||||
getState: () => invoke("WslGetState").then(mutable),
|
||||
subscribe: (cb) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { ElectronAPI } from "./api-types"
|
||||
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
||||
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
|
||||
import { bindDesktopMenu } from "./platform/menu"
|
||||
import { initializationData } from "./startup/initialization"
|
||||
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
|
||||
import { preloadStoredLocale } from "./startup/locale"
|
||||
import { LoadingSplash } from "./startup/splash"
|
||||
import { getLastActiveUrl } from "./window/route-storage"
|
||||
@@ -75,7 +75,7 @@ function DesktopWindow(props: {
|
||||
onRoute: (route: LayoutRoute) => void
|
||||
}) {
|
||||
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
|
||||
const [sidecar] = createResource(() => props.api.awaitInitialization())
|
||||
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
|
||||
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
|
||||
const [locale] = createResource(() => preloadStoredLocale(platform))
|
||||
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
|
||||
@@ -97,11 +97,8 @@ function DesktopWindow(props: {
|
||||
displayName: language.t("desktop.server.local"),
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: {
|
||||
url: data.url,
|
||||
username: data.username ?? undefined,
|
||||
password: data.password ?? undefined,
|
||||
},
|
||||
http: sidecarHttp(data),
|
||||
reconnect: createSidecarResolver({ api: props.api, current: sidecar, update: setSidecar }),
|
||||
})
|
||||
}
|
||||
list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label")))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { initializationData } from "./initialization"
|
||||
import { createSidecarResolver, initializationData } from "./initialization"
|
||||
|
||||
describe("desktop renderer initialization", () => {
|
||||
test("throws the original initialization error before rendering server providers", () => {
|
||||
@@ -45,4 +45,48 @@ describe("desktop renderer initialization", () => {
|
||||
expect(caught.message).toBe("")
|
||||
expect((caught as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true)
|
||||
})
|
||||
|
||||
test("refreshes the managed sidecar endpoint", async () => {
|
||||
const sidecar = { url: "http://127.0.0.1:4321", username: "opencode", password: "next" }
|
||||
const updates: (typeof sidecar)[] = []
|
||||
const resolve = createSidecarResolver({
|
||||
api: { reconnectService: async () => sidecar },
|
||||
current: () => undefined,
|
||||
update: (next) => updates.push(next),
|
||||
})
|
||||
|
||||
expect(await resolve(new AbortController().signal)).toEqual(sidecar)
|
||||
expect(updates).toEqual([sidecar])
|
||||
})
|
||||
|
||||
test("keeps the current sidecar when reconnection resolves the same endpoint", async () => {
|
||||
const sidecar = { url: "http://127.0.0.1:4321", username: "opencode", password: "same" }
|
||||
const updates: (typeof sidecar)[] = []
|
||||
const resolve = createSidecarResolver({
|
||||
api: { reconnectService: async () => ({ ...sidecar }) },
|
||||
current: () => sidecar,
|
||||
update: (next) => updates.push(next),
|
||||
})
|
||||
|
||||
expect(await resolve(new AbortController().signal)).toEqual(sidecar)
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
test("does not publish a sidecar resolved after cancellation", async () => {
|
||||
const sidecar = { url: "http://127.0.0.1:4321", username: "opencode", password: "next" }
|
||||
const pending = Promise.withResolvers<typeof sidecar>()
|
||||
const updates: (typeof sidecar)[] = []
|
||||
const resolve = createSidecarResolver({
|
||||
api: { reconnectService: () => pending.promise },
|
||||
current: () => undefined,
|
||||
update: (next) => updates.push(next),
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const result = resolve(abort.signal)
|
||||
abort.abort()
|
||||
pending.resolve(sidecar)
|
||||
|
||||
await expect(result).rejects.toBe(abort.signal.reason)
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
|
||||
type SidecarData = Awaited<ReturnType<ElectronAPI["awaitInitialization"]>>
|
||||
|
||||
export function initializationData<A>(state: (() => A | undefined) & { error: unknown }) {
|
||||
if (state.error !== undefined) throw markLocalServerStartup(state.error)
|
||||
return state()
|
||||
}
|
||||
|
||||
export function sidecarHttp(data: SidecarData) {
|
||||
return {
|
||||
url: data.url,
|
||||
username: data.username ?? undefined,
|
||||
password: data.password ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSidecarResolver(input: {
|
||||
api: Pick<ElectronAPI, "reconnectService">
|
||||
current: () => SidecarData | undefined
|
||||
update: (data: SidecarData) => void
|
||||
}) {
|
||||
return async (signal: AbortSignal) => {
|
||||
if (signal.aborted) throw signal.reason
|
||||
const next = await input.api.reconnectService()
|
||||
if (signal.aborted) throw signal.reason
|
||||
if (!sameSidecar(input.current(), next)) input.update(next)
|
||||
return sidecarHttp(next)
|
||||
}
|
||||
}
|
||||
|
||||
function sameSidecar(current: SidecarData | undefined, next: SidecarData) {
|
||||
return current?.url === next.url && current.username === next.username && current.password === next.password
|
||||
}
|
||||
|
||||
function markLocalServerStartup(error: unknown) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
Object.defineProperty(failure, "localServerStartup", { value: true })
|
||||
|
||||
@@ -8,6 +8,7 @@ const ServerReadyData = Schema.Struct({
|
||||
})
|
||||
|
||||
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
|
||||
export const AppReconnectService = Rpc.make("AppReconnectService", { success: ServerReadyData })
|
||||
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
|
||||
success: Schema.Array(Schema.String),
|
||||
})
|
||||
@@ -56,6 +57,7 @@ export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
|
||||
export const AppRelaunch = Rpc.make("AppRelaunch")
|
||||
export const AppRpcs = RpcGroup.make(
|
||||
AppAwaitInitialization,
|
||||
AppReconnectService,
|
||||
AppConsumeInitialDeepLinks,
|
||||
AppGetDefaultServerUrl,
|
||||
AppSetDefaultServerUrl,
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110"
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolDraft {
|
||||
@@ -13,6 +14,7 @@ export interface ToolDraft {
|
||||
export interface ToolHooks {
|
||||
readonly "execute.before": {
|
||||
readonly tool: string
|
||||
readonly inputSchema: JsonSchema.JsonSchema
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolContext extends Omit<Tool.Context, "progress"> {
|
||||
@@ -30,6 +31,7 @@ interface ToolDraft {
|
||||
interface ToolHooks {
|
||||
readonly "execute.before": {
|
||||
readonly tool: string
|
||||
readonly inputSchema: JsonSchema.JsonSchema
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormCancelInput,
|
||||
FormInfo,
|
||||
FormReplyInput,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
McpResource,
|
||||
@@ -91,6 +93,8 @@ export interface Data {
|
||||
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
|
||||
sync(sessionID: string, location?: LocationRef): Promise<void>
|
||||
invalidate(sessionID: string, location?: LocationRef): void
|
||||
reply(input: FormReplyInput, location?: LocationRef): Promise<void>
|
||||
cancel(input: FormCancelInput, location?: LocationRef): Promise<void>
|
||||
}
|
||||
}
|
||||
readonly project: {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1282,7 +1282,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
onMouseUp={
|
||||
copyOnSelectEnabled() ? (event) => Selection.copyOnSelectRelease(event, renderer, toast, clipboard) : undefined
|
||||
}
|
||||
>
|
||||
<box
|
||||
flexGrow={1}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user