Compare commits

..
152 changed files with 1926 additions and 3925 deletions
-6
View File
@@ -1,6 +0,0 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
---
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
-5
View File
@@ -84,11 +84,6 @@ 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
+55 -22
View File
@@ -54,12 +54,12 @@
"name": "@opencode-ai/app",
"version": "1.18.15",
"dependencies": {
"@ibm/plex": "6.4.1",
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -71,7 +71,6 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
@@ -192,7 +191,7 @@
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.111",
"effect": "4.0.0-rc.110",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -526,7 +525,7 @@
"name": "@opencode-ai/http-recorder",
"version": "1.18.15",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.110",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -672,7 +671,6 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
@@ -1056,6 +1054,7 @@
"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",
@@ -1079,10 +1078,10 @@
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@effect/opentelemetry": "4.0.0-rc.110",
"@effect/platform-node": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -1117,9 +1116,9 @@
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.110",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
@@ -1684,15 +1683,15 @@
"@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.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.111" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.110", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.110" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-8Uum1ikIAK2DjATeLseS71HZKVI/dduFYypvMhiO68RTli1xq9yaoTI1Y0IThPn2sI0i86ZdpFaKQx1hdw2tWw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.111", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.111", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.111", "redis": ">=5.0.0 <7.0.0" } }, "sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.110", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.110", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110", "redis": ">=5.0.0 <7.0.0" } }, "sha512-poj6VxTc2kRDowUHgjGXAZL2nWHTyGi/18607jK+pV9A9CH/wqZ4NeYj38rij95j5SiZ3U7Y1iOsk2Vfnr4GEw=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.111", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.110", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-P8EZloxS7RtCOSL3VSBPyqSenUm93xLbXf9jkWoy67cibZ2wgZ9nAou9iN8f1i4vzgxUsWtDuy6BEmg67np6Zw=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.110", "", { "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-Lam3gY1xszjQS/cebdLbWbtR21FtQI4ke7QHqbBQ6AyVJF0CGUtS/ePL9zNq6wHNmJ95FUDGS6W+Qx/l6RPSzA=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -3218,6 +3217,12 @@
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/cli-config": ["@vercel/cli-config@0.2.3", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A=="],
"@vercel/cli-exec": ["@vercel/cli-exec@1.0.1", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ=="],
"@vercel/functions": ["@vercel/functions@3.9.3", "", { "dependencies": { "@vercel/oidc": "3.8.4" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -3830,9 +3835,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"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-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-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=="],
"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=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -3848,7 +3853,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"effect": ["effect@4.0.0-rc.110", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-ega6FTJ8CS2of7tHZiADvgyJyV999Q6tZ9juE56V81O0jw6flRwydaPNtyfvP2a5LL9PZrse8A1jnNUD5sWVHg=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3976,6 +3981,8 @@
"eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
@@ -4250,6 +4257,8 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -4614,6 +4623,8 @@
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"mermaid": ["mermaid@11.16.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g=="],
@@ -4698,6 +4709,8 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
@@ -4814,6 +4827,8 @@
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -4838,6 +4853,8 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
@@ -4852,6 +4869,8 @@
"opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
"os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="],
"own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="],
"oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="],
@@ -5434,6 +5453,8 @@
"strip-comments": ["strip-comments@2.0.1", "", {}, "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"stripe": ["stripe@18.0.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA=="],
@@ -5826,6 +5847,10 @@
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
@@ -6148,8 +6173,6 @@
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@effect/platform-node-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -6398,6 +6421,10 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.4", "", { "dependencies": { "@vercel/cli-config": "0.2.3", "@vercel/cli-exec": "1.0.1", "jose": "^5.9.6" } }, "sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA=="],
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitejs/plugin-react/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
@@ -6536,6 +6563,10 @@
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
@@ -7292,6 +7323,8 @@
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
"x86_64-linux": "sha256-P8AZ2THuXiET4hiSYTwGGdTgbm2l3XGZGJxQwg6lusE=",
"aarch64-linux": "sha256-73LxDeDPQspY4Bn/gbGU43j/k7gBZ5ywMMpUjDl3elg=",
"aarch64-darwin": "sha256-sc9YyTAFlbWxlFb9vwFYLs6IYd3SRoju9mQufzeQdd8=",
"x86_64-darwin": "sha256-nnk637DELjzGB3t9aLDYbHz5h3Dd3nvWcWGdaQk8yPM="
}
}
+8 -7
View File
@@ -39,10 +39,10 @@
"packages/stats/*"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@effect/opentelemetry": "4.0.0-rc.110",
"@effect/platform-node": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -71,9 +71,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.110",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -166,6 +166,7 @@
"@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",
+1 -1
View File
@@ -213,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
- Input failed the `parameters` Schema.
- The handler returned a `ToolFailure`.
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
+19 -32
View File
@@ -41,7 +41,6 @@ const SSE_EVENTS = new Set([
"content_block_start",
"content_block_delta",
"content_block_stop",
"ping",
"error",
])
export const framing = Framing.sseEvents(SSE_EVENTS)
@@ -54,7 +53,7 @@ export type ThinkingInput =
| {
readonly type: "disabled"
}
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
| ({ readonly type: "enabled" } & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
@@ -213,7 +212,6 @@ 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"),
@@ -616,12 +614,15 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive")
if (input.type === "adaptive") {
const display =
input.display === "summarized"
? ("summarized" as const)
: input.display === "omitted"
? ("omitted" as const)
: undefined
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 =
@@ -632,7 +633,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, ...(display === undefined ? {} : { display }) }
return { type: "enabled" as const, budget_tokens: budget }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
@@ -793,8 +794,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
const block = event.content_block
if (!block) return [state, NO_EVENTS]
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -802,7 +802,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
...state,
lifecycle,
tools: ToolStream.start(state.tools, event.index, {
id: block.id,
id: block.id ?? String(event.index),
name: block.name ?? "",
input:
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
@@ -814,7 +814,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
[
...events,
LLMEvent.toolInputStart({
id: block.id,
id: block.id ?? String(event.index),
name: block.name ?? "",
providerExecuted: block.type === "server_tool_use" ? true : undefined,
}),
@@ -1004,28 +1004,15 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
}
const onError = (event: AnthropicEvent) =>
Effect.fail(
new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
}),
)
new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
})
const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
if (event.type === "content_block_start") {
const block = event.content_block
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
}
return Effect.succeed(onContentBlockStart(state, event))
}
if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event))
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
+35 -48
View File
@@ -17,7 +17,7 @@ import {
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -82,15 +82,10 @@ export type ProviderOptionsInput = OptionsInput
// =============================================================================
// Request Body Schema
// =============================================================================
// Gemini is known to send explicit `null` for optional streaming fields
// (usage counts, flags, whole subtrees), so every response-side optional uses
// `optionalNull` instead of bare `Schema.optional`. The same part/content
// schemas lower the outbound request body; encoding drops `undefined` keys,
// so the shared schemas stay safe there.
const GeminiTextPart = Schema.Struct({
text: Schema.String,
thought: optionalNull(Schema.Boolean),
thoughtSignature: optionalNull(Schema.String),
thought: Schema.optional(Schema.Boolean),
thoughtSignature: Schema.optional(Schema.String),
})
const GeminiInlineDataPart = Schema.Struct({
@@ -103,11 +98,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
const GeminiFunctionCallPart = Schema.Struct({
functionCall: Schema.Struct({
id: optionalNull(Schema.String),
id: Schema.optional(Schema.String),
name: Schema.String,
args: Schema.optional(Schema.Unknown),
}),
thoughtSignature: optionalNull(Schema.String),
thoughtSignature: Schema.optional(Schema.String),
})
const GeminiFunctionResponsePart = Schema.Struct({
@@ -127,8 +122,8 @@ const GeminiContentPart = Schema.Union([
])
const GeminiContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
parts: optionalNull(Schema.Array(GeminiContentPart)),
role: Schema.Literals(["user", "model"]),
parts: Schema.Array(GeminiContentPart),
})
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
@@ -191,33 +186,33 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
const GeminiUsage = Schema.Struct({
cachedContentTokenCount: optionalNull(Schema.Number),
thoughtsTokenCount: optionalNull(Schema.Number),
promptTokenCount: optionalNull(Schema.Number),
candidatesTokenCount: optionalNull(Schema.Number),
totalTokenCount: optionalNull(Schema.Number),
cachedContentTokenCount: Schema.optional(Schema.Number),
thoughtsTokenCount: Schema.optional(Schema.Number),
promptTokenCount: Schema.optional(Schema.Number),
candidatesTokenCount: Schema.optional(Schema.Number),
totalTokenCount: Schema.optional(Schema.Number),
})
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
const GeminiCandidate = Schema.Struct({
content: optionalNull(GeminiContent),
finishReason: optionalNull(Schema.String),
content: Schema.optional(GeminiContent),
finishReason: Schema.optional(Schema.String),
})
const GeminiPromptFeedback = Schema.StructWithRest(
Schema.Struct({
blockReason: optionalNull(Schema.String),
blockReasonMessage: optionalNull(Schema.String),
safetyRatings: optionalNull(Schema.Unknown),
blockReason: Schema.optional(Schema.String),
blockReasonMessage: Schema.optional(Schema.String),
safetyRatings: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
const GeminiEvent = Schema.Struct({
candidates: optionalNull(Schema.Array(GeminiCandidate)),
promptFeedback: optionalNull(GeminiPromptFeedback),
usageMetadata: optionalNull(GeminiUsage),
candidates: optionalArray(GeminiCandidate),
promptFeedback: Schema.optional(GeminiPromptFeedback),
usageMetadata: Schema.optional(GeminiUsage),
})
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
@@ -310,8 +305,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
const previous = contents.at(-1)
// Gemini rejects a continuation whose function-response turn carries extra
// parts, so an update after a tool result starts its own user turn.
if (previous?.role === "user" && !(previous.parts ?? []).some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), { text: part.text }] }
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
else contents.push({ role: "user", parts: [{ text: part.text }] })
continue
}
@@ -402,8 +397,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
// Gemini requires every response to a parallel call batch in one user turn,
// so consecutive tool results join the open function-response turn.
const previous = contents.at(-1)
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
else contents.push({ role: "user", parts })
}
@@ -493,25 +488,21 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// to produce the inclusive `outputTokens` the rest of the contract expects.
const mapUsage = (usage: GeminiUsage | undefined) => {
if (!usage) return undefined
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
// token arithmetic below treats them like absent counts.
const promptTokens = usage.promptTokenCount ?? undefined
const cached = usage.cachedContentTokenCount ?? undefined
const thoughts = usage.thoughtsTokenCount ?? undefined
const visible = usage.candidatesTokenCount ?? undefined
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
const cached = usage.cachedContentTokenCount
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
// inclusive `outputTokens` the contract expects. Only compute the total
// when the visible component is reported — otherwise we'd fabricate an
// inclusive number from a partial breakdown.
const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
const outputTokens =
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
return new Usage({
inputTokens: promptTokens,
inputTokens: usage.promptTokenCount,
outputTokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
reasoningTokens: usage.thoughtsTokenCount,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
providerMetadata: { google: usage },
})
}
@@ -546,10 +537,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
}
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
// `?? undefined` normalizes an explicit `null` blockReason back to absent so
// the "nothing to finish" check below keeps its meaning.
const promptBlockReason =
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
const finishReason = state.finishReason ?? promptBlockReason
if (finishReason === undefined && state.usage === undefined) return []
@@ -598,7 +586,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
for (const part of candidate.content.parts ?? []) {
for (const part of candidate.content.parts) {
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
@@ -637,8 +625,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
// Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
const supplied = part.functionCall.id ?? undefined
const supplied = part.functionCall.id
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
if (supplied !== undefined) seenCallIds.add(supplied)
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
@@ -655,7 +642,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
name: part.functionCall.name,
input,
providerMetadata:
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
part.thoughtSignature === undefined ? undefined : googleMetadata({ thoughtSignature: part.thoughtSignature }),
}),
)
hasToolCalls = true
@@ -112,8 +112,6 @@ 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,
+9 -24
View File
@@ -5,7 +5,6 @@ import { Protocol } from "../route/protocol.js"
import {
AIError,
LLMEvent,
ProviderInternalReason,
Usage,
type FinishReason,
type JsonSchema,
@@ -362,7 +361,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: ToolSchemaProjection.responses(inputSchema),
// The common tool definition does not currently express Responses strict-schema policy.
strict: false,
}
@@ -429,7 +428,7 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
return {
type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
...(url ? { file_url: url } : { file_data: media.dataUrl }),
...(url ? { file_url: url } : { file_data: media.base64 }),
}
}
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
@@ -581,14 +580,10 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
if (store === false) {
// 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) }]
if (store !== false && id && !hostedToolReferences.has(id))
input.push({ type: "item_reference", id })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
@@ -633,7 +628,6 @@ 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 } : {}),
@@ -643,7 +637,7 @@ const lowerOptions = (request: LLMRequest) => {
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
@@ -1111,19 +1105,11 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
: typeof event.status_code === "number"
? event.status_code
: undefined
const reason =
event.type === "error" &&
event.error === undefined &&
event.response === undefined &&
summary === undefined &&
status === undefined
? new ProviderInternalReason({ message })
: classifyProviderFailure({ message, code, status, rawBody: body })
return new AIError({
module: id,
method: "stream",
body,
reason,
reason: classifyProviderFailure({ message, code, status, rawBody: body }),
})
}
@@ -1140,8 +1126,7 @@ export const step = (state: ParserState, event: Event) => {
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
if (!event.item_id || typeof value !== "string")
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
if (!event.item_id || typeof value !== "string") return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
+10 -43
View File
@@ -156,9 +156,6 @@ const OpenAIChatUsage = Schema.StructWithRest(
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
cached_tokens: optionalNull(Schema.Number),
prompt_cache_hit_tokens: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
@@ -207,16 +204,11 @@ const OpenAIChatDelta = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatChoice = Schema.StructWithRest(
Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
native_finish_reason: optionalNull(Schema.String),
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
usage: optionalNull(OpenAIChatUsage),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatChoice = Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
native_finish_reason: optionalNull(Schema.String),
})
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
@@ -269,7 +261,7 @@ const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: Lower
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: ToolSchemaProjection.openAI(inputSchema),
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -517,23 +509,11 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
return messages
})
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
// whenever the conversation history contains tool calls/results. Send an
// explicit empty array when we have history but no active tools.
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
for (const message of messages) {
if (message.role === "tool") return true
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
}
return false
}
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(options.store !== undefined ? { store: options.store } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
}
@@ -552,15 +532,12 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
const hasHistory = hasToolHistory(request.messages)
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
tools:
request.tools.length === 0
? hasHistory
? []
: undefined
? undefined
: request.tools.map((tool) =>
lowerTool(
tool,
@@ -604,18 +581,11 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `AI.Usage` contract is
// satisfied on both sides.
// Providers differ on cache-hit location: OpenAI uses
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
const cached =
(usage.prompt_tokens_details?.cached_tokens ??
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
(usage as { cached_tokens?: number | null }).cached_tokens ??
undefined) as number | undefined
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
@@ -721,11 +691,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
}),
})
const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices?.[0]
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
// `choice.usage` instead of the top-level `usage` field.
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const rawFinishReason = choice?.finish_reason
const finishReason =
rawFinishReason !== undefined && rawFinishReason !== null
@@ -134,12 +134,13 @@ const HOSTED_TOOLS = {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
-11
View File
@@ -24,17 +24,6 @@ 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
@@ -28,11 +28,7 @@ export const ResponseIncludables = [
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
export const ServiceTier = Schema.declare<ServiceTier>(
(value): value is ServiceTier => typeof value === "string",
{ title: "ServiceTier" },
)
export type ServiceTier = (typeof ServiceTiers)[number]
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
@@ -42,7 +38,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
{ title: "ResponseIncludable" },
)
export const ServiceTierSchema = ServiceTier
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export const TruncationSchema = Schema.Literals(Truncations)
export const AllowedTools = Schema.Struct({
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
+32 -1
View File
@@ -2,6 +2,20 @@ 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 {}
@@ -31,7 +45,24 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
return isRecord(projected) ? projected : {}
}
const openAI = (schema: JsonSchema): JsonSchema => schema
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 responses = openAI
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
+1 -1
View File
@@ -42,7 +42,7 @@ const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LL
// unlike AI Studio, so history minted there cannot be lowered verbatim.
const contents = body.contents.map((content) => ({
...content,
parts: (content.parts ?? []).map((part) => {
parts: content.parts.map((part) => {
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
return part
+2 -6
View File
@@ -1,13 +1,9 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
readonly serviceTier?: OpenAIServiceTier
readonly [key: string]: unknown
}
export type OpenAIOptionsInput = OpenResponsesOptionsInput
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
+2 -3
View File
@@ -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, ProviderShared } from "../protocols/shared.js"
import { isRecord } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
@@ -115,12 +115,11 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails,
}
})
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
} as OpenRouterBody
}),
),
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,4 +4,10 @@ import { GoogleVertexChat } from "../../src/providers.js"
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
providerOptions: { serviceTier: "premium" },
})
@@ -8,8 +8,6 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
-67
View File
@@ -1492,73 +1492,6 @@ describe("Gemini route", () => {
}),
)
it.effect("survives explicit null usage counts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] } }] },
{ usageMetadata: { promptTokenCount: null, candidatesTokenCount: 5 } },
),
),
),
)
expect(response.text).toBe("Hi")
expect(response.usage).toMatchObject({ outputTokens: 5, totalTokens: 5 })
expect(response.usage?.inputTokens).toBeUndefined()
expect(response.usage?.nonCachedInputTokens).toBeUndefined()
expect(response.usage?.cacheReadInputTokens).toBeUndefined()
expect(response.usage?.reasoningTokens).toBeUndefined()
}),
)
it.effect("survives null candidates, content, parts, and finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ candidates: null },
{ candidates: [{ content: { role: "model", parts: null } }] },
{ candidates: [{ content: null, finishReason: null }] },
{
candidates: [
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
],
},
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
}),
)
it.effect("treats a null thought flag on a text part as visible output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{ content: { role: "model", parts: [{ text: "Visible", thought: null }] }, finishReason: "STOP" },
],
}),
),
),
)
const reasoningStart = response.events.find((event) => event.type === "reasoning-start")
expect(reasoningStart).toBeUndefined()
expect(response.reasoning ?? "").toBe("")
expect(response.text).toBe("Visible")
}),
)
it.effect("fails invalid stream events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -342,7 +342,6 @@ describe("OpenAI Chat route", () => {
},
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
],
tools: [],
stream: true,
stream_options: { include_usage: true },
})
@@ -78,45 +78,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const pdf = "data:application/pdf;base64,JVBERi0xLjQ="
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([{ type: "media", mediaType: "application/pdf", data: pdf, filename: "input.pdf" }]),
Message.assistant({ type: "tool-call", id: "call_1", name: "read", input: {} }),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [{ type: "file", uri: pdf, mime: "application/pdf", name: "result.pdf" }],
}),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_file", filename: "input.pdf", file_data: pdf }],
},
{ type: "function_call", call_id: "call_1", name: "read", arguments: "{}" },
{
type: "function_call_output",
call_id: "call_1",
output: [{ type: "input_file", filename: "result.pdf", file_data: pdf }],
},
])
}),
)
it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () {
const model = configure({
@@ -153,12 +114,7 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body).toMatchObject({
input: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }], phase: null },
],
})
}),
@@ -233,7 +189,6 @@ describe("Open Responses-compatible route", () => {
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto",
serviceTier: "provider-tier",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
parallelToolCalls: false,
@@ -258,7 +213,6 @@ describe("Open Responses-compatible route", () => {
presence_penalty: 0.2,
frequency_penalty: -0.1,
truncation: "auto",
service_tier: "provider-tier",
tool_choice: {
type: "allowed_tools",
mode: "auto",
@@ -188,15 +188,17 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes through provider-defined service tiers", () =>
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
)
expect(prepared.body.service_tier).toBe("scale")
expect(prepared.body).not.toHaveProperty("service_tier")
}),
)
it.effect("preserves function schemas", () =>
it.effect("flattens top-level object unions in function schemas", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
@@ -236,22 +238,13 @@ describe("OpenAI Responses route", () => {
strict: false,
parameters: {
type: "object",
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"],
},
],
properties: {
path: { type: "string" },
reference: { type: "string" },
limit: { type: "integer", maximum: 2000 },
resource: { type: "string" },
},
additionalProperties: false,
},
},
])
@@ -401,38 +394,6 @@ 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 = {
@@ -1312,7 +1273,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
file_data: "JVBERi0xLjQ=",
},
])
}),
@@ -1339,7 +1300,7 @@ describe("OpenAI Responses route", () => {
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
{ type: "input_file", filename: "report.pdf", file_data: base64 },
])
}),
)
@@ -1372,7 +1333,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
file_data: "JVBERi0xLjQ=",
},
])
}),
@@ -1397,7 +1358,7 @@ describe("OpenAI Responses route", () => {
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
{ type: "input_file", filename: "file", file_data: "AAECAw==" },
])
}),
)
@@ -2311,44 +2272,6 @@ 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" })
@@ -2729,47 +2652,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {
type: "computer_call",
id: "computer_1",
call_id: "call_1",
status: "completed",
action: { type: "click", x: 100, y: 200 },
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
{
type: "tool-call",
id: "computer_1",
name: "computer_use",
input: { type: "click", x: 100, y: 200 },
providerExecuted: true,
providerMetadata: { openai: { itemId: "computer_1" } },
},
{
type: "tool-result",
id: "computer_1",
name: "computer_use",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "computer_1" } },
},
])
}),
)
it.effect("decodes image generation output as image content", () =>
Effect.gen(function* () {
const item = {
@@ -2883,7 +2765,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
file_data: "JVBERi0xLjQ=",
},
],
},
@@ -2914,7 +2796,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
file_data: "JVBERi0xLjQ=",
},
],
},
@@ -2939,7 +2821,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "file",
file_data: "data:application/x-tar;base64,AAECAw==",
file_data: "AAECAw==",
},
],
},
@@ -3147,14 +3029,14 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("classifies a detail-free error event as a transient provider failure", () =>
it.effect("falls back to the raw payload when both error and response are absent", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", sequence_number: 2 }))),
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"type":"error"')
expect(error.body).toBe(error.reason.message)
}),
+31 -10
View File
@@ -50,7 +50,32 @@ describe("tool schema projections", () => {
})
})
it.effect("applies model compatibility without changing schema semantics", () =>
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", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -82,15 +107,11 @@ describe("tool schema projections", () => {
expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
type: "object",
anyOf: [
{
type: "object",
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
},
],
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
additionalProperties: false,
})
}),
)
@@ -899,26 +899,8 @@ function provider() {
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
{
id: "company-gateway",
name: "Company Gateway",
models: {
"fast-nano": {
id: "fast-nano",
api: { id: "openai/gpt-5.4-nano" },
name: "GPT-5.4 nano",
limit: { context: 128_000 },
},
"long-context": {
id: "long-context",
api: { id: "company/long-context" },
name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts",
limit: { context: 128_000 },
},
},
},
],
connected: ["opencode", "company-gateway"],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
@@ -1,106 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("renames, exports, and deletes a home session from its context menu", async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.route("**/api/session/*/rename", async (route) => {
const sessionID = new URL(route.request().url()).pathname.split("/").at(-2)
const session = sessions.find((item) => item.id === sessionID)
const payload: unknown = route.request().postDataJSON()
if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string")
throw new Error("Invalid rename payload")
if (session) session.title = payload.title
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
await expect(row).toBeVisible()
const container = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox()
const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0)
await row.focus()
await row.press("Shift+F10")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
await expect(row).toBeFocused()
const rowBox = await row.boundingBox()
await row.click({ button: "right", position: { x: 48, y: 12 } })
await expect(page).toHaveURL("/")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
await page.getByRole("menuitem", { name: "Rename" }).click()
const title = page.locator('[data-component="home-session-rename"]')
await expect(title).toBeFocused()
await expect(title).toHaveValue(fixture.expected.targetTitle)
const editorBox = await title.boundingBox()
const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
expect(editorBox?.x).toBe(titleBox?.x)
expect(editingAvatarBox).toEqual(avatarBox)
expect(
await title.evaluate((element) => ({
outline: getComputedStyle(element).outlineStyle,
shadow: getComputedStyle(element).boxShadow,
})),
).toEqual({ outline: "none", shadow: "none" })
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
await title.fill("Renamed from Home")
const renamed = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"),
)
await title.press("Enter")
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
await page.getByRole("button", { name: "Home" }).click()
await expect(page).toHaveURL("/")
renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click({ button: "right" })
const download = page.waitForEvent("download")
const exportItem = page.getByRole("menuitem", { name: "Export..." })
await exportItem.click()
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
await expect(exportItem).toBeHidden()
await renamedRow.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete..." }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
)
await dialog.getByRole("button", { name: "Delete session" }).click()
await removed
await expect(renamedRow).toBeHidden()
})
@@ -250,43 +250,6 @@ test.describe("session timeline projection", () => {
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
sessionMessages: [
{
id: "msg_model_fast_nano",
type: "model-switched",
time: { created: 1700000000000 },
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
},
{
id: "msg_model_long_context",
type: "model-switched",
time: { created: 1700000001000 },
model: { providerID: "company-gateway", id: "long-context" },
},
userMessage(),
assistantMessage(),
],
})
const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(shortNotice).toBeVisible()
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice).toBeVisible()
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
@@ -131,11 +131,8 @@ test("animates review and terminal panels while caching hidden terminal content"
await resetTerminalBottomMotion(page)
await resetTerminalAnchorGaps(page)
await resetPanelGaps(page)
const reviewContent = page.locator('[data-component="session-review-v2"]')
await reviewContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await reviewToggle.click()
await expect(page.locator("#review-panel")).toBeHidden()
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
await expect(page.locator("#review-panel")).toHaveCount(0)
await expect(panel).toBeVisible()
await expectHeightMotions(page, "session-side-region", 2)
await expectHeightMotions(page, "session-side-terminal-region", 2)
@@ -145,7 +142,6 @@ test("animates review and terminal panels while caching hidden terminal content"
await expectPanelGapHeld(page)
await reviewToggle.click()
await expect(page.locator("#review-panel")).toBeVisible()
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
await expectHeightMotions(page, "session-side-region", 3)
await expectHeightMotions(page, "session-side-terminal-region", 3)
@@ -165,18 +161,14 @@ test("animates review and terminal panels while caching hidden terminal content"
await reviewToggle.click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await expectWidthMotions(page, 2)
await expectSideSlideSettled(page, 2)
await expectHiddenSideAligned(page)
await resetHeightMotions(page)
await resetHorizontalScrolls(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await expectWidthMotions(page, 3)
await expectSideSlideSettled(page, 3)
await expectSideMotionSettled(page)
await expectNoHeightMotion(page)
await expectNoHorizontalScroll(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeHidden()
@@ -217,8 +209,6 @@ test("animates review and terminal panels while caching hidden terminal content"
type MotionProbe = {
widths: number
widthEnds: number
horizontalScrolls: number[]
reviewWidths: number[]
paintGaps: { review: number; terminalSurface: number }[]
terminalContentSizes: { width: number; height: number }[]
@@ -235,8 +225,6 @@ async function installMotionProbe(page: Page) {
await page.evaluate(() => {
const probe: MotionProbe = {
widths: 0,
widthEnds: 0,
horizontalScrolls: [],
reviewWidths: [],
paintGaps: [],
terminalContentSizes: [],
@@ -311,16 +299,10 @@ async function installMotionProbe(page: Page) {
probe.heights.push(slot)
}
})
document.addEventListener("transitionend", (event) => {
if (!(event.target instanceof Element)) return
if (event.propertyName === "width" && event.target.getAttribute("data-slot") === "session-chat-panel")
probe.widthEnds++
})
document.addEventListener("animationstart", (event) => {
if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return
probe.animations.push(event.animationName)
})
window.addEventListener("scroll", () => probe.horizontalScrolls.push(window.scrollX))
;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe
})
}
@@ -338,10 +320,11 @@ async function resetHeightMotions(page: Page) {
})
}
async function expectSideSlideSettled(page: Page, count: number) {
async function expectSideMotionSettled(page: Page) {
const side = page.locator('[data-slot="session-side-panel-presence"]')
await expect
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
.toBeGreaterThanOrEqual(count)
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
.toBe(true)
}
async function expectNoHeightMotion(page: Page) {
@@ -351,40 +334,6 @@ async function expectNoHeightMotion(page: Page) {
expect(heights).toEqual([])
}
async function expectHiddenSideAligned(page: Page) {
await expect
.poll(() =>
page.evaluate(() => {
const chat = document.querySelector<HTMLElement>('[data-slot="session-chat-panel"]')
const side = document.querySelector<HTMLElement>('[data-slot="session-side-panel-presence"]')
if (!chat?.parentElement || !side) return Number.POSITIVE_INFINITY
const row = chat.parentElement.getBoundingClientRect()
const hidden = side.getBoundingClientRect()
return Math.max(
Math.abs(row.top - hidden.top),
Math.abs(row.right - hidden.right),
Math.abs(row.bottom - hidden.bottom),
)
}),
)
.toBeLessThanOrEqual(1)
}
async function resetHorizontalScrolls(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.horizontalScrolls = []
})
}
async function expectNoHorizontalScroll(page: Page) {
const scrolls = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.horizontalScrolls ?? [],
)
expect(Math.max(0, ...scrolls)).toBe(0)
expect(await page.evaluate(() => window.scrollX)).toBe(0)
}
async function expectReviewWidthStable(page: Page) {
const side = page.locator('[data-slot="session-side-panel-presence"]')
await expect
+1 -1
View File
@@ -501,7 +501,7 @@ function currentModels(value: unknown) {
return [
{
id: model.id,
modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
modelID: model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
+1 -2
View File
@@ -52,12 +52,12 @@
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -69,7 +69,6 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
+3 -133
View File
@@ -1,12 +1,8 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Button } from "@opencode-ai/ui/button"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-ai/ui/dialog"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { skipToken, useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
import type { LocalProject } from "@/shell/state/layout"
@@ -15,10 +11,7 @@ import { ServerConnection } from "@/runtime/server/registry"
import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
@@ -42,8 +35,6 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const queryClient = useQueryClient()
const [removed, setRemoved] = createStore({ keys: [] as string[] })
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
if (!selected) return
@@ -68,13 +59,9 @@ export function createHomeSessionsController(home: HomeController) {
})
const indexedSessions = createMemo(() => {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
if (!ctx) return []
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
),
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()),
HOME_SESSION_LIMIT,
Date.now(),
)
@@ -136,119 +123,6 @@ export function createHomeSessionsController(home: HomeController) {
},
])
const rename = async (server: ServerConnection.Key, session: SessionInfo, title: string) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const next = title.trim()
if (!next || next === sessionLabel(session)) return true
return ctx.sdk.api.session
.rename({ sessionID: session.id, title: next })
.then(() => {
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
// Rename advances time.updated server-side; re-sync the canonical
// record so date grouping and ordering do not go stale.
ctx.data.session.invalidate(session.id)
void ctx.data.session.sync(session.id).catch(() => {})
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.map((item) => (item.id === session.id ? { ...item, title: next } : item)),
)
return true
})
.catch((cause) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
return false
})
}
const exportSession = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!ctx) return
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (cause) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: cause instanceof Error ? cause.message : language.t("toast.session.export.failed.description"),
})
}
}
const remove = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
return ctx.sdk.api.session
.remove({ sessionID: session.id })
.then(() => {
const removedIDs = new Set(ids)
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((item) => !removedIDs.has(item.id)),
)
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
directory: session.location.directory,
sessionIDs: ids,
})
return true
})
.catch((cause) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(cause, language.t("session.delete.failed.title")),
})
return false
})
.finally(() => {
// Always refetch: the pre-mutation cancel may have aborted an
// in-flight index fetch, and a failed delete must not leave the
// index unloaded either.
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
})
}
function DeleteDialog(props: { server: ServerConnection.Key; session: SessionInfo }) {
const name = () => sessionTitle(props.session.title) ?? language.t("command.session.new")
const confirm = async () => {
await remove(props.server, props.session)
dialog.close()
}
return (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</Button>
</DialogFooter>
</Dialog>
)
}
return {
copy: {
language,
@@ -319,10 +193,6 @@ export function createHomeSessionsController(home: HomeController) {
}),
})
},
rename,
export: exportSession,
showDelete: (server: ServerConnection.Key, session: SessionInfo) =>
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) =>
@@ -27,9 +27,6 @@ export function HomeSessions(props: {
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
+40 -282
View File
@@ -1,14 +1,10 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Key } from "@solid-primitives/keyed"
import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { createMemo, For, Show, Suspense } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
@@ -26,7 +22,6 @@ import {
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
const HOME_SESSION_LONG_PRESS_MS = 500
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
@@ -59,9 +54,6 @@ export type HomeSessionsViewProps = {
onCreateSession: () => void
onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
onArchiveSession: (session: SessionInfo) => Promise<void>
onRenameSession: (server: ServerConnection.Key, session: SessionInfo, title: string) => Promise<boolean>
onExportSession: (server: ServerConnection.Key, session: SessionInfo) => Promise<void>
onDeleteSession: (server: ServerConnection.Key, session: SessionInfo) => void
onSetHoverTarget: (element: HTMLElement) => void
onSetThumbTrack: (element: HTMLDivElement) => void
onSetContent: (element: HTMLDivElement) => void
@@ -79,16 +71,7 @@ export type HomeSessionsViewProps = {
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
}
// Session store updates recreate row components, so row-local state would
// close an open context menu or drop an in-progress rename. Keep both keyed
// by session ID at the view root, like the projects list does.
type HomeSessionRowUI = {
menu: { id: string; x: number; y: number } | undefined
editor: { id: string; draft: string; renaming: boolean } | undefined
}
export function HomeSessionsView(props: HomeSessionsViewProps) {
const [rowUI, setRowUI] = createStore<HomeSessionRowUI>({ menu: undefined, editor: undefined })
return (
<section
ref={props.onSetHoverTarget}
@@ -139,29 +122,23 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
{/* Index keeps group subtrees mounted when the group arrays are
rebuilt, so store updates cannot recreate rows mid-gesture. */}
<Index each={props.groups}>
<For each={props.groups}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group().title}
titleOpacity={props.titleOpacity(group().id)}
onSetRef={(element) => props.onSetHeader(group().id, element)}
elevated={index === 0}
title={group.title}
titleOpacity={props.titleOpacity(group.id)}
onSetRef={(element) => props.onSetHeader(group.id, element)}
elevated={index() === 0}
/>
<div class={`flex min-w-0 flex-col gap-px pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}>
{/* Rows key by session ID: session.sync replaces the
stored session object wholesale, so reference-keyed
rows would be disposed mid-interaction whenever a
sync response lands. */}
<Key each={group().sessions} by={(record) => record.session.id}>
{(record) => <HomeSessionRow {...props} record={record()} rowUI={rowUI} setRowUI={setRowUI} />}
</Key>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
</div>
</>
)}
</Index>
</For>
</div>
</Show>
</Suspense>
@@ -435,263 +412,45 @@ function HomeSessionGroupHeader(props: {
)
}
function HomeSessionRow(
props: HomeSessionsViewProps & {
record: HomeSessionRecord
rowUI: HomeSessionRowUI
setRowUI: SetStoreFunction<HomeSessionRowUI>
},
) {
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionLabel(props.record.session))
const showProjectName = () => props.showProjectName && props.record.projectName
const sessionID = () => props.record.session.id
const menu = () => (props.rowUI.menu?.id === sessionID() ? props.rowUI.menu : undefined)
const editor = () => (props.rowUI.editor?.id === sessionID() ? props.rowUI.editor : undefined)
let longPressTimer: ReturnType<typeof setTimeout> | undefined
let longPressStart: { x: number; y: number } | undefined
let suppressClick = false
let menuInteractedOutside = false
// Focus targets are looked up by session ID: session store updates recreate
// row components, so instance refs can point at detached nodes by the time
// deferred focus runs.
const rowSelector = () => `[data-component="home-session-row-container"][data-session-id="${sessionID()}"]`
const rowButton = () =>
document.querySelector<HTMLButtonElement>(`${rowSelector()} [data-component="home-session-row"]`)
const renameInput = () =>
document.querySelector<HTMLInputElement>(`${rowSelector()} [data-component="home-session-rename"]`)
const clearLongPress = () => {
if (longPressTimer !== undefined) clearTimeout(longPressTimer)
longPressTimer = undefined
longPressStart = undefined
}
onCleanup(clearLongPress)
const openMenu = (element: HTMLElement, clientX: number, clientY: number) => {
const bounds = element.getBoundingClientRect()
props.setRowUI("menu", { id: sessionID(), x: clientX - bounds.left, y: clientY - bounds.top })
}
const openEditor = () => {
props.setRowUI("editor", { id: sessionID(), draft: title(), renaming: false })
requestAnimationFrame(() => {
const input = renameInput()
input?.focus()
input?.select()
})
}
const closeEditor = () => {
if (editor()?.renaming) return
props.setRowUI("editor", (value) => (value?.id === sessionID() ? undefined : value))
}
const saveEditor = async () => {
const current = editor()
if (!current || current.renaming) return
props.setRowUI("editor", { ...current, renaming: true })
const saved = await props.onRenameSession(props.server, props.record.session, current.draft)
// Disabling the input during the request drops focus to the body; restore
// it unless the user focused another control while the rename was pending.
const restore = document.activeElement === document.body || document.activeElement === renameInput()
props.setRowUI("editor", (value) => {
if (value?.id !== sessionID()) return value
return saved ? undefined : { ...value, renaming: false }
})
if (!restore) return
requestAnimationFrame(() => {
if (saved) {
rowButton()?.focus()
return
}
renameInput()?.focus()
})
}
return (
<div
data-component="home-session-row-container"
data-session-id={props.record.session.id}
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
onContextMenu={(event) => {
// While renaming, keep the native menu so paste and spelling work.
if (editor()) return
event.preventDefault()
openMenu(event.currentTarget, event.clientX, event.clientY)
}}
>
<Show
when={!editor()}
fallback={
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={false}
/>
<InlineInput
data-component="home-session-rename"
aria-label={props.language.t("common.rename")}
dir="auto"
value={editor()?.draft ?? ""}
disabled={editor()?.renaming ?? false}
class={`
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
const draft = event.currentTarget.value
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
}}
onKeyDown={(event) => {
event.stopPropagation()
// Enter and Escape during IME composition commit or cancel
// the composition, not the rename. Safari can report the
// composition-confirming keydown with isComposing false but
// keyCode 229.
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => rowButton()?.focus())
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
}
>
<button
type="button"
data-component="home-session-row"
aria-haspopup="menu"
aria-expanded={!!menu()}
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onPointerDown={(event) => {
suppressClick = false
if (event.pointerType !== "touch") return
clearLongPress()
const element = event.currentTarget
const x = event.clientX
const y = event.clientY
longPressStart = { x, y }
longPressTimer = setTimeout(() => {
suppressClick = true
clearLongPress()
openMenu(element, x, y)
}, HOME_SESSION_LONG_PRESS_MS)
}}
onPointerMove={(event) => {
if (!longPressStart) return
if (Math.abs(event.clientX - longPressStart.x) <= 8 && Math.abs(event.clientY - longPressStart.y) <= 8)
return
clearLongPress()
}}
onPointerUp={clearLongPress}
onPointerCancel={() => {
clearLongPress()
suppressClick = false
}}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(event.currentTarget, bounds.left + 12, bounds.bottom)
}}
onClick={(event) => {
// The flag stays set until the long-press compatibility click
// arrives, however delayed; keyboard activation (detail 0) is
// never that click and passes through.
if (suppressClick) {
suppressClick = false
if (event.detail !== 0) {
event.preventDefault()
return
}
}
props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })
}}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
</Show>
<Menu
modal={false}
placement="bottom-start"
gutter={2}
open={!!menu()}
onOpenChange={(open) => {
if (open) return
props.setRowUI("menu", (value) => (value?.id === sessionID() ? undefined : value))
<button
type="button"
data-component="home-session-row"
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
}}
>
<Menu.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${menu()?.x ?? 0}px`, top: `${menu()?.y ?? 0}px` }}
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<Menu.Portal>
<Menu.Content
onInteractOutside={() => {
menuInteractedOutside = true
}}
onCloseAutoFocus={(event) => {
// The trigger is an invisible positioning span, so Kobalte's
// default close focus restore has no useful target. Skip the
// row focus when the rename editor owns focus or the user
// dismissed the menu by interacting elsewhere.
event.preventDefault()
const outside = menuInteractedOutside
menuInteractedOutside = false
if (outside || editor()) return
requestAnimationFrame(() => rowButton()?.focus())
}}
>
<Menu.Item onSelect={openEditor}>{props.language.t("common.rename")}</Menu.Item>
<Menu.Item onSelect={() => void props.onExportSession(props.server, props.record.session)}>
{props.language.t("common.export")}...
</Menu.Item>
<Menu.Separator />
<Menu.Item onSelect={() => props.onDeleteSession(props.server, props.record.session)}>
{props.language.t("common.delete")}...
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div
class={`
@@ -722,7 +481,6 @@ function HomeSessionRow(
function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
return (
<span
data-component="home-session-title"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
classList={{
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
+1 -37
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createOpenCodeEventSource, createServerTransport } from "./client"
import { createOpenCodeEventSource } from "./client"
const permission = {
id: "evt_permission",
@@ -84,39 +84,3 @@ describe("server event stream", () => {
second.dispose()
})
})
test("rotates HTTP and PTY clients together", async () => {
const requests: Array<{ url: string; authorization: string | null }> = []
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
return Response.json({ healthy: true, version: "2.0.0-test", pid: 1 })
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", username: "opencode", password: "first" },
fetch,
})
const initialPty = transport.pty
await transport.api.health.get()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
username: "opencode",
password: "second",
})
await transport.api.health.get()
expect(replacement).toBe(transport.api)
expect(transport.pty).not.toBe(initialPty)
expect(transport.url).toBe("http://127.0.0.1:4200")
expect(requests).toEqual([
{
url: "http://127.0.0.1:4100/api/health",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/health",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
})
+7 -42
View File
@@ -72,12 +72,11 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const pty = createPtyClient(api, { url: server.http.url })
const events = createOpenCodeEventSource()
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
const connection = createClientConnection(transport.api, {
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
const connection = createClientConnection(api, {
flushInterval: 16,
pageLifecycle: true,
onEvent(event) {
@@ -86,7 +85,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
log: {
info(message, data) {
if (message !== "event stream disconnected") return
console.info("[global-sdk] event stream disconnected", { url: transport.url, managed: !!reconnect, ...data })
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
},
},
})
@@ -94,48 +93,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
return {
server,
scope,
get url() {
return transport.url
},
get api() {
return transport.api
},
get pty() {
return transport.pty
},
url: server.http.url,
api,
pty,
connection,
event: events.event,
}
}
export function createServerTransport(input: { http: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }): {
update(http: ServerConnection.HttpBase): ServerApi
readonly url: string
readonly api: ServerApi
readonly pty: ReturnType<typeof createPtyClient>
} {
const build = (http: ServerConnection.HttpBase) => {
const api = createApiForServer({ server: http, fetch: input.fetch })
return { http, api, pty: createPtyClient(api, { url: http.url }) }
}
const state = { current: build(input.http) }
return {
update(http: ServerConnection.HttpBase) {
state.current = build(http)
return state.current.api
},
get url() {
return state.current.http.url
},
get api() {
return state.current.api
},
get pty() {
return state.current.pty
},
}
}
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
+1 -1
View File
@@ -210,7 +210,7 @@ export namespace ServerConnection {
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base"; reconnect?: (signal: AbortSignal) => Promise<HttpBase> }
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
+3 -3
View File
@@ -36,10 +36,10 @@ export const loadLspQuery = (scope: ServerScope, directory: string) =>
queryFn: async () => [],
})
function makeQueryOptionsApi(scope: ServerScope, serverAPI: () => ServerApi) {
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
return {
globalConfig: () => loadGlobalConfigQuery(scope),
path: () => loadPathQuery(scope, null, serverAPI().location),
path: () => loadPathQuery(scope, null, serverAPI.location),
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
}
}
@@ -52,7 +52,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (!owner) throw new Error("ServerSync must be created within owner")
const booting = new Map<string, Promise<void>>()
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.api)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const connected = () => serverSDK.connection.status() === "connected"
const [configQuery, pathQuery] = useQueries(() => ({
+2 -2
View File
@@ -101,11 +101,11 @@ export const focusTerminalById = (id: string) => {
const textarea = terminal.querySelector("textarea")
if (textarea instanceof HTMLTextAreaElement) {
textarea.focus({ preventScroll: true })
textarea.focus()
return true
}
terminal.focus({ preventScroll: true })
terminal.focus()
terminal.dispatchEvent(
typeof PointerEvent === "function"
? new PointerEvent("pointerdown", { bubbles: true, cancelable: true })
+1 -2
View File
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
classList={{
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
@@ -241,7 +241,6 @@ export function SessionScreen(props: { session: SessionModel }) {
if (event.currentTarget !== event.target) return
if (event.animationName !== "side-region-presence-out") return
if (screen.side.region.open()) return
if (sideTerminalVisible()) return
setStore("sideRegionPresent", false)
setStore("sideReviewPresent", false)
}}
@@ -1,15 +0,0 @@
import { describe, expect, test } from "bun:test"
import { removedSessionIDs } from "./session-domain"
describe("removedSessionIDs", () => {
test("includes all descendants without unrelated sessions", () => {
const sessions = [
{ id: "root" },
{ id: "child", parentID: "root" },
{ id: "grandchild", parentID: "child" },
{ id: "other" },
]
expect([...removedSessionIDs(sessions, "root")]).toEqual(["root", "child", "grandchild"])
})
})
@@ -17,19 +17,3 @@ export function selectVisibleSessionUserMessages(messages: SessionMessageUser[],
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
export function removedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}
@@ -347,9 +347,9 @@ export const Terminal = (props: TerminalProps) => {
const focusTerminal = () => {
const t = term
if (!t) return
const focus = () => (t.textarea ? t.textarea.focus({ preventScroll: true }) : t.focus())
focus()
setTimeout(focus, 0)
t.focus()
t.textarea?.focus()
setTimeout(() => t.textarea?.focus(), 0)
}
const handlePointerDown = () => {
const activeElement = document.activeElement
@@ -31,3 +31,19 @@ export function timelineChildTitle(input: {
if (input.taskDescription) return input.taskDescription
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
}
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}
@@ -11,13 +11,17 @@ import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useTabs } from "@/shell/tabs/tabs"
import type { SessionModel } from "@/session/model"
import { removedSessionIDs } from "@/session/session-domain"
import { useServerSDK } from "@/runtime/server/client"
import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "@/session/title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { showToast } from "@/shell/notifications/toast"
import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessages } from "./controller-projection"
import {
applyTimelineMessageHandoff,
timelineChildTitle,
timelineRemovedSessionIDs,
visibleTimelineMessages,
} from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
@@ -167,7 +171,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
return false
})
if (!success) return false
const removed = removedSessionIDs(data.session.list(), id)
const removed = timelineRemovedSessionIDs(data.session.list(), id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true
@@ -1,7 +1,6 @@
import { DataProvider } from "@opencode-ai/session-ui/context"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, type ParentProps } from "solid-js"
import { useProviders } from "@/providers/catalog/providers"
import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
@@ -31,11 +30,7 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const providers = useProviders(directory)
const sessionUIData = createMemo(() => ({
provider: providers.ready()
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
: undefined,
session: data.session.list(),
session_status: Object.fromEntries(
data.session
+1 -5
View File
@@ -185,11 +185,7 @@ 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}`)
// 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 Bun.write(archive, response)
await $`unzip -oq ${archive} -d ${cache}`
await rm(archive)
return executable
+1 -1
View File
@@ -4,7 +4,7 @@
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"exclude": ["dist", "dist-node"]
+1 -1
View File
@@ -37,7 +37,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-rc.111",
"effect": "4.0.0-rc.110",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
File diff suppressed because it is too large Load Diff
+7 -51
View File
@@ -6,9 +6,7 @@
import type {
AgentInfo,
CommandInfo,
FormCancelInput,
FormInfo,
FormReplyInput,
IntegrationInfo,
LocationRef,
LocationGetOutput,
@@ -39,12 +37,7 @@ import type {
import { Worktree } from "@opencode-ai/schema/worktree"
import { SessionID } from "@opencode-ai/schema/session-id"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import {
isFormAlreadySettledError,
isFormNotFoundError,
isPermissionNotFoundError,
type SessionPromptInput,
} from "../promise"
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
import { createStore, produce, reconcile } from "solid-js/store"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
@@ -127,16 +120,6 @@ function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
}
function formRequestOptions(sessionID: string, ref?: LocationRef) {
if (sessionID !== "global" || !ref) return undefined
return {
headers: {
"x-opencode-directory": encodeURIComponent(ref.directory),
...(ref.workspaceID ? { "x-opencode-workspace": ref.workspaceID } : {}),
},
}
}
function createSync() {
type Pending = { promise: Promise<void>; invalidated: boolean }
const state = new Map<string, true | Pending>()
@@ -245,32 +228,6 @@ export function createData(config: CreateDataInput) {
)
}
function removeForm(sessionID: string, formID: string, ref?: LocationRef) {
const forms = store.session.form[sessionID]
if (!forms) return false
const location = ref && locationKey(ref)
const next = forms.filter((form) => {
if (form.id !== formID) return true
if (sessionID !== "global" || !location) return false
return !form.location || locationKey(form.location) !== location
})
if (next.length === forms.length) return false
setStore("session", "form", sessionID, next)
return true
}
function settleForm(input: FormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
return request
.catch((error: unknown) => {
if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID) throw error
})
.then(() => {
if (!removeForm(input.sessionID, input.formID, ref)) return
result.session.form.invalidate(input.sessionID, ref)
void result.session.form.sync(input.sessionID, ref).catch(() => undefined)
})
}
function updatePending(sessionID: string, inboxID: string, delivery: SessionInbox.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inboxID) ?? -1
const item = store.session.pending[sessionID]?.[index]
@@ -1041,7 +998,12 @@ export function createData(config: CreateDataInput) {
return
case "form.replied":
case "form.cancelled":
removeForm(event.data.sessionID, event.data.id, event.location)
setStore(
"session",
"form",
event.data.sessionID,
(store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id),
)
return
}
@@ -1458,12 +1420,6 @@ export function createData(config: CreateDataInput) {
`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`,
)
},
reply(input: FormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)))
},
cancel(input: FormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)))
},
},
},
project: {
+5 -33
View File
@@ -2,26 +2,20 @@
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { rm } from "node:fs/promises"
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 (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
@@ -35,38 +29,16 @@ try {
return [
key,
{
import: output(value),
types: output(value, true),
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
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`
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`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from "drizzle-kit"
export default defineConfig({
out: "./migrations/",
strict: true,
schema: ["./src/**/*.sql.ts"],
verbose: true,
dialect: "mysql",
+9 -2
View File
@@ -3,11 +3,19 @@ import { Resource } from "@opencode-ai/console-resource"
export * from "drizzle-orm"
import { Client } from "@planetscale/database"
import type { MySqlTransactionConfig } from "drizzle-orm/mysql-core"
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
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,
@@ -18,7 +26,6 @@ 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<{
+3 -3
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "3fb67508-0196-4bae-b2bd-c08ece7583fd",
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
"ddl": [
{
"name": "account_state",
@@ -1442,7 +1442,7 @@
},
{
"type": "text",
"notNull": false,
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
+1 -14
View File
@@ -48,7 +48,7 @@ async function generate() {
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
),
)
await Bun.write(snapshot, await formatJson(await Bun.file(path.join(incremental, name, "snapshot.json")).text()))
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
}
await fs.mkdir(full)
@@ -192,19 +192,6 @@ async function formatTypescript(input: string) {
})
}
// Drizzle emits every array multi-line; format the snapshot so regeneration
// diffs stay minimal against the prettier-styled checked-in copy.
async function formatJson(input: string) {
const prettier = await import("prettier")
const babel = await import("prettier/plugins/babel")
const estree = await import("prettier/plugins/estree")
return prettier.format(input, {
parser: "json",
plugins: [babel.default, estree.default],
printWidth: 120,
})
}
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration.js"
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}.js"`).join("\n")}
+1 -1
View File
@@ -197,7 +197,7 @@ const evaluateShell = Effect.fnUntraced(function* (
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.preferred()
const shell = yield* services.shell.resolve({ preference: "configured" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
@@ -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 { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteAsyncDialect } 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,13 +53,12 @@ export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extend
const cache = yield* EffectCache
const logger = yield* EffectLogger
const useJitMappers = jitCompatCheck(config.jit)
const dialect = new SQLiteDialect({ useJitMappers })
const dialect = new SQLiteAsyncDialect()
const relations = config.relations ?? ({} as TRelations)
const session = new EffectSQLiteSession(client, dialect, relations, {
logger,
cache,
useJitMappers,
useJitMappers: jitCompatCheck(config.jit),
})
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 { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteAsyncDialect } 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: SQLiteDialect,
dialect: SQLiteAsyncDialect,
protected relations: TRelations,
private options: EffectSQLiteSessionOptions,
) {
@@ -41,34 +41,14 @@ export function orderSelectedFields<TColumn extends Column>(
): SelectedFieldsOrdered {
return Object.entries(fields).flatMap(([name, field]) => {
const path = pathPrefix ? [...pathPrefix, name] : [name]
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, Column) || is(field, SQL) || is(field, SQL.Aliased) || is(field, Subquery)) {
return [{ path, field }] 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 { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteAsyncDialect } 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: SQLiteDialect,
readonly dialect: SQLiteAsyncDialect,
/** @internal */
readonly session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
relations: TRelations,
@@ -3,15 +3,14 @@ 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 { SQL, sql } from "drizzle-orm/sql/sql"
import { Param, 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,
SQLiteInsertSelection,
SQLiteInsertSelectQueryBuilder,
SQLiteInsertValue,
} from "drizzle-orm/sqlite-core/query-builders/insert"
import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types"
@@ -153,24 +152,34 @@ export class SQLiteEffectInsertBuilder<
if (values.length === 0) {
throw new Error("values() must be called with at least one value")
}
return new SQLiteEffectInsertBase(this.table, values, this.session, this.dialect, this.withList)
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)
}
select(
selectQuery: (qb: QueryBuilder) => TypedQueryBuilder<SQLiteInsertSelection<TTable>>,
selectQuery: (qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<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: TypedQueryBuilder<SQLiteInsertSelection<TTable>>,
selectQuery: SQLiteInsertSelectQueryBuilder<TTable>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery:
| SQL
| TypedQueryBuilder<SQLiteInsertSelection<TTable>>
| ((qb: QueryBuilder) => TypedQueryBuilder<SQLiteInsertSelection<TTable>> | SQL),
| SQLiteInsertSelectQueryBuilder<TTable>
| ((qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<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 { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteAsyncDialect } 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: SQLiteDialect,
private dialect: SQLiteAsyncDialect,
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 { SQLiteSelectBase } from "drizzle-orm/sqlite-core/query-builders/select"
import { SQLiteSelectQueryBuilderBase } 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,9 +164,10 @@ export interface SQLiteEffectSelectBase<
out TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLiteSelectBase<
> extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
@@ -192,9 +193,10 @@ export class SQLiteEffectSelectBase<
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
extends SQLiteSelectBase<
extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
@@ -213,7 +215,7 @@ export class SQLiteEffectSelectBase<
}
/** @internal */
override getSQL(): SQL {
getSQL(): SQL {
return this.dialect.buildSelectQuery(this.effectConfig)
}
@@ -238,7 +240,7 @@ export class SQLiteEffectSelectBase<
return query as ReturnType<this["prepare"]>
}
override $withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) {
$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 { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteAsyncDialect } 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, resolveNullableObjectPaths } from "../../internal/drizzle-utils.js"
import { mapResultRow } from "../../internal/drizzle-utils.js"
import { SQLiteEffectDatabase } from "./db.js"
type MigrationConfigWithInit = MigrationConfig & { init?: boolean }
@@ -155,10 +155,7 @@ export class SQLiteEffectPreparedQuery<
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["all"]>) ??
makeJitQueryMapper<T["all"]>(
this.fields!,
resolveNullableObjectPaths(this.fields!, this.joinsNotNullableMap),
))(rows as unknown[][])
makeJitQueryMapper<T["all"]>(this.fields!, this.joinsNotNullableMap))(rows as unknown[][])
: (rows as unknown[][]).map((row) => mapResultRow(this.fields!, row, this.joinsNotNullableMap))
}
@@ -190,10 +187,7 @@ export class SQLiteEffectPreparedQuery<
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["get"][]>) ??
makeJitQueryMapper<T["get"][]>(
this.fields!,
resolveNullableObjectPaths(this.fields!, this.joinsNotNullableMap),
))([row as unknown[]])[0]
makeJitQueryMapper<T["get"][]>(this.fields!, this.joinsNotNullableMap))([row as unknown[]])[0]
: mapResultRow(this.fields!, row as unknown[], this.joinsNotNullableMap)
}
@@ -317,7 +311,7 @@ export abstract class SQLiteEffectSession<
> {
static readonly [entityKind]: string = "SQLiteEffectSession"
constructor(readonly dialect: SQLiteDialect) {}
constructor(readonly dialect: SQLiteAsyncDialect) {}
abstract prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
@@ -418,7 +412,7 @@ export abstract class SQLiteEffectTransaction<
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
constructor(
dialect: SQLiteDialect,
dialect: SQLiteAsyncDialect,
session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
protected relations: TRelations,
) {
-2
View File
@@ -44,7 +44,6 @@ import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
export const migrations = [
m00,
@@ -92,5 +91,4 @@ export const migrations = [
m42,
m43,
m44,
m45,
] satisfies DatabaseMigration.Migration[]
@@ -1,27 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260823191254_nullable_workspace_binding",
foreignKeys: false,
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`__new_workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text,
\`created_at\` integer NOT NULL,
\`last_used_at\` integer NOT NULL
);
`)
yield* tx.run(
`INSERT INTO \`__new_workspace\`(\`id\`, \`provider\`, \`binding\`, \`created_at\`, \`last_used_at\`) SELECT \`id\`, \`provider\`, \`binding\`, \`created_at\`, \`last_used_at\` FROM \`workspace\`;`,
)
yield* tx.run(`DROP TABLE \`workspace\`;`)
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
})
},
}
export default migration
+1 -1
View File
@@ -223,7 +223,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text,
\`binding\` text NOT NULL,
\`created_at\` integer NOT NULL,
\`last_used_at\` integer NOT NULL
);
+1 -7
View File
@@ -63,13 +63,7 @@ const baseLayer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const search = yield* FileSystemSearch.Service
// 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 root = 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))
+18 -25
View File
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Clock, Context, Deferred, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
@@ -61,7 +61,7 @@ export const ripgrepLayer = Layer.effect(
let index = emptyIndex()
let initialized = false
let settledAt = Number.NEGATIVE_INFINITY
let refreshing: Deferred.Deferred<void> | undefined
let refreshing = false
const scan = Effect.gen(function* () {
const next = emptyIndex()
const previous = index
@@ -84,25 +84,21 @@ export const ripgrepLayer = Layer.effect(
})
index = next
initialized = true
}).pipe(Effect.orDie)
const refresh = Effect.suspend(() => {
if (refreshing) return initialized ? Effect.void : Deferred.await(refreshing)
if (initialized && clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return Effect.void
const attempt = Deferred.makeUnsafe<void>()
refreshing = attempt
return scan.pipe(
Effect.onExit((exit) =>
Effect.sync(() => {
settledAt = clock.currentTimeMillisUnsafe()
if (refreshing === attempt) refreshing = undefined
Deferred.doneUnsafe(attempt, exit)
}),
),
Effect.forkIn(scope),
Effect.flatMap(() => (initialized ? Effect.void : Deferred.await(attempt))),
)
})
}).pipe(
Effect.orDie,
Effect.ensuring(
Effect.sync(() => {
settledAt = clock.currentTimeMillisUnsafe()
refreshing = false
}),
),
)
const refresh = Effect.sync(() => {
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
refreshing = true
return scan
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
yield* refresh
return Service.of({
find: (input) =>
Effect.gen(function* () {
@@ -184,12 +180,9 @@ 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
}),
@@ -7,6 +7,7 @@ import {
import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types.js"
import { localShellInputSchema, localShellOutputSchema } from "./tool/local-shell.js"
/**
* Check if a string is a file ID based on the given prefixes
@@ -22,11 +23,13 @@ export async function convertToOpenAIResponsesInput({
systemMessageMode,
fileIdPrefixes,
store,
hasLocalShellTool = false,
}: {
prompt: LanguageModelV3Prompt
systemMessageMode: "system" | "developer" | "remove"
fileIdPrefixes?: readonly string[]
store: boolean
hasLocalShellTool?: boolean
}): Promise<{
input: OpenAIResponsesInput
warnings: Array<SharedV3Warning>
@@ -135,6 +138,25 @@ export async function convertToOpenAIResponsesInput({
break
}
if (hasLocalShellTool && part.toolName === "local_shell") {
const parsedInput = localShellInputSchema.parse(part.input)
input.push({
type: "local_shell_call",
call_id: part.toolCallId,
id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined,
action: {
type: "exec",
command: parsedInput.action.command,
timeout_ms: parsedInput.action.timeoutMs,
user: parsedInput.action.user,
working_directory: parsedInput.action.workingDirectory,
env: parsedInput.action.env,
},
})
break
}
input.push({
type: "function_call",
call_id: part.toolCallId,
@@ -239,6 +261,15 @@ export async function convertToOpenAIResponsesInput({
}
}
if (hasLocalShellTool && part.toolName === "local_shell" && output.type === "json") {
input.push({
type: "local_shell_call_output",
call_id: part.toolCallId,
output: localShellOutputSchema.parse(output.value).output,
})
break
}
let contentValue: string
switch (output.type) {
case "text":
@@ -9,6 +9,8 @@ export type OpenAIResponsesInputItem =
| OpenAIResponsesFunctionCall
| OpenAIResponsesFunctionCallOutput
| OpenAIResponsesComputerCall
| OpenAIResponsesLocalShellCall
| OpenAIResponsesLocalShellCallOutput
| OpenAIResponsesReasoning
| OpenAIResponsesItemReference
| OpenAIResponsesMcpApprovalResponse
@@ -67,6 +69,26 @@ export type OpenAIResponsesComputerCall = {
status?: string
}
export type OpenAIResponsesLocalShellCall = {
type: "local_shell_call"
id?: string
call_id: string
action: {
type: "exec"
command: string[]
timeout_ms?: number
user?: string
working_directory?: string
env?: Record<string, string>
}
}
export type OpenAIResponsesLocalShellCallOutput = {
type: "local_shell_call_output"
call_id: string
output: string
}
export type OpenAIResponsesItemReference = {
type: "item_reference"
id: string
@@ -177,6 +199,9 @@ export type OpenAIResponsesTool =
quality: "auto" | "low" | "medium" | "high" | undefined
size: "auto" | "1024x1024" | "1024x1536" | "1536x1024" | undefined
}
| {
type: "local_shell"
}
export type OpenAIResponsesReasoning = {
type: "reasoning"
@@ -29,6 +29,7 @@ import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-rea
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
import { localShellInputSchema } from "./tool/local-shell.js"
const webSearchCallItem = z.object({
type: z.literal("web_search_call"),
@@ -85,6 +86,20 @@ const codeInterpreterCallItem = z.object({
.nullable(),
})
const localShellCallItem = z.object({
type: z.literal("local_shell_call"),
id: z.string(),
call_id: z.string(),
action: z.object({
type: z.literal("exec"),
command: z.array(z.string()),
timeout_ms: z.number().optional(),
user: z.string().optional(),
working_directory: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
}),
})
const imageGenerationCallItem = z.object({
type: z.literal("image_generation_call"),
id: z.string(),
@@ -190,6 +205,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
systemMessageMode: modelConfig.systemMessageMode,
fileIdPrefixes: this.config.fileIdPrefixes,
store,
hasLocalShellTool: hasOpenAITool("openai.local_shell"),
})
warnings.push(...inputWarnings)
@@ -446,6 +462,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
fileSearchCallItem,
codeInterpreterCallItem,
imageGenerationCallItem,
localShellCallItem,
z.object({
type: z.literal("function_call"),
call_id: z.string(),
@@ -543,6 +560,22 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
break
}
case "local_shell_call": {
content.push({
type: "tool-call",
toolCallId: part.call_id,
toolName: "local_shell",
input: JSON.stringify({ action: part.action } satisfies z.infer<typeof localShellInputSchema>),
providerMetadata: {
copilot: {
itemId: part.id,
},
},
})
break
}
case "message": {
for (const contentPart of part.content) {
if (options.providerOptions?.copilot?.logprobs && contentPart.logprobs) {
@@ -1060,6 +1093,27 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
result: value.item.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
})
} else if (value.item.type === "local_shell_call") {
ongoingToolCalls[value.output_index] = undefined
controller.enqueue({
type: "tool-call",
toolCallId: value.item.call_id,
toolName: "local_shell",
input: JSON.stringify({
action: {
type: "exec",
command: value.item.action.command,
timeoutMs: value.item.action.timeout_ms,
user: value.item.action.user,
workingDirectory: value.item.action.working_directory,
env: value.item.action.env,
},
} satisfies z.infer<typeof localShellInputSchema>),
providerMetadata: {
copilot: { itemId: value.item.id },
},
})
} else if (value.item.type === "message") {
if (currentTextId) {
controller.enqueue({
@@ -1474,6 +1528,7 @@ const responseOutputItemDoneSchema = z.object({
imageGenerationCallItem,
webSearchCallItem,
fileSearchCallItem,
localShellCallItem,
z.object({
type: z.literal("computer_call"),
id: z.string(),
@@ -70,6 +70,12 @@ export function prepareResponsesTools({
break
}
case "openai.local_shell": {
openaiTools.push({
type: "local_shell",
})
break
}
case "openai.web_search_preview": {
const args = webSearchPreviewArgsSchema.parse(tool.args)
openaiTools.push({
@@ -0,0 +1,16 @@
import { z } from "zod/v4"
export const localShellInputSchema = z.object({
action: z.object({
type: z.literal("exec"),
command: z.array(z.string()),
timeoutMs: z.number().optional(),
user: z.string().optional(),
workingDirectory: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
}),
})
export const localShellOutputSchema = z.object({
output: z.string(),
})
+6 -20
View File
@@ -8,27 +8,13 @@ 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(
// 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(),
}),
Effect.tryPromise({
try: () => import("@silvia-odwyer/photon-node"),
catch: () => new ResizerUnavailableError(),
}),
)
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
+1 -8
View File
@@ -147,14 +147,7 @@ export function buildLocationServiceMap(
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{
// Workspace-placed directories exist only inside the workspace, so a
// local stat consults the wrong filesystem. Workspace liveness is
// owned by placement; do not probe the sandbox here, which would
// provision lazily-idle workspaces.
idleTimeToLive: (ref) =>
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
},
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? Duration.infinity : Duration.zero) },
),
(inner) => ({
...inner,
+9 -34
View File
@@ -273,53 +273,28 @@ export const layer = (options?: Options) =>
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
const credentialID = found.id
const methodID = found.value.methodID
const integrationID = entry.integrationID
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
let presented = found.value.refresh
const readOAuthCredential = async () => {
const stored = await Effect.runPromise(credentials.list(integrationID))
const match = stored.find((credential) => credential.id === credentialID)
return match && match.value.type === "oauth" ? match.value : undefined
}
let current: Credential.OAuth | undefined = found.value
return MCPOAuth.provider({
...base,
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
// connection may have already replaced ours; deleting then would discard the newer valid credential and
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
// credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") return
const oauth = await readOAuthCredential()
if (!oauth || oauth.refresh !== presented) return
current = undefined
await Effect.runPromise(credentials.remove(credentialID))
},
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
// token fails with invalid_grant.
store: {
tokens: async () => {
const oauth = await readOAuthCredential()
if (!oauth) return undefined
presented = oauth.refresh
return MCPOAuth.toTokens(oauth)
},
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
saveTokens: async (tokens) => {
const previous = await readOAuthCredential()
const value = MCPOAuth.toCredential({
current = MCPOAuth.toCredential({
methodID,
serverUrl: remote.url,
tokens,
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
})
presented = value.refresh
await Effect.runPromise(credentials.update(credentialID, { value }))
},
clientInformation: async () => {
const oauth = await readOAuthCredential()
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
await Effect.runPromise(credentials.update(credentialID, { value: current }))
},
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
saveClientInformation: async () => {},
codeVerifier: async () => undefined,
saveCodeVerifier: async () => {},
+4
View File
@@ -6,6 +6,7 @@ import { Global } from "@opencode-ai/util/global"
import { Effect, Stream } from "effect"
import path from "path"
import { Agent } from "../agent.js"
import { Environment } from "../environment/index.js"
import { Permission } from "../permission.js"
import { SessionEvent } from "../session/event.js"
@@ -27,9 +28,12 @@ You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply.
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
const environment = yield* Environment.Service
const global = yield* Global.Service
const directory = path.join(global.home, ".opencode", "plan")
const enterReminder = enter(directory)
yield* environment.files.mkdir(directory).pipe(Effect.orDie)
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
+1 -1
View File
@@ -164,7 +164,7 @@ const layer = () =>
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || (yield* shell.preferred())
const command = input.command || (yield* shell.resolve({ preference: "configured" }))
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
+13 -8
View File
@@ -30,7 +30,13 @@ export interface Coordinator<Key, E, Reason = never> {
* with this execution's exit.
*/
type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
/**
* Resolves with the execution's exit as a success value. Success-valued on purpose:
* completing a Deferred with an interrupted exit interrupts suspended waiters as it
* resumes them, and can starve later waiters of their resume entirely
* (Effect-TS/effect#7364). Joiners flatten the exit; idleness waiters just await.
*/
readonly done: Deferred.Deferred<Exit.Exit<void, E>>
owner?: Fiber.Fiber<void>
scope: Promotable
pendingWake?: Promotable
@@ -78,7 +84,7 @@ export const make = <Key, E, Reason = never>(options: {
const start = (key: Key, force: boolean, scope: Promotable) => {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
done: Deferred.makeUnsafe<Exit.Exit<void, E>>(),
scope,
stopping: false,
}
@@ -108,7 +114,7 @@ export const make = <Key, E, Reason = never>(options: {
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
Deferred.doneUnsafe(execution.done, Exit.succeed(exit))
}
const run = (key: Key): Effect.Effect<void, E> =>
@@ -116,11 +122,10 @@ export const make = <Key, E, Reason = never>(options: {
const execution = executions.get(key)
if (execution !== undefined) {
// A stopping execution refuses joiners: wait out its cleanup, then run fresh.
if (execution.stopping)
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(run(key)))
return Deferred.await(execution.done)
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done).pipe(Effect.flatten)
}
return Deferred.await(start(key, true, "input").done)
return Deferred.await(start(key, true, "input").done).pipe(Effect.flatten)
})
const wake = (key: Key, scope: Promotable = "input") =>
@@ -162,7 +167,7 @@ export const make = <Key, E, Reason = never>(options: {
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined) return Effect.void
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
return Deferred.await(execution.done).pipe(Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
+21 -6
View File
@@ -10,7 +10,7 @@ import {
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Config, Data, Effect, Exit, Fiber, FiberMap, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
@@ -165,7 +165,9 @@ const layer = Layer.effect(
)
})
// Title generation starts once input is visible and must not delay model execution.
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
// The in-flight set coalesces overlapping prompts while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
* Execution lifecycle is published per busy period by SessionExecution, not here.
@@ -332,10 +334,7 @@ const layer = Layer.effect(
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0)
yield* FiberMap.run(titles, sessionID, title.generateForFirstPrompt(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
if (promoted > 0) yield* startTitle(sessionID)
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected)
@@ -716,6 +715,22 @@ const layer = Layer.effect(
}
})
/** Starts one title request at a time after a successful step makes user input visible. */
const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titlesRunning.has(sessionID)) return
titlesRunning.add(sessionID)
forkTitle(
title.generateForFirstPrompt(sessionID).pipe(
Effect.ignore,
Effect.ensuring(
Effect.sync(() => {
titlesRunning.delete(sessionID)
}),
),
),
)
})
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
+7 -7
View File
@@ -30,6 +30,9 @@ export const RETENTION = Duration.days(7)
export const DIRECTORY = "shell"
type Info = Shell.Info
type CreateInput = Shell.CreateInput & {
shell?: string
}
type Active = {
// Immutable snapshot; lifecycle updates replace it via immer `produce`.
@@ -52,9 +55,8 @@ type Active = {
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
*/
export interface Interface {
readonly name: () => Effect.Effect<string>
readonly create: <E = never, R = never>(
input: Shell.CreateInput,
input: CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
// Currently running commands only; exited shells are retained for get/output but excluded here.
@@ -185,8 +187,6 @@ const layer = () =>
return session.info
})
const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
@@ -218,7 +218,7 @@ const layer = () =>
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
input: CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const sessionID = input.metadata?.sessionID
@@ -230,7 +230,7 @@ const layer = () =>
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* shell.preferred(),
shell: input.shell ?? (yield* shell.resolve({ preference: "configured" })),
env: {
...(sessionEnvironment ?? process.env),
TERM: "xterm-256color",
@@ -383,7 +383,7 @@ const layer = () =>
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
return Service.of({ create, list, get, wait, timeout, output, remove })
}),
)
+27 -33
View File
@@ -41,8 +41,12 @@ export type Draft = {
configure: (shell: string) => void
}
export type ResolveInput = {
preference: "configured" | "compatible"
}
export interface Interface extends State.Transformable<Draft> {
readonly preferred: () => Effect.Effect<string>
readonly resolve: (input: ResolveInput) => Effect.Effect<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
@@ -70,7 +74,7 @@ function meta(file: string) {
return META[name(file)]
}
function ok(file: string) {
function compatible(file: string) {
return meta(file)?.deny !== true
}
@@ -78,7 +82,7 @@ function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string, options?: Options, bin?: string) {
function executable(file: string, options?: Options, bin?: string) {
const shell = full(file, options, bin)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
@@ -108,9 +112,9 @@ async function unix() {
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file, options, bin)
function select(file: string | undefined, options?: Options, opts?: { compatible?: boolean }, bin?: string) {
if (file && (!opts?.compatible || compatible(file))) {
const shell = executable(file, options, bin)
if (shell) return shell
}
if (process.platform === "win32") return win(options, bin)[0]
@@ -151,8 +155,8 @@ function info(file: string, options?: Options, bin?: string): Item {
const n = name(item)
return {
path: item,
name: resolve(n, options, bin) ? n : item,
acceptable: ok(item),
name: executable(n, options, bin) ? n : item,
acceptable: compatible(item),
}
}
@@ -163,38 +167,28 @@ export function args(file: string, command: string) {
return ["-c", command]
}
let defaultPreferred: { bin?: string; value: string } | undefined
let defaultAcceptable: { bin?: string; value: string } | undefined
let defaultConfigured: { bin?: string; value: string } | undefined
let defaultCompatible: { bin?: string; value: string } | undefined
export function preferred(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options, undefined, bin)
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
const cached = defaultPreferred
export function resolve(input: ResolveInput, configShell?: string, options?: Options, bin?: string) {
const filter = input.preference === "compatible" ? { compatible: true } : undefined
if (configShell) return select(configShell, options, filter, bin)
if (options?.gitbash) return select(process.env.SHELL, options, filter, bin)
const cached = input.preference === "compatible" ? defaultCompatible : defaultConfigured
if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
defaultPreferred = { bin, value }
const value = select(process.env.SHELL, undefined, filter, bin) ?? fallback(bin)
if (input.preference === "compatible") defaultCompatible = { bin, value }
if (input.preference === "configured") defaultConfigured = { bin, value }
return value
}
preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options, { acceptable: true }, bin)
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
const cached = defaultAcceptable
if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
defaultAcceptable = { bin, value }
return value
}
acceptable.reset = () => {
defaultAcceptable = undefined
resolve.reset = () => {
defaultConfigured = undefined
defaultCompatible = undefined
}
export async function list(options?: Options, bin?: string): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options, bin) : await unix()
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
return shells.filter((shell) => executable(shell, options, bin)).map((shell) => info(shell, options, bin))
}
const layer = (options?: Options) =>
@@ -214,7 +208,7 @@ const layer = (options?: Options) =>
return Service.of({
transform: state.transform,
reload: state.reload,
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
resolve: (input) => Effect.sync(() => resolve(input, state.get().shell, options, global.bin)),
})
}),
)
-1
View File
@@ -90,7 +90,6 @@ const layer = Layer.effect(
) {
const beforeEvent: PluginHooks.Domains["tool"]["execute.before"] = {
tool: name,
inputSchema: definition(tool).inputSchema,
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
+5 -1
View File
@@ -13,6 +13,7 @@ import { NonNegativeInt } from "../../schema.js"
import { SessionSchema } from "../../session/schema.js"
import { Shell } from "../../shell.js"
import { ShellParse } from "../../shell/parse.js"
import { ShellSelect } from "../../shell/select.js"
import { ToolOutput } from "../../tool-output.js"
export const name = "shell"
@@ -109,6 +110,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const permission = yield* Permission.Service
const config = yield* Config.Service
@@ -185,6 +187,7 @@ export const Plugin = {
command: input.command,
cwd: input.workdir,
timeout,
shell: yield* shellSelect.resolve({ preference: "compatible" }),
metadata: { sessionID: context.sessionID },
},
(invocation) =>
@@ -340,7 +343,8 @@ export const Plugin = {
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
tool.description = description(yield* shell.name())
const selected = yield* shellSelect.resolve({ preference: "compatible" })
tool.description = description(ShellSelect.name(selected))
}),
)
}),
+38 -121
View File
@@ -3,7 +3,7 @@ export * as Workspace from "./workspace.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { eq } from "drizzle-orm"
import { Clock, Context, Deferred, Duration, Effect, Exit, FiberSet, Layer, Ref, Schedule, Schema, Scope } from "effect"
import { Clock, Context, Duration, Effect, Exit, Layer, Ref, Schedule, Schema, Scope } from "effect"
import { systemError } from "effect/PlatformError"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver as EnvironmentDriver } from "./environment/driver.js"
@@ -26,12 +26,7 @@ export class Info extends Schema.Class<Info>("Workspace.Info")({
export class NotFound extends Schema.TaggedError<NotFound>()("Workspace.NotFound", { workspaceID: ID }) {}
export interface Interface {
/** Instantly commits a logical workspace ID. No provider work happens here. */
readonly create: (provider: string) => Effect.Effect<ID, WorkspaceDriver.ProviderNotFound>
/** Starts or joins the shared attempt that makes the backing resource real, then returns it. */
readonly provision: (
workspaceID: ID,
) => Effect.Effect<Info, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
readonly create: (provider: string) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
readonly connect: (
workspaceID: ID,
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
@@ -56,8 +51,6 @@ interface Connection {
readonly scope: Scope.Closeable
}
type ReadinessError = NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound
export const configured = (options: Options = {}) =>
makeGlobalNode({
service: Service,
@@ -73,10 +66,7 @@ const layer = (options: Options) =>
const registry = yield* WorkspaceDriver.RegistryService
const lifetime = yield* Scope.Scope
const connections = new Map<ID, Connection>()
// Destroy cancels the racing provision body by settling the deferred.
const attempts = new Map<ID, Deferred.Deferred<Info, ReadinessError>>()
const locks = KeyedMutex.makeUnsafe<ID>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const idleThreshold = Duration.toMillis(options.idleThreshold ?? Duration.minutes(20))
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
@@ -90,75 +80,29 @@ const layer = (options: Options) =>
return row
})
const saveBinding = (workspaceID: ID, binding: WorkspaceDriver.Binding) =>
db.update(WorkspaceTable).set({ binding }).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
const info = (row: typeof WorkspaceTable.$inferSelect, binding: WorkspaceDriver.Binding) =>
new Info({
id: row.id,
provider: row.provider,
binding,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
})
const provision = Effect.fn("Workspace.provision")((workspaceID: ID) =>
Effect.suspend(() => {
const existing = attempts.get(workspaceID)
if (existing) return Deferred.await(existing)
const attempt = Deferred.makeUnsafe<Info, ReadinessError>()
attempts.set(workspaceID, attempt)
fork(
locks
.withLock(workspaceID)(
Effect.gen(function* () {
const row = yield* load(workspaceID)
if (row.binding) return info(row, row.binding)
const driver = yield* registry.get(row.provider)
const result = yield* driver.create({ workspaceID })
yield* saveBinding(workspaceID, result.binding)
return info(row, result.binding)
}),
)
.pipe(
Effect.raceFirst(Deferred.await(attempt)),
Effect.onExit((exit) =>
Effect.sync(() => {
if (attempts.get(workspaceID) === attempt) attempts.delete(workspaceID)
Deferred.doneUnsafe(attempt, exit)
}),
),
Effect.exit,
Effect.asVoid,
),
)
return Deferred.await(attempt)
}),
)
const open = Effect.fn("Workspace.open")(function* (workspaceID: ID) {
const existing = connections.get(workspaceID)
if (existing) return existing
const row = yield* load(workspaceID)
// Bindings are persisted before provision resolves and never nulled; a raced
// destroy deletes the whole row and surfaces as NotFound from load above.
if (!row.binding) return yield* Effect.die(`workspace ${workspaceID} has no binding after provision`)
const driver = yield* registry.get(row.provider)
const persistBinding = (binding: WorkspaceDriver.Binding) => saveBinding(workspaceID, binding)
const saveBinding = (value: WorkspaceDriver.Binding) =>
db
.update(WorkspaceTable)
.set({ binding: value })
.where(eq(WorkspaceTable.id, workspaceID))
.run()
.pipe(Effect.orDie)
const scope = yield* Scope.fork(lifetime)
const environment = yield* driver
.connect({ workspaceID, binding: row.binding, saveBinding: persistBinding })
.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
)
const environment = yield* driver.connect({ workspaceID, binding: row.binding, saveBinding }).pipe(
Effect.provideService(Scope.Scope, scope),
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
)
const now = yield* Clock.currentTimeMillis
const connection: Connection = {
driver,
environment,
saveBinding: persistBinding,
saveBinding,
lastActivity: yield* Ref.make(now),
active: yield* Ref.make(0),
scope,
@@ -185,7 +129,6 @@ const layer = (options: Options) =>
const lastActivity = yield* Ref.get(connection.lastActivity)
if (now - lastActivity < idleThreshold) return
const row = yield* load(workspaceID)
if (!row.binding) return
// Deliberate: a racing spawn blocks, then wakes cleanly. Unlocking mid-suspend could reattach a sandbox being terminated.
yield* connection.driver.suspendForIdle({
workspaceID,
@@ -208,41 +151,37 @@ const layer = (options: Options) =>
return Service.of({
create: Effect.fn("Workspace.create")(function* (provider) {
yield* registry.get(provider)
const driver = yield* registry.get(provider)
const workspaceID = ID.create()
const result = yield* driver.create({ workspaceID })
const now = yield* Clock.currentTimeMillis
yield* db
.insert(WorkspaceTable)
.values({ id: workspaceID, provider, binding: null, created_at: now, last_used_at: now })
.values({ id: workspaceID, provider, binding: result.binding, created_at: now, last_used_at: now })
.run()
.pipe(Effect.orDie)
return workspaceID
return new Info({ id: workspaceID, provider, binding: result.binding, createdAt: now, lastUsedAt: now })
}),
provision,
connect: Effect.fn("Workspace.connect")(function* (workspaceID) {
const spawner = make((command) =>
Effect.acquireRelease(
// A live connection implies the binding is already persisted, so skip the provision hop.
Effect.suspend(() => (connections.has(workspaceID) ? Effect.void : provision(workspaceID))).pipe(
Effect.andThen(
locks.withLock(workspaceID)(
Effect.gen(function* () {
const connection = yield* open(workspaceID)
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
yield* Ref.update(connection.active, (active) => active + 1)
return connection
}),
),
),
Effect.mapError((cause) =>
systemError({
_tag: "Unknown",
module: "Workspace",
method: "spawn",
description: `Failed to wake workspace ${workspaceID}`,
cause,
}),
),
locks.withLock(workspaceID)(
Effect.gen(function* () {
const connection = yield* open(workspaceID).pipe(
Effect.mapError((cause) =>
systemError({
_tag: "Unknown",
module: "Workspace",
method: "spawn",
description: `Failed to wake workspace ${workspaceID}`,
cause,
}),
),
)
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
yield* Ref.update(connection.active, (active) => active + 1)
return connection
}),
),
(connection) =>
locks.withLock(workspaceID)(
@@ -257,32 +196,14 @@ const layer = (options: Options) =>
return { spawner }
}),
destroy: Effect.fn("Workspace.destroy")(function* (workspaceID) {
// Settling the shared attempt cancels its racing provision body and fails
// waiters with NotFound before teardown commits. Accepted tradeoffs: if the
// locked teardown below fails, those waiters saw NotFound for a workspace
// that still exists (the next provision retries it), and a provision racing
// this window may briefly succeed before teardown destroys its fresh binding.
const attempt = attempts.get(workspaceID)
if (attempt) {
attempts.delete(workspaceID)
Deferred.doneUnsafe(attempt, Exit.fail(new NotFound({ workspaceID })))
}
yield* locks.withLock(workspaceID)(
Effect.gen(function* () {
const row = yield* load(workspaceID)
const connection = connections.get(workspaceID)
connections.delete(workspaceID)
if (connection) yield* Scope.close(connection.scope, Exit.void)
// Null binding still reaches the driver: an interrupted or crashed
// provision may have created a resource that was never persisted. A
// provider missing from the registry cannot block deleting a
// never-provisioned row.
yield* registry.get(row.provider).pipe(
Effect.flatMap((driver) => driver.destroy({ workspaceID, binding: row.binding })),
Effect.catchTag("WorkspaceDriver.ProviderNotFound", (error) =>
row.binding ? Effect.fail(error) : Effect.void,
),
)
const driver = yield* registry.get(row.provider)
yield* driver.destroy({ workspaceID, binding: row.binding })
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
}),
)
@@ -295,8 +216,4 @@ export const node = configured()
// TODO(workspace-plan): add the boot janitor and ~23h safety snapshot rotation in a later PR.
// TODO(workspace-plan): make cold wake interruptible with a re-pin loop against janitor races.
// TODO(workspace-plan): consider extracting a keyed shared-attempt helper (join/cancel, drop-on-settle) beside
// KeyedMutex at end-of-series consolidation; filesystem/search.ts and session/run-coordinator.ts hand-roll the same
// shape. Audited stdlib alternatives (rc.111): RcMap fails twice (refcount release cancels in-flight work when the
// last waiter leaves, and one finalizer path cannot express idle-suspend vs destroy); Cache interrupts the shared
// lookup when its last awaiter is interrupted and cannot fail waiters with NotFound on invalidation.
// TODO(workspace-plan): consider RcMap at end-of-series consolidation; idle suspend and destroy need distinct finalizers.
+1 -19
View File
@@ -24,16 +24,6 @@ export class ProviderNotFound extends Schema.TaggedError<ProviderNotFound>()("Wo
}) {}
export interface Interface {
/**
* Get-or-create the provider resource backing this logical workspace.
*
* MUST be idempotent per `workspaceID`: core retries the same ID after
* failures and process crashes, including a crash between a successful
* create and the binding being persisted, and another process may race the
* same ID. Key the resource by `workspaceID` (a provider tag or a
* deterministic name) and adopt an existing match instead of creating a
* duplicate.
*/
readonly create: (input: {
readonly workspaceID: Workspace.ID
}) => Effect.Effect<{ readonly binding: Binding }, Error>
@@ -47,17 +37,9 @@ export interface Interface {
readonly binding: Binding
readonly saveBinding: (binding: Binding) => Effect.Effect<void>
}) => Effect.Effect<void, Error>
/**
* Release the provider resource for this workspace.
*
* `binding` is null when none was persisted: the workspace was never
* provisioned, or provisioning was interrupted mid-create. Look up any
* resource previously created for `workspaceID` and clean it up, treating
* absence as success.
*/
readonly destroy: (input: {
readonly workspaceID: Workspace.ID
readonly binding: Binding | null
readonly binding: Binding
}) => Effect.Effect<void, Error>
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { WorkspaceDriver } from "./driver.js"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
provider: text().notNull(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
created_at: integer().notNull(),
last_used_at: integer().notNull(),
})
+2 -2
View File
@@ -24,12 +24,12 @@ describe("ConfigShellPlugin.Plugin", () => {
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))
const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath
expect(yield* shell.preferred()).toBe(configured)
expect(yield* shell.resolve({ preference: "configured" })).toBe(configured)
yield* config.setEntries([])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if ((yield* shell.preferred()) !== configured) return
if ((yield* shell.resolve({ preference: "configured" })) !== configured) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for shell config reload"))
+23 -60
View File
@@ -12,25 +12,8 @@ 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-"))
@@ -67,44 +50,6 @@ 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())
@@ -118,15 +63,29 @@ describe("FileSystemSearch", () => {
),
),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
[
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([]),
}),
),
],
])
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(
@@ -138,6 +97,7 @@ describe("FileSystemSearch", () => {
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
let scans = 0
const initial = Effect.runSync(Deferred.make<void>())
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
@@ -167,6 +127,7 @@ describe("FileSystemSearch", () => {
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
if (scans === 1) yield* Deferred.succeed(initial, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
@@ -179,7 +140,7 @@ describe("FileSystemSearch", () => {
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* search.find({ query: "old", type: "file" })
yield* Deferred.await(initial)
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(1)
@@ -202,6 +163,7 @@ describe("FileSystemSearch", () => {
test("reuses location-owned fuzzy targets across index refreshes", async () => {
let scans = 0
const first = Effect.runSync(Deferred.make<void>())
const second = Effect.runSync(Deferred.make<void>())
const prepare = spyOn(fuzzysort, "prepare")
const cleanup = spyOn(fuzzysort, "cleanup")
@@ -225,7 +187,7 @@ describe("FileSystemSearch", () => {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
@@ -238,6 +200,7 @@ describe("FileSystemSearch", () => {
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(first)
yield* search.find({ query: "index", type: "file" })
yield* TestClock.adjust("10 seconds")
yield* search.find({ query: "index", type: "file" })
+2 -46
View File
@@ -6,20 +6,16 @@ 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, workspaceID?: Workspace.ID) =>
const provide = (directory: string) =>
Effect.provide(
LayerNode.compile(FileSystem.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
]),
)
@@ -60,46 +56,6 @@ 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* () {
+5 -47
View File
@@ -3,20 +3,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import {
DateTime,
Deferred,
Duration,
Effect,
Equal,
Fiber,
Hash,
Layer,
LayerMap,
RcMap,
Schema,
Stream,
} from "effect"
import { DateTime, Deferred, Duration, Effect, Equal, Fiber, Hash, Layer, LayerMap, RcMap, Schema, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
@@ -36,7 +23,6 @@ import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Workspace } from "@opencode-ai/core/workspace"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
@@ -75,9 +61,10 @@ const activityLocations = Layer.effect(
),
)
const itWithActivity = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [
[LocationServiceMap.node, activityLocations],
]),
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]),
[[LocationServiceMap.node, activityLocations]],
),
)
describe("LocationServiceMap", () => {
@@ -128,35 +115,6 @@ describe("LocationServiceMap", () => {
),
)
it.live("keeps a workspace location admitted when its directory does not exist locally", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = AbsolutePath.make(path.join(dir.path, "workspace-only"))
const workspaceRef = Location.Ref.make({ directory, workspaceID: Workspace.ID.make("wrk_liveness") })
const localRef = Location.Ref.make({ directory })
// The directory only exists inside the workspace, so 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()),
+3 -11
View File
@@ -455,7 +455,7 @@ describe("Plugin", () => {
const registry = yield* Tool.Service
const executed: unknown[] = []
const seen: {
before?: { input: unknown; inputSchema: unknown }
before?: unknown
after?: { input: unknown; status: string; content: unknown; metadata: unknown }
} = {}
@@ -480,7 +480,7 @@ describe("Plugin", () => {
yield* ctx.tool
.hook("execute.before", (event) =>
Effect.sync(() => {
seen.before = { input: event.input, inputSchema: event.inputSchema }
seen.before = event.input
event.input = { text: "before-mutated" }
}),
)
@@ -526,15 +526,7 @@ describe("Plugin", () => {
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
})
expect(seen.before).toEqual({
input: { text: "original" },
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
additionalProperties: false,
},
})
expect(seen.before).toEqual({ text: "original" })
expect(executed).toEqual([{ text: "before-mutated" }])
expect(seen.after).toEqual({
input: { text: "before-mutated" },
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { DateTime, Effect, Option, Stream, Types } from "effect"
import { DateTime, Effect, Stream, Types } from "effect"
import type { SessionContext } from "@opencode-ai/plugin/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Agent } from "@opencode-ai/core/agent"
@@ -242,10 +242,10 @@ describe("plan plugin reminders", () => {
})
describe("plan plugin mutations", () => {
it.effect("does not create the Plan directory during activation", () =>
it.effect("creates the Plan directory", () =>
Effect.gen(function* () {
const { files } = yield* run()
expect(Option.isNone(yield* files.stat(planDirectory).pipe(Effect.option))).toBe(true)
expect((yield* files.stat(planDirectory)).type).toBe("directory")
}),
)

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