mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 02:26:24 +00:00
Compare commits
17
Commits
cli-script-resolve
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73974dc668 | ||
|
|
1593479454 | ||
|
|
971681c567 | ||
|
|
378a7b7be1 | ||
|
|
6d57667168 | ||
|
|
d4935360c5 | ||
|
|
89281dcd01 | ||
|
|
9f9184b002 | ||
|
|
c6f4fa0d1f | ||
|
|
c505c91438 | ||
|
|
8887c2ffa9 | ||
|
|
ddaf9ff433 | ||
|
|
d3e5d6d268 | ||
|
|
f9bc948912 | ||
|
|
6f238feeff | ||
|
|
e867a21ea5 | ||
|
|
64c0411edb |
@@ -84,6 +84,11 @@ jobs:
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Verify published codemode package
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/codemode
|
||||
run: bun run script/publish.ts --dry-run
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -671,6 +671,7 @@
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -1054,7 +1055,6 @@
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
@@ -1116,8 +1116,8 @@
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
@@ -1683,7 +1683,7 @@
|
||||
|
||||
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -3217,12 +3217,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=="],
|
||||
@@ -3835,9 +3829,9 @@
|
||||
|
||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-rc.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TRxUmj1wDA2QCt3GvuhfamvIa66wJ7+MzSxBMKkpRtYScjHTumT9BE+x6daSzuEacSrPEuUH5/cW1uo5RkoPIg=="],
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-rc.5-ab785fc", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TSg7sK1finOxdo48O9mDfyMY3lhi/aHCVIil5TFhthyq/H/89H/3nwX5V9Gdp7HYMUGFHyWJD5iowSn98l0elw=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-rc.5-169397b", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@drizzle-team/minipg": ">=0.3.2", "@effect/sql-d1": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-libsql": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-mysql2": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-pg": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-pglite": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-bun": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-do": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-node": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-wasm": ">=4.0.0-beta.105 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=17", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.7.1", "@tursodatabase/database-common": ">=0.7.1", "@tursodatabase/database-wasm": ">=0.7.1", "@tursodatabase/serverless": ">=1.4.0", "@tursodatabase/sync": ">=0.7.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.105 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.2.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@drizzle-team/minipg", "@effect/sql-d1", "@effect/sql-libsql", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/sql-pglite", "@effect/sql-sqlite-bun", "@effect/sql-sqlite-do", "@effect/sql-sqlite-node", "@effect/sql-sqlite-wasm", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@tursodatabase/serverless", "@tursodatabase/sync", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-xf5TudQhFplMMzjwgmMY5f64Sqbv4iwXxdTHBHqvLMt+SHyY8rAYhzNLQchEkDxiqMclTS/txPEOg5pdQy0Tcw=="],
|
||||
|
||||
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
||||
|
||||
@@ -3981,8 +3975,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 +4249,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 +4613,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 +4697,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 +4813,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 +4837,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 +4851,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 +5433,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 +5825,6 @@
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
|
||||
|
||||
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||
|
||||
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
@@ -6423,10 +6397,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=="],
|
||||
@@ -6529,6 +6499,8 @@
|
||||
|
||||
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
||||
|
||||
"db0/drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
|
||||
|
||||
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
@@ -6565,10 +6537,6 @@
|
||||
|
||||
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
@@ -7325,8 +7293,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-pnyqHqM8nt1Dh/d6xRm8DploZG9PiaO3HWGVIFyew54=",
|
||||
"aarch64-linux": "sha256-cWPfoDku1bFN7zwCy6rsLP/WMmp6hEpO3Ous3FPGcfo=",
|
||||
"aarch64-darwin": "sha256-2S3opWupuF3AncBbZLd5B7IsrUxYxA4S1mKWy58gMlo=",
|
||||
"x86_64-darwin": "sha256-fu6WCBh9CWqsk8ACvLdeleP55uZ08i8MfEPFqgt7ob0="
|
||||
"x86_64-linux": "sha256-iou+Ej/822crZq1EwR/eX7WzNVAf1sKKjc3eAr2pMJE=",
|
||||
"aarch64-linux": "sha256-XYLgqMyZHClngu+LZVR2JngY/0f4U4cbiGkuMc3nr40=",
|
||||
"aarch64-darwin": "sha256-rUtWNzK3s56LT4aEuoN4nSf1a1+AhWklBjz/GrhnTKU=",
|
||||
"x86_64-darwin": "sha256-7BTJEefqMLddRM7eWbPcPQdGTkszXcucazkDffaXm/0="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -71,8 +71,8 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -166,7 +166,6 @@
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
|
||||
@@ -54,7 +54,7 @@ export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({ readonly type: "enabled" } & (
|
||||
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
@@ -213,6 +213,7 @@ const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
@@ -615,15 +616,12 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
if (input.type === "adaptive") {
|
||||
const display =
|
||||
input.display === "summarized"
|
||||
? ("summarized" as const)
|
||||
: input.display === "omitted"
|
||||
? ("omitted" as const)
|
||||
: undefined
|
||||
const display =
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive")
|
||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
}
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "enabled") return undefined
|
||||
const budget =
|
||||
@@ -634,7 +632,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget }
|
||||
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
|
||||
@@ -112,6 +112,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
responseID = created
|
||||
return { type: "frame", frame }
|
||||
}
|
||||
// Keepalives carry no response state and may arrive before response.created.
|
||||
if (event.type === "keepalive") return { type: "frame", frame }
|
||||
if (!responseID)
|
||||
return yield* ProviderShared.eventError(
|
||||
options.id,
|
||||
|
||||
@@ -362,7 +362,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
|
||||
type: "function" as const,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: ToolSchemaProjection.responses(inputSchema),
|
||||
parameters: inputSchema,
|
||||
// The common tool definition does not currently express Responses strict-schema policy.
|
||||
strict: false,
|
||||
}
|
||||
@@ -582,8 +582,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
flushText()
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
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
|
||||
if (store === false) {
|
||||
// The server is not storing this exchange, so the tool outcome has to
|
||||
// travel in the input. Non-content results degrade to their text form.
|
||||
const content: ReadonlyArray<Content> =
|
||||
part.result.type === "content"
|
||||
? part.result.value
|
||||
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
@@ -628,6 +633,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
return {
|
||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
@@ -637,7 +643,7 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
|
||||
: {}),
|
||||
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(options.include ? { include: options.include } : {}),
|
||||
...(options.reasoningEffort || options.reasoningSummary
|
||||
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
|
||||
|
||||
@@ -269,7 +269,7 @@ const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: Lower
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||
parameters: inputSchema,
|
||||
},
|
||||
cache_control: options.cacheControl?.(tool.cache),
|
||||
})
|
||||
@@ -530,9 +530,10 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
return {
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
|
||||
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
|
||||
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
|
||||
if (key === undefined) return undefined
|
||||
const chars = Array.from(key)
|
||||
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
|
||||
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming tool-call accumulator. Adapters that build a tool call across
|
||||
* multiple `tool-input-delta` chunks store the partial JSON input string here
|
||||
|
||||
@@ -2,20 +2,6 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch
|
||||
import { isRecord } from "../../utils/record.js"
|
||||
import { GeminiToolSchema } from "./gemini-tool-schema.js"
|
||||
|
||||
const removeNullSchemas = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(removeNullSchemas)
|
||||
if (!isRecord(value)) return value
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([key]) => key !== "anyOf")
|
||||
.map(([key, field]) => [key, removeNullSchemas(field)]),
|
||||
)
|
||||
if (!Array.isArray(value.anyOf)) return fields
|
||||
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
|
||||
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
|
||||
return { ...fields, anyOf: variants }
|
||||
}
|
||||
|
||||
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
|
||||
const projected = items.map(moonshotNode)
|
||||
if (projected.length === 0) return {}
|
||||
@@ -45,24 +31,7 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
|
||||
return isRecord(projected) ? projected : {}
|
||||
}
|
||||
|
||||
const openAI = (schema: JsonSchema): JsonSchema => {
|
||||
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
|
||||
const flattened =
|
||||
variants.length === 0
|
||||
? { ...schema, type: "object" }
|
||||
: {
|
||||
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
|
||||
type: "object",
|
||||
properties: variants.reduce(
|
||||
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
|
||||
{},
|
||||
),
|
||||
additionalProperties: false,
|
||||
}
|
||||
const normalized = removeNullSchemas(flattened)
|
||||
return isRecord(normalized) ? normalized : { type: "object" }
|
||||
}
|
||||
|
||||
const openAI = (schema: JsonSchema): JsonSchema => schema
|
||||
const responses = openAI
|
||||
|
||||
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
||||
import { isRecord } from "../protocols/shared.js"
|
||||
import { isRecord, ProviderShared } from "../protocols/shared.js"
|
||||
|
||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||
export const id = ProviderID.make(profile.provider)
|
||||
@@ -115,11 +115,12 @@ export const protocol = Protocol.make({
|
||||
reasoning_details: reasoningDetails,
|
||||
}
|
||||
})
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
return {
|
||||
...body,
|
||||
messages,
|
||||
...bodyOptions(request.providerOptions),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||
} as OpenRouterBody
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flattens top-level object unions in function schemas", () =>
|
||||
it.effect("preserves function schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
@@ -236,13 +236,22 @@ describe("OpenAI Responses route", () => {
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
reference: { type: "string" },
|
||||
limit: { type: "integer", maximum: 2000 },
|
||||
resource: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
limit: { type: "integer", maximum: 2000 },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
|
||||
required: ["resource"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -392,6 +401,38 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tolerates keepalive frames before response.created", () =>
|
||||
Effect.gen(function* () {
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_alive", usage: { input_tokens: 1, output_tokens: 1 } },
|
||||
}),
|
||||
]),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
)
|
||||
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
|
||||
"gpt-4.1-mini",
|
||||
)
|
||||
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe(
|
||||
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
|
||||
)
|
||||
expect(response.finishReason?.normalized).toBe("stop")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a tool call with only the new tool output", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
@@ -2270,6 +2311,44 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stateless hosted tool results with their text form", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Search."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
input: { query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
}),
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
},
|
||||
]),
|
||||
Message.user("Continue."),
|
||||
],
|
||||
providerOptions: { store: false },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stateless hosted image generation with the generated image", () =>
|
||||
Effect.gen(function* () {
|
||||
const imageTool = OpenAI.imageGeneration({ action: "edit" })
|
||||
|
||||
@@ -50,32 +50,7 @@ describe("tool schema projections", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("openai keeps one flat object top-level schema", () => {
|
||||
expect(
|
||||
ToolSchemaProjection.openAI({
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
maybe: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
{ type: "object", properties: { resource: { type: "string" } } },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
maybe: { type: "string" },
|
||||
resource: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("applies model compatibility before protocol projection", () =>
|
||||
it.effect("applies model compatibility without changing schema semantics", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
@@ -107,11 +82,15 @@ describe("tool schema projections", () => {
|
||||
|
||||
expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
|
||||
linked: { $ref: "#/$defs/Linked" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
|
||||
linked: { $ref: "#/$defs/Linked" },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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">[] = [
|
||||
|
||||
@@ -501,7 +501,7 @@ function currentModels(value: unknown) {
|
||||
return [
|
||||
{
|
||||
id: model.id,
|
||||
modelID: model.id,
|
||||
modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
|
||||
providerID: provider.id,
|
||||
name: model.name,
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, type ParentProps } from "solid-js"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { LocalProvider } from "@/providers/models/selection"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
@@ -30,7 +31,11 @@ export function SessionUIProvider(
|
||||
await data.session.sync(sessionID).catch(() => undefined)
|
||||
navigate(href(sessionID))
|
||||
}
|
||||
const providers = useProviders(directory)
|
||||
const sessionUIData = createMemo(() => ({
|
||||
provider: providers.ready()
|
||||
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
|
||||
: undefined,
|
||||
session: data.session.list(),
|
||||
session_status: Object.fromEntries(
|
||||
data.session
|
||||
|
||||
@@ -185,7 +185,11 @@ async function compileExecutable(item: (typeof allTargets)[number]) {
|
||||
headers: { Accept: "application/octet-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to download ${name} from Bun release ${release}: ${response.status}`)
|
||||
await Bun.write(archive, response)
|
||||
// Stream to disk instead of `Bun.write(archive, response)`: passing the Response object
|
||||
// hangs forever if it gets GC'd mid-download (https://github.com/oven-sh/bun/issues/40278).
|
||||
const sink = Bun.file(archive).writer()
|
||||
for await (const chunk of response.body!) await sink.write(chunk)
|
||||
await sink.end()
|
||||
await $`unzip -oq ${archive} -d ${cache}`
|
||||
await rm(archive)
|
||||
return executable
|
||||
|
||||
@@ -2,20 +2,26 @@
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const dryRun = Bun.argv.includes("--dry-run")
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
imports: Record<string, Record<string, string>>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
const output = (value: string, types = false) =>
|
||||
value.replace("./src/", "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js")
|
||||
|
||||
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -29,16 +35,38 @@ try {
|
||||
return [
|
||||
key,
|
||||
{
|
||||
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
|
||||
import: output(value),
|
||||
types: output(value, true),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
const consumer = await mkdtemp(join(tmpdir(), "opencode-codemode-"))
|
||||
try {
|
||||
await Bun.write(
|
||||
join(consumer, "package.json"),
|
||||
JSON.stringify({
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: { [pkg.name]: `file:${join(process.cwd(), tarball)}` },
|
||||
}),
|
||||
)
|
||||
await $`npm install --ignore-scripts --no-audit --no-fund`.cwd(consumer)
|
||||
await $`node --input-type=module -e ${`await import(${JSON.stringify(pkg.name)})`}`.cwd(consumer)
|
||||
await $`bun --conditions=workerd -e ${`await import(${JSON.stringify(pkg.name)})`}`.cwd(consumer)
|
||||
} finally {
|
||||
await rm(consumer, { recursive: true, force: true })
|
||||
}
|
||||
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
|
||||
@@ -3,7 +3,6 @@ import { defineConfig } from "drizzle-kit"
|
||||
|
||||
export default defineConfig({
|
||||
out: "./migrations/",
|
||||
strict: true,
|
||||
schema: ["./src/**/*.sql.ts"],
|
||||
verbose: true,
|
||||
dialect: "mysql",
|
||||
|
||||
@@ -3,19 +3,11 @@ import { Resource } from "@opencode-ai/console-resource"
|
||||
export * from "drizzle-orm"
|
||||
import { Client } from "@planetscale/database"
|
||||
|
||||
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
|
||||
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
|
||||
import type { MySqlTransactionConfig } from "drizzle-orm/mysql-core"
|
||||
import { Context } from "../context"
|
||||
import { memo } from "../util/memo"
|
||||
|
||||
export namespace Database {
|
||||
export type Transaction = MySqlTransaction<
|
||||
PlanetscaleQueryResultHKT,
|
||||
PlanetScalePreparedQueryHKT,
|
||||
Record<string, never>,
|
||||
any
|
||||
>
|
||||
|
||||
const client = memo(() => {
|
||||
const result = new Client({
|
||||
host: Resource.Database.host,
|
||||
@@ -26,6 +18,7 @@ export namespace Database {
|
||||
return db
|
||||
})
|
||||
|
||||
export type Transaction = Parameters<Parameters<ReturnType<typeof client>["transaction"]>[0]>[0]
|
||||
export type TxOrDb = Transaction | ReturnType<typeof client>
|
||||
|
||||
const TransactionContext = Context.create<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EffectCache } from "drizzle-orm/cache/core/cache-effect"
|
||||
import { EffectLogger } from "drizzle-orm/effect-core"
|
||||
import { entityKind } from "drizzle-orm/entity"
|
||||
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
|
||||
import { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import { SQLiteEffectDatabase } from "../sqlite-core/effect/db.js"
|
||||
import type { DrizzleConfig } from "drizzle-orm/utils"
|
||||
import { jitCompatCheck } from "../internal/drizzle-utils.js"
|
||||
@@ -53,12 +53,13 @@ export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extend
|
||||
const cache = yield* EffectCache
|
||||
const logger = yield* EffectLogger
|
||||
|
||||
const dialect = new SQLiteAsyncDialect()
|
||||
const useJitMappers = jitCompatCheck(config.jit)
|
||||
const dialect = new SQLiteDialect({ useJitMappers })
|
||||
const relations = config.relations ?? ({} as TRelations)
|
||||
const session = new EffectSQLiteSession(client, dialect, relations, {
|
||||
logger,
|
||||
cache,
|
||||
useJitMappers: jitCompatCheck(config.jit),
|
||||
useJitMappers,
|
||||
})
|
||||
const db = new EffectSQLiteDatabase(dialect, session, relations) as EffectSQLiteDatabase<TRelations> & {
|
||||
$client: SqlClient
|
||||
|
||||
@@ -14,7 +14,7 @@ import { entityKind } from "drizzle-orm/entity"
|
||||
import type { AnyRelations } from "drizzle-orm/relations"
|
||||
import type { RelationalQueryMapperConfig } from "drizzle-orm/relations"
|
||||
import type { Query } from "drizzle-orm/sql/sql"
|
||||
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import {
|
||||
SQLiteEffectPreparedQuery,
|
||||
SQLiteEffectSession,
|
||||
@@ -59,7 +59,7 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
|
||||
|
||||
constructor(
|
||||
private client: SqlClient,
|
||||
dialect: SQLiteAsyncDialect,
|
||||
dialect: SQLiteDialect,
|
||||
protected relations: TRelations,
|
||||
private options: EffectSQLiteSessionOptions,
|
||||
) {
|
||||
|
||||
@@ -41,14 +41,34 @@ export function orderSelectedFields<TColumn extends Column>(
|
||||
): SelectedFieldsOrdered {
|
||||
return Object.entries(fields).flatMap(([name, field]) => {
|
||||
const path = pathPrefix ? [...pathPrefix, name] : [name]
|
||||
if (is(field, Column) || is(field, SQL) || is(field, SQL.Aliased) || is(field, Subquery)) {
|
||||
return [{ path, field }] as SelectedFieldsOrdered
|
||||
}
|
||||
if (is(field, Column)) return [{ path, field, fieldType: "Column", column: field }] as SelectedFieldsOrdered
|
||||
if (is(field, SQL)) return [{ path, field, fieldType: "SQL" }] as SelectedFieldsOrdered
|
||||
if (is(field, SQL.Aliased)) return [{ path, field, fieldType: "SQL.Aliased" }] as SelectedFieldsOrdered
|
||||
if (is(field, Subquery)) return [{ path, field, fieldType: "Subquery" }] as SelectedFieldsOrdered
|
||||
if (is(field, Table)) return orderSelectedFields(getTableColumnsRuntime(field as SQLiteTable), path)
|
||||
return orderSelectedFields(field as Record<string, unknown>, path)
|
||||
}) as SelectedFieldsOrdered
|
||||
}
|
||||
|
||||
export function resolveNullableObjectPaths(
|
||||
columns: SelectedFieldsOrdered,
|
||||
joinsNotNullableMap: Record<string, boolean> | undefined,
|
||||
) {
|
||||
if (!joinsNotNullableMap) return undefined
|
||||
const tableOf = columns
|
||||
.filter((column) => column.path.length > 1 && column.fieldType === "Column")
|
||||
.reduce<Record<string, string | false>>((result, column) => {
|
||||
const key = column.path[0]!
|
||||
const tableName = getTableName(getColumnTable(column.field as Column))
|
||||
const current = result[key]
|
||||
result[key] = current === undefined ? tableName : current === tableName ? current : false
|
||||
return result
|
||||
}, {})
|
||||
return Object.entries(tableOf)
|
||||
.filter(([, tableName]) => typeof tableName === "string" && !joinsNotNullableMap[tableName])
|
||||
.map(([key]) => key)
|
||||
}
|
||||
|
||||
export function mapUpdateSet<TTable extends SQLiteTable>(table: TTable, values: SQLiteUpdateSetSource<TTable>) {
|
||||
const entries = Object.entries(values).filter(([, value]) => value !== undefined)
|
||||
if (entries.length === 0) throw new Error("No values to set")
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TypedQueryBuilder } from "drizzle-orm/query-builders/query-builder
|
||||
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
|
||||
import { SelectionProxyHandler } from "drizzle-orm/selection-proxy"
|
||||
import { type ColumnsSelection, type SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
|
||||
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import { QueryBuilder } from "drizzle-orm/sqlite-core/query-builders/query-builder"
|
||||
import type { SelectedFields } from "drizzle-orm/sqlite-core/query-builders/select.types"
|
||||
import type { SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
|
||||
@@ -45,7 +45,7 @@ export class SQLiteEffectDatabase<
|
||||
|
||||
constructor(
|
||||
/** @internal */
|
||||
readonly dialect: SQLiteAsyncDialect,
|
||||
readonly dialect: SQLiteDialect,
|
||||
/** @internal */
|
||||
readonly session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
|
||||
relations: TRelations,
|
||||
|
||||
@@ -3,14 +3,15 @@ import type * as Effect from "effect/Effect"
|
||||
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
|
||||
import { entityKind, is } from "drizzle-orm/entity"
|
||||
import type { SelectResultFields } from "drizzle-orm/query-builders/select.types"
|
||||
import type { TypedQueryBuilder } from "drizzle-orm/query-builders/query-builder"
|
||||
import type { RunnableQuery } from "drizzle-orm/runnable-query"
|
||||
import type { Query, SQLWrapper } from "drizzle-orm/sql/sql"
|
||||
import { Param, SQL, sql } from "drizzle-orm/sql/sql"
|
||||
import { SQL, sql } from "drizzle-orm/sql/sql"
|
||||
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import type { IndexColumn } from "drizzle-orm/sqlite-core/indexes"
|
||||
import type {
|
||||
SQLiteInsertConfig,
|
||||
SQLiteInsertSelectQueryBuilder,
|
||||
SQLiteInsertSelection,
|
||||
SQLiteInsertValue,
|
||||
} from "drizzle-orm/sqlite-core/query-builders/insert"
|
||||
import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types"
|
||||
@@ -152,34 +153,24 @@ export class SQLiteEffectInsertBuilder<
|
||||
if (values.length === 0) {
|
||||
throw new Error("values() must be called with at least one value")
|
||||
}
|
||||
const mappedValues = values.map((entry) => {
|
||||
const result: Record<string, Param | SQL> = {}
|
||||
const cols = getTableColumnsRuntime(this.table)
|
||||
for (const colKey of Object.keys(entry)) {
|
||||
const colValue = entry[colKey as keyof typeof entry]
|
||||
result[colKey] = is(colValue, SQL) ? colValue : new Param(colValue, cols[colKey])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return new SQLiteEffectInsertBase(this.table, mappedValues, this.session, this.dialect, this.withList)
|
||||
return new SQLiteEffectInsertBase(this.table, values, this.session, this.dialect, this.withList)
|
||||
}
|
||||
|
||||
select(
|
||||
selectQuery: (qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable>,
|
||||
selectQuery: (qb: QueryBuilder) => TypedQueryBuilder<SQLiteInsertSelection<TTable>>,
|
||||
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
|
||||
select(
|
||||
selectQuery: (qb: QueryBuilder) => SQL,
|
||||
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
|
||||
select(selectQuery: SQL): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
|
||||
select(
|
||||
selectQuery: SQLiteInsertSelectQueryBuilder<TTable>,
|
||||
selectQuery: TypedQueryBuilder<SQLiteInsertSelection<TTable>>,
|
||||
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
|
||||
select(
|
||||
selectQuery:
|
||||
| SQL
|
||||
| SQLiteInsertSelectQueryBuilder<TTable>
|
||||
| ((qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable> | SQL),
|
||||
| TypedQueryBuilder<SQLiteInsertSelection<TTable>>
|
||||
| ((qb: QueryBuilder) => TypedQueryBuilder<SQLiteInsertSelection<TTable>> | SQL),
|
||||
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
|
||||
const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { entityKind } from "drizzle-orm/entity"
|
||||
import type { RunnableQuery } from "drizzle-orm/runnable-query"
|
||||
import type { PreparedQuery } from "drizzle-orm/session"
|
||||
import type { Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql"
|
||||
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
|
||||
type SQLiteEffectRawAction = "all" | "get" | "values" | "run"
|
||||
|
||||
@@ -29,7 +29,7 @@ export class SQLiteEffectRaw<TResult, TEffectHKT extends QueryEffectHKTBase = Qu
|
||||
/** @internal */
|
||||
public getSQL: () => SQL,
|
||||
private action: SQLiteEffectRawAction,
|
||||
private dialect: SQLiteAsyncDialect,
|
||||
private dialect: SQLiteDialect,
|
||||
private mapBatchResult: (result: unknown) => unknown,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SQL } from "drizzle-orm/sql/sql"
|
||||
import type { ColumnsSelection, SQLWrapper } from "drizzle-orm/sql/sql"
|
||||
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns"
|
||||
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import { SQLiteSelectQueryBuilderBase } from "drizzle-orm/sqlite-core/query-builders/select"
|
||||
import { SQLiteSelectBase } from "drizzle-orm/sqlite-core/query-builders/select"
|
||||
import type {
|
||||
CreateSQLiteSelectFromBuilderMode,
|
||||
SelectedFields,
|
||||
@@ -97,8 +97,8 @@ export class SQLiteEffectSelectBuilder<
|
||||
>
|
||||
: CreateSQLiteSelectFromBuilderMode<
|
||||
TBuilderMode,
|
||||
SQLiteEffectSelectHKT<TEffectHKT>,
|
||||
GetSelectTableName<TFrom>,
|
||||
"async",
|
||||
TRunResult,
|
||||
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
|
||||
TSelection extends undefined ? "single" : "partial"
|
||||
@@ -164,10 +164,9 @@ export interface SQLiteEffectSelectBase<
|
||||
out TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
|
||||
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
> extends SQLiteSelectQueryBuilderBase<
|
||||
> extends SQLiteSelectBase<
|
||||
SQLiteEffectSelectHKT<TEffectHKT>,
|
||||
TTableName,
|
||||
"async",
|
||||
TRunResult,
|
||||
TSelection,
|
||||
TSelectMode,
|
||||
@@ -193,10 +192,9 @@ export class SQLiteEffectSelectBase<
|
||||
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
>
|
||||
extends SQLiteSelectQueryBuilderBase<
|
||||
extends SQLiteSelectBase<
|
||||
SQLiteEffectSelectHKT<TEffectHKT>,
|
||||
TTableName,
|
||||
"async",
|
||||
TRunResult,
|
||||
TSelection,
|
||||
TSelectMode,
|
||||
@@ -215,7 +213,7 @@ export class SQLiteEffectSelectBase<
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
getSQL(): SQL {
|
||||
override getSQL(): SQL {
|
||||
return this.dialect.buildSelectQuery(this.effectConfig)
|
||||
}
|
||||
|
||||
@@ -240,7 +238,7 @@ export class SQLiteEffectSelectBase<
|
||||
return query as ReturnType<this["prepare"]>
|
||||
}
|
||||
|
||||
$withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) {
|
||||
override $withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) {
|
||||
this.cacheConfig =
|
||||
config === undefined
|
||||
? { config: {}, enabled: true, autoInvalidate: true }
|
||||
|
||||
@@ -21,12 +21,12 @@ import type {
|
||||
import { makeJitRqbMapper } from "drizzle-orm/relations"
|
||||
import type { PreparedQuery } from "drizzle-orm/session"
|
||||
import { fillPlaceholders, type Query, type SQL, sql } from "drizzle-orm/sql/sql"
|
||||
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
|
||||
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
|
||||
import { upgradeIfNeeded } from "../../up-migrations/effect-sqlite.js"
|
||||
import { assertUnreachable, makeJitQueryMapper, type RowsMapper } from "drizzle-orm/utils"
|
||||
import { mapResultRow } from "../../internal/drizzle-utils.js"
|
||||
import { mapResultRow, resolveNullableObjectPaths } from "../../internal/drizzle-utils.js"
|
||||
import { SQLiteEffectDatabase } from "./db.js"
|
||||
|
||||
type MigrationConfigWithInit = MigrationConfig & { init?: boolean }
|
||||
@@ -155,7 +155,10 @@ export class SQLiteEffectPreparedQuery<
|
||||
return this.useJitMappers
|
||||
? (this.jitMapper =
|
||||
(this.jitMapper as RowsMapper<T["all"]>) ??
|
||||
makeJitQueryMapper<T["all"]>(this.fields!, this.joinsNotNullableMap))(rows as unknown[][])
|
||||
makeJitQueryMapper<T["all"]>(
|
||||
this.fields!,
|
||||
resolveNullableObjectPaths(this.fields!, this.joinsNotNullableMap),
|
||||
))(rows as unknown[][])
|
||||
: (rows as unknown[][]).map((row) => mapResultRow(this.fields!, row, this.joinsNotNullableMap))
|
||||
}
|
||||
|
||||
@@ -187,7 +190,10 @@ export class SQLiteEffectPreparedQuery<
|
||||
return this.useJitMappers
|
||||
? (this.jitMapper =
|
||||
(this.jitMapper as RowsMapper<T["get"][]>) ??
|
||||
makeJitQueryMapper<T["get"][]>(this.fields!, this.joinsNotNullableMap))([row as unknown[]])[0]
|
||||
makeJitQueryMapper<T["get"][]>(
|
||||
this.fields!,
|
||||
resolveNullableObjectPaths(this.fields!, this.joinsNotNullableMap),
|
||||
))([row as unknown[]])[0]
|
||||
: mapResultRow(this.fields!, row as unknown[], this.joinsNotNullableMap)
|
||||
}
|
||||
|
||||
@@ -311,7 +317,7 @@ export abstract class SQLiteEffectSession<
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSession"
|
||||
|
||||
constructor(readonly dialect: SQLiteAsyncDialect) {}
|
||||
constructor(readonly dialect: SQLiteDialect) {}
|
||||
|
||||
abstract prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
|
||||
query: Query,
|
||||
@@ -412,7 +418,7 @@ export abstract class SQLiteEffectTransaction<
|
||||
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
||||
|
||||
constructor(
|
||||
dialect: SQLiteAsyncDialect,
|
||||
dialect: SQLiteDialect,
|
||||
session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
|
||||
protected relations: TRelations,
|
||||
) {
|
||||
|
||||
+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
|
||||
);
|
||||
|
||||
@@ -63,7 +63,13 @@ const baseLayer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||
// Workspace-placed directories exist only inside the workspace, so a host
|
||||
// realpath probe at boot consults the wrong filesystem and would block
|
||||
// construction on servers without a matching local directory. Treat the
|
||||
// configured directory as canonical; local placements keep symlink
|
||||
// canonicalization. This skip is boot-only: resolve/read/list below still
|
||||
// access the host filesystem per operation (tracked in #44568).
|
||||
const root = location.workspaceID ? location.directory : yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
||||
const absolute = path.resolve(location.directory, input ?? ".")
|
||||
if (!FSUtil.contains(location.directory, absolute))
|
||||
|
||||
@@ -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* () {
|
||||
@@ -180,9 +184,12 @@ export const fffLayer = Layer.effect(
|
||||
export const layer = (options?: Options) =>
|
||||
Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
// Workspace-backed Locations resolve in a remote sandbox; fff would index the local server directory
|
||||
// in-process and serve wrong results. Ripgrep routes through the Location environment spawner.
|
||||
if (location.workspaceID) return ripgrepLayer
|
||||
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
|
||||
return ripgrepLayer
|
||||
const location = yield* Location.Service
|
||||
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
|
||||
return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer
|
||||
}),
|
||||
|
||||
@@ -8,13 +8,27 @@ import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
|
||||
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||
|
||||
export const make = Effect.gen(function* () {
|
||||
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
|
||||
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
|
||||
const loadPhoton = yield* Effect.cached(
|
||||
Effect.tryPromise({
|
||||
try: () => import("@silvia-odwyer/photon-node"),
|
||||
catch: () => new ResizerUnavailableError(),
|
||||
}),
|
||||
// A runtime without a photon wasm artifact (#photon-wasm resolves to the
|
||||
// empty string on workerd) has no resizer, by declaration: fail typed
|
||||
// before touching URLs or module loading. The path resolution and import
|
||||
// for runtimes that DO have an artifact stay inside the guard too — a
|
||||
// throw outside it (workerd's undefined import.meta.url was one) is a
|
||||
// defect that escapes the ResizerUnavailableError handling and turns any
|
||||
// image-bearing prompt into a 500 instead of degrading to passthrough.
|
||||
photonWasm === ""
|
||||
? Effect.fail(new ResizerUnavailableError())
|
||||
: Effect.tryPromise({
|
||||
try: async () => {
|
||||
;(
|
||||
globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }
|
||||
).__OPENCODE_PHOTON_WASM_PATH = path.isAbsolute(photonWasm)
|
||||
? photonWasm
|
||||
: fileURLToPath(new URL(photonWasm, import.meta.url))
|
||||
return await import("@silvia-odwyer/photon-node")
|
||||
},
|
||||
catch: () => new ResizerUnavailableError(),
|
||||
}),
|
||||
)
|
||||
return Effect.fn("Image.Photon.normalize")(function* (
|
||||
resource: 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,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { SessionEvent } from "../session/event.js"
|
||||
|
||||
@@ -28,12 +27,9 @@ You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply.
|
||||
export const Plugin = define({
|
||||
id: "opencode.plan",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const environment = yield* Environment.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.home, ".opencode", "plan")
|
||||
const enterReminder = enter(directory)
|
||||
yield* environment.files.mkdir(directory).pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(plan, (item) => {
|
||||
item.name = Agent.Name.make("Plan")
|
||||
|
||||
+121
-38
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -12,8 +12,25 @@ import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { location } from "../fixture/location"
|
||||
|
||||
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
onFind(input)
|
||||
if (input.onEntry)
|
||||
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make(entry), type: "file" }))
|
||||
return []
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
describe("FileSystemSearch", () => {
|
||||
test("honors wildcard directory rules from .gitignore", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
|
||||
@@ -50,6 +67,44 @@ describe("FileSystemSearch", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
|
||||
try {
|
||||
// A local file that only an fff index of the server directory could surface.
|
||||
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
|
||||
// true; elsewhere the layer choice already falls back to ripgrep.
|
||||
await Bun.write(path.join(directory, "server-local.ts"), "server local")
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const ref = Location.Ref.make({
|
||||
directory: AbsolutePath.make(directory),
|
||||
workspaceID: Workspace.ID.make("wrk_test"),
|
||||
})
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "ts", type: "file" })
|
||||
expect(observed?.cwd).toBe(directory)
|
||||
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("bounds a home scan even when home is detected as a repository", async () => {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
@@ -63,29 +118,15 @@ describe("FileSystemSearch", () => {
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
observed = input
|
||||
if (input.onEntry)
|
||||
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
|
||||
return []
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
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 +138,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 +167,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 +179,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 +202,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 +225,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 +238,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" })
|
||||
|
||||
@@ -6,16 +6,20 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const provide = (directory: string) =>
|
||||
const provide = (directory: string, workspaceID?: Workspace.ID) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(FileSystem.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
@@ -56,6 +60,46 @@ describe("FileSystem", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("skips host canonicalization for workspace locations at boot", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
// The directory exists only inside the workspace, so boot must not
|
||||
// require it to exist on the host. Operations still access the host
|
||||
// filesystem per call (#44568); only boot canonicalization is skipped.
|
||||
const missing = path.join(directory, "workspace-only")
|
||||
const workspace = yield* FileSystem.Service.pipe(
|
||||
provide(missing, Workspace.ID.make("wrk_filesystem")),
|
||||
Effect.exit,
|
||||
)
|
||||
expect(Exit.isSuccess(workspace)).toBe(true)
|
||||
|
||||
// A local ref with the same missing directory keeps failing boot:
|
||||
// host realpath canonicalization stays load-bearing for local placements.
|
||||
const local = yield* FileSystem.Service.pipe(provide(missing), Effect.exit)
|
||||
expect(Exit.isFailure(local)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("canonicalizes local symlinked directories", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const real = path.join(directory, "real")
|
||||
yield* Effect.promise(() => fs.mkdir(real))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(real, "file.txt"), "linked"))
|
||||
const link = path.join(directory, "link")
|
||||
yield* Effect.promise(() => fs.symlink(real, link))
|
||||
// Reads resolve through the symlink only because boot canonicalized
|
||||
// the location root to the real directory.
|
||||
const read = yield* FileSystem.Service.pipe(
|
||||
Effect.flatMap((service) => service.read({ path: RelativePath.make("file.txt") })),
|
||||
provide(link),
|
||||
)
|
||||
expect(new TextDecoder().decode(read.content)).toBe("linked")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects lexical escapes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -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 neither liveness
|
||||
// nor boot may consult the local filesystem: the full location graph
|
||||
// must construct and the entry must survive release instead of being
|
||||
// evicted by a zero idle time-to-live.
|
||||
const location = yield* Location.Service.pipe(Effect.provide(locations.get(workspaceRef)), Effect.scoped)
|
||||
expect(location.directory).toBe(directory)
|
||||
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()),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,6 @@ export default defineConfig({
|
||||
schema: ["./src/database/schema.ts"],
|
||||
// schema: ["./src/**/*.sql.ts"],
|
||||
out: "./migrations/",
|
||||
strict: true,
|
||||
verbose: true,
|
||||
dbCredentials: {
|
||||
database: Resource.StatsDatabase.database,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { drizzle } from "drizzle-orm/planetscale-serverless"
|
||||
import { migrate as drizzleMigrate } from "drizzle-orm/planetscale-serverless/migrator"
|
||||
import { Config, ConfigProvider, Effect, Layer, Schema } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import * as schema from "./database/schema"
|
||||
import { Resource } from "sst/resource"
|
||||
|
||||
export const DatabaseUrl = Schema.NonEmptyString.pipe(Schema.brand("DatabaseUrl"))
|
||||
@@ -32,7 +31,7 @@ export class DatabaseConfig extends Context.Service<DatabaseConfig, DatabaseSett
|
||||
}
|
||||
|
||||
function makeDrizzle(settings: DatabaseSettings) {
|
||||
return drizzle({ client: new Client({ url: settings.url }), schema })
|
||||
return drizzle({ client: new Client({ url: settings.url }) })
|
||||
}
|
||||
|
||||
export type Drizzle = ReturnType<typeof makeDrizzle>
|
||||
|
||||
@@ -9,7 +9,7 @@ export type ProviderIconProps = JSX.SVGElementTags["svg"] & {
|
||||
|
||||
export const ProviderIcon: Component<ProviderIconProps> = (props) => {
|
||||
const [local, rest] = splitProps(props, ["id", "class", "classList"])
|
||||
const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "session.synthetic"))
|
||||
const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "synthetic"))
|
||||
return (
|
||||
<svg
|
||||
data-component="provider-icon"
|
||||
|
||||
@@ -211,6 +211,7 @@ const source = {
|
||||
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"ui.message.interrupted": "Interrupted",
|
||||
"ui.sessionTimeline.notice.model": "Model",
|
||||
"ui.sessionTimeline.notice.modelSwitched": "Switched to {{model}}",
|
||||
"ui.sessionTimeline.notice.movedTo": "Moved to",
|
||||
"ui.sessionTimeline.notice.movedTooltip": "Session working directory changed",
|
||||
"ui.sessionTimeline.notice.failed": "{{actor}} failed",
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
diff --git a/cache/core/cache-effect.cjs b/cache/core/cache-effect.cjs
|
||||
--- a/cache/core/cache-effect.cjs
|
||||
+++ b/cache/core/cache-effect.cjs
|
||||
@@ -103,1 +103,1 @@
|
||||
-var EffectCacheError = class extends effect_Schema.TaggedErrorClass()("EffectCacheError", { cause: effect_Schema.Unknown }) {
|
||||
+var EffectCacheError = class extends effect_Schema.TaggedError()("EffectCacheError", { cause: effect_Schema.Unknown }) {
|
||||
diff --git a/cache/core/cache-effect.js b/cache/core/cache-effect.js
|
||||
--- a/cache/core/cache-effect.js
|
||||
+++ b/cache/core/cache-effect.js
|
||||
@@ -97,1 +97,1 @@
|
||||
-var EffectCacheError = class extends Schema.TaggedErrorClass()("EffectCacheError", { cause: Schema.Unknown }) {
|
||||
+var EffectCacheError = class extends Schema.TaggedError()("EffectCacheError", { cause: Schema.Unknown }) {
|
||||
diff --git a/effect-core/errors.cjs b/effect-core/errors.cjs
|
||||
--- a/effect-core/errors.cjs
|
||||
+++ b/effect-core/errors.cjs
|
||||
@@ -8,1 +8,1 @@
|
||||
-var EffectDrizzleError = class extends effect_Schema.TaggedErrorClass()("EffectDrizzleError", {
|
||||
+var EffectDrizzleError = class extends effect_Schema.TaggedError()("EffectDrizzleError", {
|
||||
@@ -14,1 +14,1 @@
|
||||
-var EffectDrizzleQueryError = class EffectDrizzleQueryError extends effect_Schema.TaggedErrorClass()("EffectDrizzleQueryError", {
|
||||
+var EffectDrizzleQueryError = class EffectDrizzleQueryError extends effect_Schema.TaggedError()("EffectDrizzleQueryError", {
|
||||
@@ -28,1 +28,1 @@
|
||||
-var EffectTransactionRollbackError = class extends effect_Schema.TaggedErrorClass()("EffectTransactionRollbackError", {}) {
|
||||
+var EffectTransactionRollbackError = class extends effect_Schema.TaggedError()("EffectTransactionRollbackError", {}) {
|
||||
@@ -32,1 +32,1 @@
|
||||
-var MigratorInitError = class extends effect_Schema.TaggedErrorClass()("MigratorInitError", { exitCode: effect_Schema.Literals(["databaseMigrations", "localMigrations"]) }) {
|
||||
+var MigratorInitError = class extends effect_Schema.TaggedError()("MigratorInitError", { exitCode: effect_Schema.Literals(["databaseMigrations", "localMigrations"]) }) {
|
||||
diff --git a/effect-core/errors.js b/effect-core/errors.js
|
||||
--- a/effect-core/errors.js
|
||||
+++ b/effect-core/errors.js
|
||||
@@ -5,1 +5,1 @@
|
||||
-var EffectDrizzleError = class extends Schema.TaggedErrorClass()("EffectDrizzleError", {
|
||||
+var EffectDrizzleError = class extends Schema.TaggedError()("EffectDrizzleError", {
|
||||
@@ -11,1 +11,1 @@
|
||||
-var EffectDrizzleQueryError = class EffectDrizzleQueryError extends Schema.TaggedErrorClass()("EffectDrizzleQueryError", {
|
||||
+var EffectDrizzleQueryError = class EffectDrizzleQueryError extends Schema.TaggedError()("EffectDrizzleQueryError", {
|
||||
@@ -25,1 +25,1 @@
|
||||
-var EffectTransactionRollbackError = class extends Schema.TaggedErrorClass()("EffectTransactionRollbackError", {}) {
|
||||
+var EffectTransactionRollbackError = class extends Schema.TaggedError()("EffectTransactionRollbackError", {}) {
|
||||
@@ -29,1 +29,1 @@
|
||||
-var MigratorInitError = class extends Schema.TaggedErrorClass()("MigratorInitError", { exitCode: Schema.Literals(["databaseMigrations", "localMigrations"]) }) {
|
||||
+var MigratorInitError = class extends Schema.TaggedError()("MigratorInitError", { exitCode: Schema.Literals(["databaseMigrations", "localMigrations"]) }) {
|
||||
Reference in New Issue
Block a user