Compare commits

...
43 Commits
Author SHA1 Message Date
opencode-agent[bot]andnexxeln 3b8949b1ee fix(tui): load paginated session history (#44656)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-24 16:02:19 +05:30
Luke Parker 97daae9b77 feat(app): align context search rows with figma (#44654) 2026-08-24 20:14:40 +10:00
opencode-agent[bot] 3dd84d629d fix(session-ui): preserve timeline message order (#44652) 2026-08-24 09:44:47 +00:00
Luke Parker 279062d3c9 feat(app): align execute tool with shell treatment (#44642) 2026-08-24 09:20:56 +00:00
Brendan Allan 0a410e404b fix(session-ui): show tool errors inline (#44632) 2026-08-24 07:59:12 +00:00
opencode-agent[bot]andBrendonovich 5cd40ad66f fix(app): remember submitted workspace choice (#44620)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-24 07:12:46 +00:00
Luke Parker 46d1f1fed1 refactor(app): make auto-accept permissions an app-level setting (#44608) 2026-08-24 16:56:14 +10:00
opencode-agent[bot]andBrendonovich e282066cf8 fix(app): place thinking above queued prompts (#44616)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 06:05:22 +00:00
opencode-agent[bot]andBrendonovich 6704f34cad fix(app): handle Windows menu accelerators (#44607)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 13:57:52 +08:00
Aiden Cline e63aba48bc fix(ai): reconcile reasoning finals that arrive without deltas (#44595) 2026-08-24 00:29:23 -05:00
Brendan Allan 297f5298cc fix(app): deduplicate notification sounds across tabs (#44612) 2026-08-24 05:26:57 +00:00
opencode-agent[bot]andBrendonovich 7102c487c9 fix(app): sync project list between windows (#44600)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 12:59:50 +08:00
opencode-agent[bot]andBrendonovich d8f59d312b feat(desktop): hide local server from projects (#44598)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 12:58:53 +08:00
opencode-agent[bot]andBrendonovich b23e4c3118 fix(app): cap workspace settings list height (#44603)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 12:57:30 +08:00
opencode-agent[bot]andBrendonovich 1c03f08512 fix(desktop): copy IDs through native clipboard (#44599)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 12:54:28 +08:00
Michael HartandBrendan Allan 3eb07e38ae fix(app): use a deletable cursor for draft blob cleanup (#44594)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-24 12:53:39 +08:00
Aiden Cline 299e314a28 fix(ai): passthrough anthropic top-level body fields (#44604) 2026-08-23 23:47:17 -05:00
Aiden Cline 76027fe3da fix(ai): expand anthropic media lowering to match SDK (#44593) 2026-08-23 23:12:28 -05:00
Aiden Cline 8b65bd53bf fix(ai): drop invalid item ids when replaying responses history (#44587) 2026-08-23 23:08:16 -05:00
Aiden Cline 1dee7e05b5 fix(ai): support disable_parallel_tool_use and send beta query (#44583) 2026-08-23 22:47:56 -05:00
opencode-agent[bot]andBrendonovich 89026373bb fix(desktop): ignore late draft flushes (#44584)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 11:47:08 +08:00
opencode-agent[bot] 1ea4584f67 chore: update nix node_modules hashes 2026-08-24 03:41:11 +00:00
Aiden Cline 89a51a088a fix(ai): ignore orphan response deltas (#44575) 2026-08-23 22:37:44 -05:00
usrnk1andLukeParkerDev 3f807d8836 feat(desktop): add home session actions (#43245)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-08-24 13:18:03 +10:00
Aiden Cline 4876f416cc fix(ai): fail loudly on missing anthropic tool_use id (#44569) 2026-08-23 21:56:39 -05:00
Luke Parker dbc178c3cc fix: add DOM.AsyncIterable lib so ReadableStream iteration typechecks (#44580) 2026-08-24 02:26:54 +00:00
opencode-agent[bot] 73974dc668 chore: update nix node_modules hashes 2026-08-24 02:18:31 +00:00
Kit Langton 1593479454 chore(core): upgrade drizzle orm to rc5 (#44573) 2026-08-23 21:56:02 -04:00
Kit Langton 971681c567 fix(image): degrade photon resizer load failure to typed error on workerd (#43894) 2026-08-23 21:44:01 -04:00
Luke Parker 378a7b7be1 fix: stream Bun runtime downloads to disk to avoid Bun.write GC hang (#44572) 2026-08-24 11:22:42 +10:00
Aiden Cline 6d57667168 fix(ai): clamp prompt_cache_key to 64 chars for all chat protocols (#44571) 2026-08-23 20:21:15 -05:00
Aiden Cline d4935360c5 fix(ai): keep stateless hosted tool results and tolerate WS keepalives (#44488) 2026-08-23 20:19:14 -05:00
Aiden Cline 89281dcd01 fix(ai): preserve display on enabled thinking (#44544) 2026-08-23 20:05:30 -05:00
Aiden Cline 9f9184b002 fix(ai): preserve OpenAI tool schemas (#44546) 2026-08-23 20:05:06 -05:00
Kit Langton c6f4fa0d1f fix(core): never build fff index for workspace locations (#44563) 2026-08-24 01:00:27 +00:00
Kit Langton c505c91438 fix(codemode): package conditional transpilers (#44565) 2026-08-23 20:50:39 -04:00
Kit Langton 8887c2ffa9 fix(core): skip host realpath canonicalization for workspace locations (#44564) 2026-08-23 20:50:29 -04:00
opencode-agent[bot] ddaf9ff433 chore: update nix node_modules hashes 2026-08-24 00:35:01 +00:00
usrnk1andLukeParkerDev d3e5d6d268 feat(desktop): add model change notices (#43288)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-08-24 00:17:35 +00:00
Kit Langton f9bc948912 fix(core): skip local liveness check for workspace locations (#44560) 2026-08-23 20:17:04 -04:00
opencode-agent[bot] 6f238feeff chore: update nix node_modules hashes 2026-08-24 00:07:29 +00:00
Kit Langton e867a21ea5 feat(core): decouple workspace identity from provisioning (#44526) 2026-08-23 23:49:51 +00:00
Luke Parker 64c0411edb fix(session-ui): align tool error card with figma (#44543) 2026-08-23 23:31:45 +00:00
138 changed files with 3862 additions and 1520 deletions
+5
View File
@@ -84,6 +84,11 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify published codemode package
if: runner.os == 'Linux'
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
+7 -42
View File
@@ -71,6 +71,7 @@
"@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",
@@ -671,6 +672,7 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
@@ -1054,7 +1056,6 @@
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
@@ -1116,8 +1117,8 @@
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
@@ -1683,7 +1684,7 @@
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.111" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
@@ -3217,12 +3218,6 @@
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/cli-config": ["@vercel/cli-config@0.2.3", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A=="],
"@vercel/cli-exec": ["@vercel/cli-exec@1.0.1", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ=="],
"@vercel/functions": ["@vercel/functions@3.9.3", "", { "dependencies": { "@vercel/oidc": "3.8.4" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -3835,9 +3830,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-rc.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TRxUmj1wDA2QCt3GvuhfamvIa66wJ7+MzSxBMKkpRtYScjHTumT9BE+x6daSzuEacSrPEuUH5/cW1uo5RkoPIg=="],
"drizzle-kit": ["drizzle-kit@1.0.0-rc.5-ab785fc", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TSg7sK1finOxdo48O9mDfyMY3lhi/aHCVIil5TFhthyq/H/89H/3nwX5V9Gdp7HYMUGFHyWJD5iowSn98l0elw=="],
"drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
"drizzle-orm": ["drizzle-orm@1.0.0-rc.5-169397b", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@drizzle-team/minipg": ">=0.3.2", "@effect/sql-d1": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-libsql": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-mysql2": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-pg": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-pglite": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-bun": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-do": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-node": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-wasm": ">=4.0.0-beta.105 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=17", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.7.1", "@tursodatabase/database-common": ">=0.7.1", "@tursodatabase/database-wasm": ">=0.7.1", "@tursodatabase/serverless": ">=1.4.0", "@tursodatabase/sync": ">=0.7.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.105 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.2.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@drizzle-team/minipg", "@effect/sql-d1", "@effect/sql-libsql", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/sql-pglite", "@effect/sql-sqlite-bun", "@effect/sql-sqlite-do", "@effect/sql-sqlite-node", "@effect/sql-sqlite-wasm", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@tursodatabase/serverless", "@tursodatabase/sync", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-xf5TudQhFplMMzjwgmMY5f64Sqbv4iwXxdTHBHqvLMt+SHyY8rAYhzNLQchEkDxiqMclTS/txPEOg5pdQy0Tcw=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -3981,8 +3976,6 @@
"eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
@@ -4257,8 +4250,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -4623,8 +4614,6 @@
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"mermaid": ["mermaid@11.16.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g=="],
@@ -4709,8 +4698,6 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
@@ -4827,8 +4814,6 @@
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -4853,8 +4838,6 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
@@ -4869,8 +4852,6 @@
"opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
"os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="],
"own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="],
"oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="],
@@ -5453,8 +5434,6 @@
"strip-comments": ["strip-comments@2.0.1", "", {}, "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"stripe": ["stripe@18.0.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA=="],
@@ -5847,10 +5826,6 @@
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
@@ -6423,10 +6398,6 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.4", "", { "dependencies": { "@vercel/cli-config": "0.2.3", "@vercel/cli-exec": "1.0.1", "jose": "^5.9.6" } }, "sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA=="],
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitejs/plugin-react/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
@@ -6565,10 +6536,6 @@
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
@@ -7325,8 +7292,6 @@
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-pnyqHqM8nt1Dh/d6xRm8DploZG9PiaO3HWGVIFyew54=",
"aarch64-linux": "sha256-cWPfoDku1bFN7zwCy6rsLP/WMmp6hEpO3Ous3FPGcfo=",
"aarch64-darwin": "sha256-2S3opWupuF3AncBbZLd5B7IsrUxYxA4S1mKWy58gMlo=",
"x86_64-darwin": "sha256-fu6WCBh9CWqsk8ACvLdeleP55uZ08i8MfEPFqgt7ob0="
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
}
}
+2 -3
View File
@@ -71,8 +71,8 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
@@ -166,7 +166,6 @@
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
+357 -35
View File
@@ -1,3 +1,4 @@
import { Buffer } from "node:buffer"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
@@ -54,7 +55,7 @@ export type ThinkingInput =
| {
readonly type: "disabled"
}
| ({ readonly type: "enabled" } & (
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
@@ -63,6 +64,19 @@ export interface OptionsInput {
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
readonly service_tier?: "auto" | "standard_only"
readonly serviceTier?: "auto" | "standard_only"
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?: string | { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
readonly outputConfig?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
}
export type ProviderOptionsInput = OptionsInput
@@ -82,25 +96,61 @@ const AnthropicTextBlock = Schema.Struct({
})
type AnthropicTextBlock = Schema.Schema.Type<typeof AnthropicTextBlock>
// SDK: Base64ImageSource:201 {type:"base64", media_type:"image/jpeg"|... , data}, URLImageSource:3817 {type:"url", url}, FileImageSource:2350 {type:"file", file_id}
// SDK: ImageBlockParam:2356 {source: Base64|URL|File, cache_control, transformations:2381 {oversized_image?}}
const AnthropicBase64ImageSource = Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.String,
data: Schema.String,
})
const AnthropicURLImageSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
const AnthropicFileImageSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
const AnthropicImageSource = Schema.Union([
AnthropicBase64ImageSource,
AnthropicURLImageSource,
AnthropicFileImageSource,
])
const AnthropicImageTransformations = Schema.Struct({
oversized_image: Schema.optional(Schema.Literals(["downsize", "error"])),
})
const AnthropicImageBlock = Schema.Struct({
type: Schema.tag("image"),
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.String,
data: Schema.String,
}),
source: AnthropicImageSource,
cache_control: Schema.optional(AnthropicCacheControl),
transformations: Schema.optional(AnthropicImageTransformations),
})
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
// SDK: Base64PDFSource:209 {type:"base64", media_type:"application/pdf", data}, PlainTextSource:2716 {type:"text", media_type:"text/plain", data},
// SDK: URLPDFSource:3823 {type:"url", url}, FileDocumentSource:2344 {type:"file", file_id}, ContentBlockSource:2266 {type:"content", content}
// SDK: DocumentBlockParam:2297 {source: 5-way union, cache_control, citations, context, title}
const AnthropicBase64PDFSource = Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
})
const AnthropicPlainTextSource = Schema.Struct({
type: Schema.tag("text"),
media_type: Schema.Literal("text/plain"),
data: Schema.String,
})
const AnthropicURLPDFSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
const AnthropicFileDocumentSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
const AnthropicDocumentSource = Schema.Union([
AnthropicBase64PDFSource,
AnthropicPlainTextSource,
AnthropicURLPDFSource,
AnthropicFileDocumentSource,
])
const AnthropicDocumentBlock = Schema.Struct({
type: Schema.tag("document"),
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
}),
source: AnthropicDocumentSource,
cache_control: Schema.optional(AnthropicCacheControl),
title: Schema.optional(Schema.String),
context: Schema.optional(Schema.String),
citations: Schema.optional(Schema.Struct({ enabled: Schema.Boolean })),
})
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
@@ -205,14 +255,18 @@ const AnthropicTool = Schema.Struct({
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
const AnthropicToolChoice = Schema.Union([
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
Schema.Struct({
type: Schema.Literals(["auto", "any", "none"]),
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
}),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String, disable_parallel_tool_use: Schema.optional(Schema.Boolean) }),
])
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"),
@@ -223,10 +277,28 @@ const AnthropicThinking = Schema.Union([
}),
])
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
const AnthropicJsonOutputFormat = Schema.Struct({
type: Schema.Literal("json_schema"),
schema: JsonObject,
})
const AnthropicOutputConfig = Schema.Struct({
effort: Schema.optional(Schema.String),
format: Schema.optional(Schema.NullOr(AnthropicJsonOutputFormat)),
})
// SDK Metadata:2649 {user_id?: string|null}
const AnthropicMetadata = Schema.Struct({ user_id: optionalNull(Schema.String) })
// SDK MessageCreateParamsContainer:2596 ContainerParams|string; ContainerParams:2172 {id?, skills?}
const AnthropicContainer = Schema.Union([
Schema.String,
Schema.Struct({
id: optionalNull(Schema.String),
skills: optionalNull(Schema.Array(JsonObject)),
}),
])
const AnthropicBodyFields = {
model: Schema.String,
system: optionalArray(AnthropicTextBlock),
@@ -241,6 +313,12 @@ const AnthropicBodyFields = {
stop_sequences: optionalArray(Schema.String),
thinking: Schema.optional(AnthropicThinking),
output_config: Schema.optional(AnthropicOutputConfig),
// SDK top-level passthrough: cache_control:4638, container:4643, inference_geo:4649, metadata:4654, service_tier:4670
cache_control: Schema.optional(AnthropicCacheControl),
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(AnthropicMetadata),
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -369,10 +447,26 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
auto: () => ({ type: "auto" as const }),
auto: () => ({
type: "auto" as const,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
none: () => ({ type: "none" as const }),
required: () => ({ type: "any" as const }),
tool: (name) => ({ type: "tool" as const, name }),
required: () => ({
type: "any" as const,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
tool: (name) => ({
type: "tool" as const,
name,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
})
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
@@ -411,7 +505,152 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
})
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = metadata.anthropic
if (ProviderShared.isRecord(anthropic)) {
if (typeof anthropic.file_id === "string") return anthropic.file_id
if (typeof anthropic.fileId === "string") return anthropic.fileId
}
if (typeof metadata.file_id === "string") return metadata.file_id
if (typeof metadata.fileId === "string") return metadata.fileId
return undefined
}
const transformationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicImageBlock["transformations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
const raw = anthropic?.transformations ?? metadata.transformations
if (ProviderShared.isRecord(raw)) {
const value = raw.oversized_image
if (value === "downsize" || value === "error") return { oversized_image: value }
}
if (anthropic && (anthropic.oversized_image === "downsize" || anthropic.oversized_image === "error"))
return { oversized_image: anthropic.oversized_image }
return undefined
}
const documentTitleFromPart = (part: MediaPart): string | undefined => {
if (ProviderShared.isRecord(part.metadata)) {
const anthropic = part.metadata.anthropic
if (ProviderShared.isRecord(anthropic) && typeof anthropic.title === "string") return anthropic.title
if (typeof part.metadata.title === "string") return part.metadata.title
}
if (typeof part.filename === "string" && part.filename.length > 0) return part.filename
return undefined
}
const documentContextFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
if (anthropic && typeof anthropic.context === "string") return anthropic.context
if (typeof metadata.context === "string") return metadata.context
return undefined
}
const citationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicDocumentBlock["citations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const raw = ProviderShared.isRecord(metadata.anthropic)
? (metadata.anthropic.citations ?? metadata.citations)
: metadata.citations
if (ProviderShared.isRecord(raw) && typeof raw.enabled === "boolean") return { enabled: raw.enabled }
return undefined
}
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value.trim())
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
part: MediaPart,
breakpoints?: Cache.Breakpoints,
) {
const mime = part.mediaType.toLowerCase()
const cacheControlValue = breakpoints ? cacheControl(breakpoints, part.cache) : undefined
const fileId = fileIdFromMetadata(part.metadata)
// SDK file sources: FileImageSource:2350 / FileDocumentSource:2344 {type:"file", file_id}
if (fileId) {
if (mime.startsWith("image/"))
return {
type: "image" as const,
source: { type: "file" as const, file_id: fileId },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
return {
type: "document" as const,
source: { type: "file" as const, file_id: fileId },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
const rawString = typeof part.data === "string" ? part.data.trim() : undefined
// SDK URL sources: URLImageSource:3817 / URLPDFSource:3823 {type:"url", url}
if (rawString && isHttpUrl(rawString) && !rawString.startsWith("data:")) {
if (mime.startsWith("image/"))
return {
type: "image" as const,
source: { type: "url" as const, url: rawString },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
if (mime === "application/pdf")
return {
type: "document" as const,
source: { type: "url" as const, url: rawString },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
// SDK PlainTextSource:2716 {type:"text", media_type:"text/plain", data}
if (mime === "text/plain") {
const textData =
typeof part.data !== "string"
? Buffer.from(part.data).toString("utf8")
: part.data.startsWith("data:")
? (() => {
const comma = part.data.indexOf(",")
const payload = comma >= 0 ? part.data.slice(comma + 1) : part.data
return part.data.includes(";base64")
? Buffer.from(payload, "base64").toString("utf8")
: decodeURIComponent(payload)
})()
: part.data
return {
type: "document" as const,
source: { type: "text" as const, media_type: "text/plain" as const, data: textData },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
const media = ProviderShared.normalizeMedia(part)
if (media.mime === "application/pdf")
return {
@@ -421,6 +660,14 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
media_type: "application/pdf" as const,
data: media.base64,
},
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
if (!media.mime.startsWith("image/"))
return yield* invalid(`Anthropic Messages does not support media type ${part.mediaType}`)
@@ -431,6 +678,10 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
media_type: media.mime,
data: media.base64,
},
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
})
@@ -539,7 +790,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(yield* lowerMedia(part))
content.push(yield* lowerMedia(part, breakpoints))
continue
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
@@ -606,24 +857,74 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier = (input as Record<string, unknown> | undefined)?.service_tier ?? (input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) &&
(typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as string | { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
(input as Record<string, unknown> | undefined)?.inferenceGeo
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
const rawCacheControl =
(input as Record<string, unknown> | undefined)?.cache_control ??
(input as Record<string, unknown> | undefined)?.cacheControl
const cache_control =
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
: undefined
const rawOutputConfig =
(input as Record<string, unknown> | undefined)?.output_config ??
(input as Record<string, unknown> | undefined)?.outputConfig
const outputConfigEffort =
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
? ((input as Record<string, unknown>).effort as string)
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
? (rawOutputConfig.effort as string)
: undefined
const outputConfigFormat =
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const output_config =
outputConfigEffort === undefined && outputConfigFormat === undefined
? undefined
: {
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
}
return {
thinking: yield* resolveThinking(input?.thinking),
effort: typeof input?.effort === "string" ? input.effort : undefined,
effort: outputConfigEffort,
output_config,
service_tier,
metadata,
container,
inference_geo,
cache_control,
}
})
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "adaptive") {
const display =
input.display === "summarized"
? ("summarized" as const)
: input.display === "omitted"
? ("omitted" as const)
: undefined
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive")
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
}
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined
const budget =
@@ -634,7 +935,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget }
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
@@ -684,7 +985,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
output_config: options.effort === undefined ? undefined : { effort: options.effort },
output_config: options.output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control,
container: options.container,
inference_geo: options.inference_geo,
metadata: options.metadata,
service_tier: options.service_tier,
}
})
@@ -795,7 +1102,8 @@ 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") && event.index !== undefined) {
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -803,7 +1111,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
...state,
lifecycle,
tools: ToolStream.start(state.tools, event.index, {
id: block.id ?? String(event.index),
id: block.id,
name: block.name ?? "",
input:
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
@@ -815,7 +1123,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
[
...events,
LLMEvent.toolInputStart({
id: block.id ?? String(event.index),
id: block.id,
name: block.name ?? "",
providerExecuted: block.type === "server_tool_use" ? true : undefined,
}),
@@ -1015,7 +1323,18 @@ const onError = (event: AnthropicEvent) =>
const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(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_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))
@@ -1054,7 +1373,10 @@ export const route = Route.make({
provider: "anthropic",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
endpoint: Endpoint.path(
(input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH),
{ baseURL: DEFAULT_BASE_URL },
),
auth: Auth.none,
framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
@@ -112,6 +112,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
responseID = created
return { type: "frame", frame }
}
// Keepalives carry no response state and may arrive before response.created.
if (event.type === "keepalive") return { type: "frame", frame }
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
+93 -44
View File
@@ -313,6 +313,9 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
// Which lowered input item a persisted item id is about to be attached to.
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
export interface Extension {
readonly id: string
readonly name: string
@@ -321,6 +324,10 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
// Optional grammar check applied before a persisted item id is resent as
// part of replayed history. Returning false drops the id; every lowered
// item treats a dropped id the same as an absent one.
readonly acceptsItemID?: (kind: ItemKind, id: string) => boolean
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -346,6 +353,10 @@ interface ReasoningStreamItem {
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
// Summary indexes that received at least one streamed delta. The `:0` block
// is started eagerly when the item opens, so block existence cannot tell
// whether a `.done` final would duplicate streamed text.
readonly deltaIndexes: ReadonlySet<number>
}
// =============================================================================
@@ -362,7 +373,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.responses(inputSchema),
parameters: inputSchema,
// The common tool definition does not currently express Responses strict-schema policy.
strict: false,
}
@@ -376,28 +387,46 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
// Servers validate item ids on replayed history, and a malformed or oversized
// id can fail an otherwise valid request. Only server-issued tokens are worth
// resending; anything else is treated as absent so the item is resent without
// an id (or skipped, for items that cannot be expressed without one).
const ITEM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
return ProviderShared.isRecord(metadata) &&
typeof metadata.itemId === "string" &&
ITEM_ID_PATTERN.test(metadata.itemId)
? metadata.itemId
: undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const acceptsItemID = (extension: Extension, kind: ItemKind, id: string | undefined): id is string =>
id !== undefined && (extension.acceptsItemID?.(kind, id) ?? true)
const lowerToolCall = (
part: ToolCallPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(id ? { id } : {}),
...(acceptsItemID(extension, "function-call", id) ? { id } : {}),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
if (!ProviderShared.isRecord(metadata) || !id) return undefined
if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
@@ -529,7 +558,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
const rawID = itemID(part.providerMetadata, providerMetadataKey)
const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
@@ -554,7 +584,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
@@ -575,15 +605,22 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey))
input.push(lowerToolCall(part, providerMetadataKey, extension))
continue
}
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 && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
if (store !== false && reference && !hostedToolReferences.has(reference))
input.push({ type: "item_reference", id: reference })
if (store === false) {
// The server is not storing this exchange, so the tool outcome has to
// travel in the input. Non-content results degrade to their text form.
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
@@ -591,7 +628,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
),
})
}
if (id) hostedToolReferences.add(id)
if (reference) hostedToolReferences.add(reference)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -628,6 +665,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
@@ -637,7 +675,7 @@ const lowerOptions = (request: LLMRequest) => {
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
@@ -753,7 +791,7 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
@@ -771,20 +809,33 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
const events: LLMEvent[] = []
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...state.reasoningItems,
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
},
},
events,
]
}
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
// Some compatible gateways emit a reasoning final without streaming any
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
@@ -822,7 +873,11 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
[item.id]: {
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
},
},
},
events,
@@ -852,27 +907,9 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
if (event.summary_index === 0) {
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
},
},
events,
]
}
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
@@ -951,7 +988,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
state: ParserState,
event: Event,
) {
if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult
if (!event.item_id || !event.delta || !state.tools[event.item_id]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
state.id,
state.tools,
@@ -1146,6 +1183,15 @@ export const step = (state: ParserState, event: Event) => {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_summary.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
@@ -1159,7 +1205,10 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
}
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.function_call_arguments.delta")
return event.item_id
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
+3 -2
View File
@@ -269,7 +269,7 @@ const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: Lower
function: {
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
parameters: inputSchema,
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -530,9 +530,10 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
}
+19 -4
View File
@@ -51,9 +51,28 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
// Replayed items are paired with stored server state by id, so a foreign or
// synthetic token can fail request validation even when `call_id` pairing is
// intact. Only resend ids in each item kind's own grammar; hosted tool
// references keep generic validation because every hosted tool mints its own
// prefix. The same allowlist approach codex uses before resending history
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
message: ["msg_"],
reasoning: ["rs_"],
"function-call": ["fc_"],
// Every hosted tool mints its own id prefix, so references keep generic
// validation only.
reference: [],
}
const extension = {
id: ADAPTER,
name: NAME,
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
const prefixes = ITEM_ID_PREFIXES[kind]
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
},
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -147,10 +166,6 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+11
View File
@@ -24,6 +24,17 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
if (key === undefined) return undefined
const chars = Array.from(key)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
/**
* Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here
+1 -32
View File
@@ -2,20 +2,6 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch
import { isRecord } from "../../utils/record.js"
import { GeminiToolSchema } from "./gemini-tool-schema.js"
const removeNullSchemas = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(removeNullSchemas)
if (!isRecord(value)) return value
const fields = Object.fromEntries(
Object.entries(value)
.filter(([key]) => key !== "anyOf")
.map(([key, field]) => [key, removeNullSchemas(field)]),
)
if (!Array.isArray(value.anyOf)) return fields
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
return { ...fields, anyOf: variants }
}
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
const projected = items.map(moonshotNode)
if (projected.length === 0) return {}
@@ -45,24 +31,7 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
return isRecord(projected) ? projected : {}
}
const openAI = (schema: JsonSchema): JsonSchema => {
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
const flattened =
variants.length === 0
? { ...schema, type: "object" }
: {
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
type: "object",
properties: variants.reduce(
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
{},
),
additionalProperties: false,
}
const normalized = removeNullSchemas(flattened)
return isRecord(normalized) ? normalized : { type: "object" }
}
const openAI = (schema: JsonSchema): JsonSchema => schema
const responses = openAI
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
+2 -10
View File
@@ -1,7 +1,5 @@
import { Effect } from "effect"
import { Protocol } from "../route/protocol.js"
import { OpenResponses } from "./open-responses.js"
import { ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
@@ -27,15 +25,9 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+3 -2
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 } from "../protocols/shared.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
@@ -115,11 +115,12 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails,
}
})
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
} as OpenRouterBody
}),
),
+2
View File
@@ -51,6 +51,7 @@ export const MediaPart = Schema.Struct({
mediaType: Schema.String,
data: Schema.Union([Schema.String, Schema.Uint8Array]),
filename: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
@@ -255,6 +256,7 @@ export namespace ToolDefinition {
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
type: Schema.Literals(["auto", "none", "required", "tool"]),
name: Schema.optional(Schema.String),
disableParallelToolUse: Schema.optional(Schema.Boolean),
}) {}
export namespace ToolChoice {
@@ -237,6 +237,7 @@ describe("Google Vertex providers", () => {
})
return input.respond(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello." },
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -132,6 +132,78 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
// The baseline does not enforce a provider id grammar, so a
// non-OpenAI but well-formed token is resent as-is.
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
// Shape violations are dropped even without a grammar policy.
{
type: "text",
text: "Dropped.",
providerMetadata: { openresponses: { itemId: `m${"a".repeat(64)}` } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "message",
id: "history_1",
role: "assistant",
content: [{ type: "output_text", text: "Kept." }],
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Dropped." }],
},
])
}),
)
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
},
// Raw reasoning finals carry no summary index; they reconcile
// into the item's first block.
{ type: "response.reasoning.done", item_id: "rs_raw", text: "Raw chain of thought." },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Raw chain of thought.")
}),
)
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
Effect.gen(function* () {
const model = configure({
@@ -196,7 +196,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("flattens top-level object unions in function schemas", () =>
it.effect("preserves function schemas", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
@@ -236,13 +236,22 @@ describe("OpenAI Responses route", () => {
strict: false,
parameters: {
type: "object",
properties: {
path: { type: "string" },
reference: { type: "string" },
limit: { type: "integer", maximum: 2000 },
resource: { type: "string" },
},
additionalProperties: false,
anyOf: [
{
type: "object",
properties: {
path: { type: "string" },
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
limit: { type: "integer", maximum: 2000 },
},
required: ["path"],
},
{
type: "object",
properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
required: ["resource"],
},
],
},
},
])
@@ -312,6 +321,10 @@ describe("OpenAI Responses route", () => {
}),
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({
type: "response.output_item.added",
item: { type: "message", id: "msg_1" },
}),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
@@ -392,6 +405,38 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("tolerates keepalive frames before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
response: { id: "resp_alive", usage: { input_tokens: 1, output_tokens: 1 } },
}),
]),
close: Effect.void,
}),
})
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
)
expect(response.finishReason?.normalized).toBe("stop")
}),
)
it.effect("continues a tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
@@ -1624,6 +1669,7 @@ describe("OpenAI Responses route", () => {
it.effect("parses text and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "!" },
{
@@ -1885,6 +1931,67 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores deltas without a matching output item", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_missing", delta: "orphaned text" },
{ type: "response.refusal.delta", item_id: "refusal_missing", delta: "orphaned refusal" },
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_missing",
summary_index: 0,
delta: "orphaned reasoning",
},
{
type: "response.reasoning_summary_part.added",
item_id: "rs_still_missing",
summary_index: 0,
},
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_still_missing",
summary_index: 0,
delta: "still orphaned reasoning",
},
{
type: "response.function_call_arguments.delta",
item_id: "fc_missing",
delta: '{"orphaned":true}',
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("")
expect(response.message.content).toEqual([])
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}),
)
it.effect("rejects function argument deltas without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.function_call_arguments.delta", delta: "{}" },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.function_call_arguments.delta is missing item_id")
}),
)
it.effect("rejects reasoning events without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
@@ -1940,8 +2047,10 @@ describe("OpenAI Responses route", () => {
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_text.done", item_id: "msg_1" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
{ type: "response.completed", response: { id: "resp_1" } },
@@ -1952,10 +2061,10 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1", providerMetadata: undefined },
{ type: "text-delta", id: "msg_1", text: "First", providerMetadata: undefined },
{ type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-delta", id: "msg_2", text: "Second", providerMetadata: undefined },
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
])
}),
@@ -1964,7 +2073,9 @@ describe("OpenAI Responses route", () => {
it.effect("parses reasoning summary stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1" },
{ type: "response.completed", response: { id: "resp_1" } },
@@ -1976,18 +2087,22 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "reasoning-start", id: "rs_1:0" },
{ type: "reasoning-delta", id: "rs_1:0", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-end", id: "rs_1:0" },
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" },
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
@@ -1999,6 +2114,7 @@ describe("OpenAI Responses route", () => {
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{
type: "response.output_item.done",
@@ -2018,7 +2134,7 @@ describe("OpenAI Responses route", () => {
expect(response.events).toContainEqual(
expect.objectContaining({
type: "reasoning-end",
id: "rs_1",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
)
@@ -2080,6 +2196,105 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
// No `.delta` events at all: the gateway sends the complete
// summary text in the `.done` final.
{
type: "response.reasoning_summary_text.done",
item_id: "rs_1",
summary_index: 0,
text: "Checked the diff.",
},
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Checked the diff.")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [response.message],
providerOptions: { store: false, include: ["reasoning.encrypted_content"] },
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
])
}),
)
it.effect("does not duplicate reasoning finals after streamed deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Streamed" },
// Repeats the complete text, as the spec allows.
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Streamed" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Streamed")
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([
{ type: "reasoning-delta", id: "rs_1:0", text: "Streamed", providerMetadata: undefined },
])
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
@@ -2159,6 +2374,7 @@ describe("OpenAI Responses route", () => {
})
return input.respond(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -2270,6 +2486,134 @@ 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("drops replayed item ids outside the server's grammar", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
// Fails the message id prefix.
{
type: "text",
text: "Hello",
providerMetadata: { openai: { itemId: "history_1" } },
},
// Oversized for the Responses item id limit.
{
type: "text",
text: "World",
providerMetadata: { openai: { itemId: `m${"a".repeat(64)}` } },
},
// Fails the reasoning id prefix, so the whole item is unreplayable
// statelessly and is skipped rather than sent malformed.
{
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "thinking_1", reasoningEncryptedContent: "encrypted-state" } },
},
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { openai: { itemId: "toolu_01A" } },
}),
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
content: [
{ type: "output_text", text: "Hello" },
{ type: "output_text", text: "World" },
],
},
{
type: "function_call",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
])
}),
)
it.effect("keeps well-formed hosted references and drops malformed ones under storage", () =>
Effect.gen(function* () {
const hostedResult = (itemId: string) => [
ToolCallPart.make({
id: itemId,
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId } },
}),
{
type: "tool-result" as const,
id: itemId,
name: "web_search",
result: { type: "json" as const, value: { status: "completed" } },
providerExecuted: true as const,
providerMetadata: { openai: { itemId } },
},
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant(hostedResult("ws_1")), Message.assistant(hostedResult("bad ref"))],
providerOptions: { store: true },
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "ws_1" }])
}),
)
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
@@ -2393,15 +2737,15 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' },
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query"' },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: ':"weather"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2430,7 +2774,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-start",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
{
type: "tool-input-delta",
@@ -2448,7 +2792,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
{
type: "tool-call",
@@ -2456,7 +2800,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
{
type: "step-finish",
@@ -2477,7 +2821,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2491,7 +2835,7 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
@@ -2503,7 +2847,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
{
type: "tool-call",
@@ -2511,7 +2855,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
input: {},
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
],
)
@@ -2524,14 +2868,14 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
@@ -2563,7 +2907,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
@@ -2592,7 +2936,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2606,7 +2950,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
})
}),
)
@@ -24,14 +24,23 @@ describe("xAI Responses route", () => {
}),
)
it.effect("parses xAI reasoning text events", () =>
it.effect("parses xAI reasoning summaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
{
type: "response.output_item.added",
item: { type: "reasoning", id: "reasoning_1" },
},
// Grok streams reasoning with the standard summary event name.
{
type: "response.reasoning_summary_text.delta",
item_id: "reasoning_1",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
+10 -31
View File
@@ -50,32 +50,7 @@ describe("tool schema projections", () => {
})
})
test("openai keeps one flat object top-level schema", () => {
expect(
ToolSchemaProjection.openAI({
anyOf: [
{
type: "object",
properties: {
path: { type: "string" },
maybe: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
{ type: "object", properties: { resource: { type: "string" } } },
],
}),
).toEqual({
type: "object",
properties: {
path: { type: "string" },
maybe: { type: "string" },
resource: { type: "string" },
},
additionalProperties: false,
})
})
it.effect("applies model compatibility before protocol projection", () =>
it.effect("applies model compatibility without changing schema semantics", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -107,11 +82,15 @@ describe("tool schema projections", () => {
expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
type: "object",
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
additionalProperties: false,
anyOf: [
{
type: "object",
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
},
],
})
}),
)
@@ -899,8 +899,26 @@ function provider() {
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
{
id: "company-gateway",
name: "Company Gateway",
models: {
"fast-nano": {
id: "fast-nano",
api: { id: "openai/gpt-5.4-nano" },
name: "GPT-5.4 nano",
limit: { context: 128_000 },
},
"long-context": {
id: "long-context",
api: { id: "company/long-context" },
name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts",
limit: { context: 128_000 },
},
},
},
],
connected: ["opencode"],
connected: ["opencode", "company-gateway"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
@@ -0,0 +1,106 @@
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()
})
@@ -13,9 +13,14 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, permissionRequests)
// Server A has no tab and is never visited: a pending request there proves
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
})
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -38,7 +43,17 @@ test("session settings use the remote server context", async ({ page }) => {
}),
)
.toBe(true)
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { reply: "once" },
},
])
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
@@ -143,6 +158,99 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
])
})
test("auto-accept sweeps again after a reconnect", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const pendingA: MockPermission[] = []
const listFailures: Record<string, number> = {}
const sessionGets: string[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport(page, { server: serverA, retry: 20 })
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: pendingA },
listFailures,
sessionGets,
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
const first = await transport.waitForConnection()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
// This request is asked while the client is disconnected, so it is never
// delivered as an event and only a reconnect sweep can find it. The first
// listing after the reconnect fails, so only the bounded sweep retry can
// deliver the reply.
pendingA.push(pendingPermission("permission-offline-a", sessionA.id))
listFailures[serverA] = 1
const syncsBeforeReconnect = sessionGets.length
await transport.disconnect()
await transport.waitForConnection({ after: first.id })
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { reply: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
// cached locations, since another client may have moved them meanwhile.
expect(sessionGets.slice(syncsBeforeReconnect)).toContain(sessionA.id)
})
test("auto-accept approves a request discovered by opening a session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
// The request is only served from the per-session permission list, so it
// reaches the client through the store sync when the session view opens,
// never through a location sweep or an event.
await mockServers(page, permissionRequests, permissionResponses, {
sessionPending: { [sessionA.id]: [pendingPermission("permission-synced-a", sessionA.id)] },
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { reply: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
@@ -151,6 +259,19 @@ type PermissionResponse = {
body: unknown
}
type MockPermission = {
id: string
sessionID: string
action: string
resources: string[]
metadata: Record<string, unknown>
save: unknown[]
}
function pendingPermission(id: string, sessionID: string): MockPermission {
return { id, sessionID, action: "shell", resources: ["git status"], metadata: {}, save: [] }
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
@@ -161,7 +282,23 @@ async function configureServers(page: Page, tabs: { type: "session"; server: str
)
}
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
type MockServerOptions = {
// Pending requests served from /api/permission/request, keyed by origin.
pending?: Record<string, MockPermission[]>
// Pending requests served from /api/session/:id/permission, keyed by session ID.
sessionPending?: Record<string, MockPermission[]>
// Counts of /api/permission/request calls to fail with a 500, keyed by origin.
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
}
async function mockServers(
page: Page,
permissionRequests: string[],
permissionResponses: PermissionResponse[] = [],
options: MockServerOptions = {},
) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@@ -178,8 +315,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
return json(route, true)
// The generated client requires exactly 204 for a successful reply.
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionPermission = url.pathname.match(/^\/api\/session\/([^/]+)\/permission$/)
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/provider")
return json(route, {
@@ -197,7 +338,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/permission/request") {
permissionRequests.push(url.toString())
return json(route, { location: { directory }, data: [] })
const failures = options.listFailures?.[url.origin] ?? 0
if (failures > 0) {
options.listFailures![url.origin] = failures - 1
return json(route, { name: "Internal" }, 500)
}
return json(route, { location: { directory }, data: options.pending?.[url.origin] ?? [] })
}
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
@@ -219,9 +365,13 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === "/api/session/active")
return json(route, { data: Object.fromEntries(sessions.map((session) => [session.id, { type: "running" }])) })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (currentSessionInfo) {
options.sessionGets?.push(currentSessionInfo.id)
return json(route, { data: currentSession(currentSessionInfo) })
}
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
@@ -250,6 +250,43 @@ test.describe("session timeline projection", () => {
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
sessionMessages: [
{
id: "msg_model_fast_nano",
type: "model-switched",
time: { created: 1700000000000 },
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
},
{
id: "msg_model_long_context",
type: "model-switched",
time: { created: 1700000001000 },
model: { providerID: "company-gateway", id: "long-context" },
},
userMessage(),
assistantMessage(),
],
})
const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(shortNotice).toBeVisible()
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice).toBeVisible()
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
+1 -1
View File
@@ -501,7 +501,7 @@ function currentModels(value: unknown) {
return [
{
id: model.id,
modelID: model.id,
modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
+2 -1
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,6 +69,7 @@
"@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",
+13 -13
View File
@@ -105,14 +105,16 @@ export function AppInterface(props: {
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
<GlobalProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
@@ -123,11 +125,9 @@ export function AppInterface(props: {
servers={props.servers}
>
<SettingsProvider>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
</Dynamic>
</GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
</Dynamic>
</SettingsProvider>
</ServersProvider>
)
+5 -6
View File
@@ -1,6 +1,6 @@
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
@@ -8,12 +8,11 @@ import { createEffect, createMemo, startTransition } from "solid-js"
export function createHomeController() {
const layout = useLayout()
const global = useGlobal()
const servers = useServers()
const tabs = useTabs()
const selection = layout.home.selection
const focusedServer = createMemo<ServerConnection.Any | undefined>(
() =>
global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ??
global.servers.list()[0],
() => servers.visible.find((conn) => ServerConnection.key(conn) === selection().server) ?? servers.visible[0],
)
const focusedServerCtx = useServerCtx(focusedServer)
const focusedSync = () => focusedServerCtx()?.sync
@@ -29,7 +28,7 @@ export function createHomeController() {
)
createEffect(() => {
const list = global.servers.list()
const list = servers.visible
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
const conn = list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
@@ -54,7 +53,7 @@ export function createHomeController() {
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
},
server: {
list: global.servers.list,
list: () => servers.visible,
health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn),
focused: focusedServer,
@@ -61,6 +61,8 @@ export function createHomeProjectsController(home: HomeController) {
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
canHide: (conn: ServerConnection.Any) => serverManagement.connection.canHide(ServerConnection.key(conn)),
hide: (conn: ServerConnection.Any) => serverManagement.connection.setHidden(ServerConnection.key(conn), true),
edit: (conn: ServerConnection.Http) => {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
@@ -26,6 +26,8 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
onSetDefaultServer={props.projects.server.setDefault}
canRemoveServer={props.projects.server.canRemove}
onRemoveServer={props.projects.server.remove}
canHideServer={props.projects.server.canHide}
onHideServer={props.projects.server.hide}
onMoveProject={props.projects.project.move}
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
+11 -5
View File
@@ -49,6 +49,8 @@ export type HomeProjectsViewProps = {
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
canRemoveServer: (server: ServerConnection.Any) => boolean
onRemoveServer: (server: ServerConnection.Any) => void
canHideServer: (server: ServerConnection.Any) => boolean
onHideServer: (server: ServerConnection.Any) => void
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
@@ -188,6 +190,8 @@ function HomeServerRow(props: {
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
canHideServer: HomeProjectsViewProps["canHideServer"]
onHideServer: HomeProjectsViewProps["onHideServer"]
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
server: ServerConnection.Any
@@ -212,11 +216,11 @@ function HomeServerRow(props: {
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
>
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
<HomeProjectNavButton
type="button"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
<HomeProjectNavButton
type="button"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
@@ -284,10 +288,12 @@ function HomeServerRow(props: {
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
canHide={props.canHideServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
onHide={() => props.onHideServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
/>
+133 -4
View File
@@ -1,8 +1,12 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { skipToken, useQuery } from "@tanstack/solid-query"
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 { 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"
@@ -11,7 +15,10 @@ 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"
@@ -35,6 +42,8 @@ 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
@@ -59,9 +68,13 @@ export function createHomeSessionsController(home: HomeController) {
})
const indexedSessions = createMemo(() => {
const ctx = home.server.focusedContext()
if (!ctx) return []
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()),
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
),
HOME_SESSION_LIMIT,
Date.now(),
)
@@ -123,6 +136,119 @@ 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,
@@ -193,6 +319,10 @@ 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) =>
@@ -243,7 +373,6 @@ export function HomeSessionStatusController(props: {
}) {
const avatar = useSessionTabAvatarState(
() => props.server,
() => props.record.session.location.directory,
() => props.record.session.id,
() => true,
)
@@ -27,6 +27,9 @@ 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}
+282 -40
View File
@@ -1,10 +1,14 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo, For, Show, Suspense } from "solid-js"
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 { 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"
@@ -22,6 +26,7 @@ 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.
@@ -54,6 +59,9 @@ 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
@@ -71,7 +79,16 @@ 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}
@@ -122,23 +139,29 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<For each={props.groups}>
{/* Index keeps group subtrees mounted when the group arrays are
rebuilt, so store updates cannot recreate rows mid-gesture. */}
<Index 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"}`}
>
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
<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>
</>
)}
</For>
</Index>
</div>
</Show>
</Suspense>
@@ -412,45 +435,263 @@ function HomeSessionGroupHeader(props: {
)
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
function HomeSessionRow(
props: HomeSessionsViewProps & {
record: HomeSessionRecord
rowUI: HomeSessionRowUI
setRowUI: SetStoreFunction<HomeSessionRowUI>
},
) {
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
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
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"
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)
}}
>
<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 })
<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))
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
<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` }}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
<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>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div
class={`
@@ -481,6 +722,7 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
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,
@@ -8,7 +8,6 @@ import { createComposerControls, createComposerModelSelection } from "@/composer
import { createComposerProjectControls } from "./project/controller"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
import { usePermission } from "@/session/requests/permission"
import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
@@ -30,7 +29,6 @@ export function createNewSessionComposerAdapter(props: {
const data = useData()
const server = useServer()
const serverSDK = useServerSDK()
const permission = usePermission()
const tabs = useTabs()
const location = useWorkspaceLocation()
const language = useLanguage()
@@ -86,9 +84,6 @@ export function createNewSessionComposerAdapter(props: {
)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
permission.enableAutoAccept(created.id, sessionDirectory)
}
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,
@@ -18,13 +18,23 @@ describe("new session workspace selection", () => {
).toBe("main")
})
test("derives an existing worktree from the current directory", () => {
test("uses the saved destination instead of the current worktree", () => {
expect(
resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }),
).toBe("/project/feature")
expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe(
"main",
)
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "create",
}),
).toBe("create")
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "main",
}),
).toBe("/project")
})
test("normalizes main to the project root outside the main worktree", () => {
@@ -33,9 +43,9 @@ describe("new session workspace selection", () => {
})
test("treats equivalent Windows roots as the main worktree", () => {
expect(resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" })).toBe(
"main",
)
expect(
resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" }),
).toBe("main")
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
})
@@ -21,8 +21,7 @@ export function resolveNewSessionWorktree(input: {
}) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
if (input.projectWorktree && !sameDirectory(input.directory, input.projectWorktree)) return input.directory
return input.fallback ?? "main"
return normalizeNewSessionWorktree(input.fallback ?? "main", input.directory, input.projectWorktree)
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
@@ -121,7 +120,6 @@ export function createNewSessionWorkspaceController(input: {
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
remember(worktree)
},
},
project: {
+2
View File
@@ -374,6 +374,8 @@ export const dict = {
"dialog.server.menu.default": "Set as default",
"dialog.server.menu.defaultRemove": "Remove default",
"dialog.server.menu.delete": "Delete",
"dialog.server.menu.hide": "Hide from project list",
"dialog.server.menu.show": "Show in project list",
"dialog.server.current": "Current Server",
"dialog.server.status.default": "Default",
"wsl.server.add": "Add WSL server",
@@ -114,11 +114,12 @@ export function createBrowserDraftStore(): DraftStore {
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
return item
})
const blobs = transaction.objectStore("blobs").openKeyCursor()
const store = transaction.objectStore("blobs")
const blobs = store.openKeyCursor()
blobs.addEventListener("success", () => {
const cursor = blobs.result
if (!cursor) return
if (!used.has(String(cursor.key))) cursor.delete()
if (!used.has(String(cursor.key))) store.delete(cursor.key)
cursor.continue()
})
})
@@ -1,7 +1,7 @@
import { Platform, usePlatform } from "@/runtime/platform/platform"
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, type Accessor } from "solid-js"
import { createResource, onCleanup, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
@@ -16,6 +16,7 @@ type PersistedWithReady<T> = [
type PersistTarget = {
draft?: boolean
sync?: boolean
storage?: string
scope?: "window"
workspaceStorageAliases?: string[]
@@ -637,7 +638,17 @@ export function persisted<T>(
return api
})()
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
const channel =
config.sync && typeof BroadcastChannel !== "undefined"
? new BroadcastChannel(`opencode.persist:${config.storage ?? "default"}:${config.key}`)
: undefined
if (channel) onCleanup(() => channel.close())
const [state, setState, init] = makePersisted(store, {
name: config.key,
storage,
sync: channel ? messageSync(channel) : undefined,
})
const isAsync = init instanceof Promise
const [ready] = createResource(
@@ -104,6 +104,9 @@ type PlatformBase = {
/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>
/** Write text to the native clipboard (desktop only) */
writeClipboardText?(text: string): Promise<void>
/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string>
@@ -264,11 +264,13 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
const [store, setStore, _] = persisted(
{
...Persist.global("server"),
sync: true,
previousKey: "server.v3",
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
},
createStore({
list: [] as StoredServer[],
hidden: {} as Record<string, boolean>,
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
recentlyClosed: {} as Record<string, string[]>,
@@ -280,6 +282,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers })
})
const visibleServers = createMemo(() => allServers().filter((conn) => !store.hidden[ServerConnection.key(conn)]))
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
@@ -321,6 +324,15 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
get list() {
return allServers()
},
get visible() {
return visibleServers()
},
isHidden(key: ServerConnection.Key) {
return store.hidden[key] ?? false
},
setHidden(key: ServerConnection.Key, hidden: boolean) {
setStore("hidden", key, hidden)
},
add,
remove,
canRemove,
+2 -3
View File
@@ -8,7 +8,7 @@ import { createServerSdkContext } from "./client"
import { createServerSyncContext } from "./sync"
import { createData } from "@opencode-ai/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createServerPermissionState } from "@/session/requests/server-permission"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
@@ -144,7 +144,7 @@ function createServerController(
directory: "",
})
const sync = createServerSyncContext(sdk, data)
const permission = createServerPermissionState({ sdk, sync, data })
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) {
@@ -187,7 +187,6 @@ function createServerController(
list: projectsList,
recentlyClosed: recentlyClosedList,
},
permission,
notification,
}
}
@@ -66,7 +66,19 @@ export function useServerActionsController() {
}
}
return { defaults, connection: { canRemove: server.canRemove, remove } }
return {
defaults,
connection: {
canRemove: server.canRemove,
remove,
canHide: (key: ServerConnection.Key) => {
const conn = server.list.find((item) => ServerConnection.key(item) === key)
return server.visible.length > 1 && !!conn && ServerConnection.builtin(conn)
},
isHidden: server.isHidden,
setHidden: server.setHidden,
},
}
}
export type ServerActionsController = ReturnType<typeof useServerActionsController>
@@ -22,10 +22,14 @@ export const ServerRowMenu: Component<{
canDefault={props.domain.defaults.available()}
isDefault={props.domain.defaults.key() === key}
canRemove={props.domain.connection.canRemove(key)}
canHide={props.domain.connection.canHide(key)}
hidden={props.domain.connection.isHidden(key)}
onEdit={props.onEdit}
onSetDefault={() => props.domain.defaults.set(key)}
onRemoveDefault={() => props.domain.defaults.set(null)}
onRemove={() => props.domain.connection.remove(key)}
onHide={() => props.domain.connection.setHidden(key, true)}
onShow={() => props.domain.connection.setHidden(key, false)}
open={props.open}
onOpenChange={props.onOpenChange}
/>
@@ -40,6 +44,8 @@ export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
default: language.t("dialog.server.menu.default"),
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
delete: language.t("dialog.server.menu.delete"),
hide: language.t("dialog.server.menu.hide"),
show: language.t("dialog.server.menu.show"),
}
}
@@ -49,10 +55,14 @@ export const ServerRowMenuView: Component<{
canDefault: boolean
isDefault: boolean
canRemove: boolean
canHide?: boolean
hidden?: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
onRemove: () => void
onHide?: () => void
onShow?: () => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
@@ -86,6 +96,12 @@ export const ServerRowMenuView: Component<{
<Show when={props.canDefault && props.isDefault}>
<Menu.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</Menu.Item>
</Show>
<Show when={props.hidden}>
<Menu.Item onSelect={props.onShow}>{props.labels.show}</Menu.Item>
</Show>
<Show when={!props.hidden && props.canHide}>
<Menu.Item onSelect={props.onHide}>{props.labels.hide}</Menu.Item>
</Show>
<Show when={props.canRemove}>
<Menu.Separator />
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item>
@@ -4,14 +4,13 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePermission } from "@/session/requests/permission"
import { useComposerState } from "@/composer/persistence"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { useTerminal } from "@/session/terminal/context"
import { showToast } from "@/shell/notifications/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import type { SessionModel } from "@/session/model"
import type { SessionRevert } from "@/session/revert"
@@ -47,12 +46,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const permission = usePermission()
const prompt = useComposerState()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const settings = useSettings()
const terminal = useTerminal()
const platform = usePlatform()
const layout = useLayout()
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
const owner = actions.session.ownership.capture()
@@ -97,11 +95,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const mcpCommand = withCategory(language.t("command.category.mcp"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))
const isAutoAcceptActive = () => {
const sessionID = actions.session.identity.params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
return permission.isAutoAcceptingDirectory(sdk().directory)
}
const exportSession = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
@@ -131,7 +124,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
try {
await navigator.clipboard.writeText(sessionID)
await (platform.writeClipboardText?.(sessionID) ?? navigator.clipboard.writeText(sessionID))
showToast({
variant: "success",
icon: "circle-check",
@@ -151,7 +144,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const projectID = actions.session.data.info()?.projectID
if (!projectID) return
try {
await navigator.clipboard.writeText(projectID)
await (platform.writeClipboardText?.(projectID) ?? navigator.clipboard.writeText(projectID))
showToast({
variant: "success",
icon: "circle-check",
@@ -221,13 +214,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const toggleAutoAccept = () => {
const sessionID = actions.session.identity.params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
else permission.toggleAutoAcceptDirectory(sdk().directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk().directory)
: permission.isAutoAcceptingDirectory(sdk().directory)
const active = !settings.permissions.autoApprove()
settings.permissions.setAutoApprove(active)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
@@ -452,7 +440,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permissionsCmds = () => [
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
title: settings.permissions.autoApprove()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
@@ -0,0 +1,136 @@
import { createEffect, onCleanup } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import type { ServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
const respondedLimit = 1000
const retryLimit = 2
const retryDelayMs = 1000
// Auto-approves permission requests on one server connection whenever the
// app-level auto-approve setting is on. The setting lives in the client-local
// settings store, so it applies to every session, tab, and server at once.
export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data }) {
const enabled = useSettings().permissions.autoApprove
const state = { disposed: false, generation: 0, responded: new Set<string>() }
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (enabled()) approve(event.data)
})
onCleanup(() => {
state.disposed = true
unsubscribe()
})
// The event stream does not replay requests asked while this client was
// disconnected, and requests may already be pending before the setting turns
// on, so sweep on every connect while the setting is on.
createEffect(() => {
if (!enabled() || input.sdk.connection.status() !== "connected") return
const generation = ++state.generation
void sweepWithRetry(generation, 0)
})
// Approves pending requests that reach the local store, which is how a
// previously unknown idle session's requests surface when its view opens
// and syncs them. Store changes cannot re-trigger the network sweep: it
// deliberately reads them after an await, outside Solid tracking.
createEffect(() => {
if (!enabled()) return
for (const session of input.data.session.list()) {
for (const request of input.data.session.permission.list(session.id) ?? []) approve(request)
}
})
// An incomplete sweep leaves pending requests hidden with no later trigger
// to recover them, so retry it a bounded number of times. A newer sweep
// supersedes scheduled retries.
async function sweepWithRetry(generation: number, attempt: number) {
const complete = await sweep()
if (complete || attempt >= retryLimit) return
setTimeout(() => {
if (state.disposed || !enabled() || generation !== state.generation) return
void sweepWithRetry(generation, attempt + 1)
}, retryDelayMs * (attempt + 1))
}
async function sweep() {
const inventory = await sweepLocations()
const listed = await Promise.all(
inventory.locations.map((location) =>
input.sdk.api.permission.request
.list({ location: { directory: location.directory, workspace: location.workspaceID } })
.then((pending) => {
if (!state.disposed) pending.data.forEach((request) => approve(request))
return true
})
.catch(() => false),
),
)
return inventory.complete && listed.every(Boolean)
}
// Active sessions are the primary inventory: session.active is server-wide,
// so it covers sessions no tab has loaded, and a request blocking a tool
// call always belongs to one (Permission.assert clears its entry when the
// awaiting fiber dies). Locally known sessions are swept too because the
// external session.permission.create API can park a request on an idle
// session. A detached request on a session this client never loaded is the
// one case that stays uncovered.
async function sweepLocations() {
const active = await input.sdk.api.session.active().catch(() => undefined)
const ids = Object.keys(active ?? {})
// Resync every active session rather than trusting cached info: another
// client may have moved one while this client was disconnected, and the
// cached location would list permissions from the old location. A failed
// resync falls back to the cached location and marks the sweep incomplete.
const synced = await Promise.all(
ids.map((id) => {
input.data.session.invalidate(id)
return input.data.session.sync(id).then(
() => true,
() => false,
)
}),
)
const locations = [
...ids.flatMap((id) => {
const location = input.data.session.get(id)?.location
return location ? [location] : []
}),
...input.data.session.list().map((session) => session.location),
]
return {
locations: [
...new Map(locations.map((item) => [`${item.directory}\u0000${item.workspaceID ?? ""}`, item])).values(),
],
complete: active !== undefined && synced.every(Boolean),
}
}
function approve(permission: PermissionRequest, attempt = 0) {
// enabled() guards the retry timer path: the user may disable the setting
// between a failed reply and its scheduled retry.
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
// of times. Later sweeps retry it after that.
state.responded.delete(permission.id)
if (state.disposed || attempt >= retryLimit) return
setTimeout(() => approve(permission, attempt + 1), retryDelayMs * (attempt + 1))
})
}
function remember(id: string) {
state.responded.add(id)
for (const oldest of state.responded) {
if (state.responded.size <= respondedLimit) break
state.responded.delete(oldest)
}
}
}
@@ -1,129 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, relocateAutoAccept, sessionAutoAccept } from "./auto-respond"
const session = (input: { id: string; parentID?: string }) =>
({
id: input.id,
parentID: input.parentID,
}) as SessionInfo
const permission = (sessionID: string) =>
({
sessionID,
}) as Pick<PermissionRequest, "sessionID">
describe("autoRespondsPermission", () => {
test("uses a parent session's directory-scoped auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
test("defaults to requiring approval when no lineage override exists", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
const autoAccept = {
other: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
})
test("inherits a parent session's false override", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("prefers a child override over parent override", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/root`]: false,
[`${base64Encode(directory)}/child`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
test("falls back to directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
})
test("session-level override takes precedence over directory-level", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})
test("parent false override takes precedence over directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
})
test("parent true override takes precedence over disabled directory fallback", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: false,
[`${base64Encode(directory)}/root`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
})
describe("isDirectoryAutoAccepting", () => {
test("returns true when directory key is set", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
})
test("returns false when directory key is not set", () => {
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
})
test("returns false when directory key is explicitly false", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
})
})
test("relocates bare session settings when the directory becomes known", () => {
const directory = "/tmp/project"
expect(relocateAutoAccept({ root: true }, [{ id: "root" }], directory)).toEqual({
[`${base64Encode(directory)}/root`]: true,
})
expect(
relocateAutoAccept({ root: true, [`${base64Encode(directory)}/root`]: false }, [{ id: "root" }], directory),
).toEqual({ [`${base64Encode(directory)}/root`]: false })
})
@@ -1,79 +0,0 @@
import { base64Encode } from "@opencode-ai/util/encode"
export function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
}
export function directoryAcceptKey(directory: string) {
return `${base64Encode(directory)}/*`
}
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
return autoAccept[acceptKey(sessionID, directory)]
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
const key = directoryAcceptKey(directory)
return autoAccept[key] ?? false
}
export function relocateAutoAccept(
autoAccept: Record<string, boolean>,
sessions: readonly { id: string }[],
directory: string,
) {
const moves = sessions.flatMap((session) => {
const value = autoAccept[session.id]
if (value === undefined) return []
return [{ source: session.id, target: acceptKey(session.id, directory), value }]
})
if (moves.length === 0) return autoAccept
const next = { ...autoAccept }
for (const move of moves) {
if (next[move.target] === undefined) next[move.target] = move.value
delete next[move.source]
}
return next
}
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
const parent = session.reduce((acc, item) => {
if (item.parentID) acc.set(item.id, item.parentID)
return acc
}, new Map<string, string>())
const seen = new Set([sessionID])
const ids = [sessionID]
for (const id of ids) {
const parentID = parent.get(id)
if (!parentID || seen.has(parentID)) continue
seen.add(parentID)
ids.push(parentID)
}
return ids
}
export function autoRespondsPermission(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
const value = sessionAutoAccept(autoAccept, session, permission, directory)
if (value !== undefined) return value
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
}
export function sessionAutoAccept(
autoAccept: Record<string, boolean>,
session: { id: string; parentID?: string }[],
permission: { sessionID: string },
directory?: string,
) {
return sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined)
}
+4 -5
View File
@@ -5,7 +5,7 @@ import { useParams } from "@solidjs/router"
import { showToast } from "@/shell/notifications/toast"
import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { usePermission } from "@/session/requests/permission"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { useData } from "@/runtime/server/current"
@@ -16,7 +16,7 @@ export function createSessionRequestModel() {
const serverSDK = useServerSDK()
const data = useData()
const language = useLanguage()
const permission = usePermission()
const settings = useSettings()
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
@@ -32,9 +32,8 @@ export function createSessionRequestModel() {
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
return !permission.autoResponds(item, sdk().directory)
})
if (settings.permissions.autoApprove()) return undefined
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id)
})
const blocked = createMemo(() => {
@@ -1,3 +0,0 @@
import { useServer } from "@/runtime/server/current"
export const usePermission = () => useServer().ctx.permission
@@ -1,348 +0,0 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { ServerSDK } from "@/runtime/server/client"
import type { ServerSync } from "@/runtime/server/sync"
import type { Data } from "@opencode-ai/client/solid"
import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/runtime/persistence/base64"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { type DraftTab, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { requireServerKey } from "@/shell/routes/session"
import { ServerScope } from "@/runtime/server/scope"
import {
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
relocateAutoAccept,
sessionAutoAccept,
} from "./auto-respond"
type PermissionRespondFn = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
directory?: string
}) => void
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
if (typeof rule !== "object") return false
if (Array.isArray(rule)) return false
for (const action of Object.values(rule)) {
if (action !== "allow") return true
}
return false
}
function hasPermissionPromptRules(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
if (Array.isArray(permission)) return false
const config = permission as Record<string, unknown>
return Object.values(config).some(isNonAllowRule)
}
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(input.sdk.scope, "permission"),
...(input.sdk.scope === ServerScope.local ? { previousKey: "permission.v3" } : {}),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
createStore({
autoAccept: {} as Record<string, boolean>,
}),
)
function enableConfiguredDirectory(directory: string) {
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] !== undefined) return
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
const meta = { disposed: false }
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
if (now - ts < RESPONDED_TTL_MS) break
responded.delete(id)
}
for (const id of responded.keys()) {
if (responded.size <= MAX_RESPONDED) break
responded.delete(id)
}
}
const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return
input.sdk.api.permission
.reply({
sessionID: request.sessionID,
requestID: request.permissionID,
reply: request.response,
})
.catch(() => {
responded.delete(request.permissionID)
})
}
const list = async (directory: string) => {
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data)
}
function respondOnce(permission: PermissionRequest, directory?: string) {
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
function sessions(_directory?: string) {
return input.data.session.list()
}
function autoAccept(directory?: string) {
if (!directory) return store.autoAccept
const next = relocateAutoAccept(store.autoAccept, sessions(directory), directory)
if (next !== store.autoAccept) setStore("autoAccept", reconcile(next))
return next
}
function isAutoAccepting(sessionID: string, directory?: string) {
return autoRespondsPermission(autoAccept(directory), sessions(directory), { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
return autoRespondsPermission(autoAccept(directory), sessions(directory), permission, directory)
}
function isPending(permission: PermissionRequest) {
const pending = input.data.session.permission.list(permission.sessionID)
return pending === undefined || pending.some((item) => item.id === permission.id)
}
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
const override = sessionAutoAccept(autoAccept(directory), sessions(directory), permission, directory)
if (override !== undefined) return override
const loaded = new Set<string>()
while (!loaded.has(input.data.session.root(permission.sessionID))) {
const root = input.data.session.root(permission.sessionID)
loaded.add(root)
if (input.data.session.get(root)) break
await input.data.session.sync(root).catch(() => undefined)
}
if (meta.disposed || !input.data.session.get(permission.sessionID)) return false
return shouldAutoRespond(permission, directory)
}
async function respondPending(
permission: PermissionRequest,
directory?: string,
current: () => boolean = () => true,
) {
if (!current() || !isPending(permission)) return
if (!(await shouldAutoRespondResolved(permission, directory))) return
if (meta.disposed || !current() || !isPending(permission)) return
respondOnce(permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (ready()) {
void respondPending(event.data, event.location?.directory)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
void respondPending(event.data, event.location?.directory)
})
})
onCleanup(() => {
meta.disposed = true
unsubscribe()
})
function enableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const permission of permissions) {
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
if (meta.disposed) return
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
if (meta.disposed) return
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const permission of permissions) {
void respondPending(
permission,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
}
})
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
if (meta.disposed) return
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : sessionID
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}
const api = {
ready: () => !meta.disposed && ready(),
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
if (meta.disposed) return false
return shouldAutoRespond(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
if (meta.disposed) return false
return isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
if (meta.disposed) return false
return isAutoAcceptingDirectory(directory)
},
toggleAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (meta.disposed) return
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (meta.disposed) return
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
if (meta.disposed) return
disable(sessionID, directory)
},
isPermissionAllowAll(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return childStore.config.permission === "allow"
},
}
return {
...api,
api,
sync: input.sync,
enableConfiguredDirectory,
permissionsEnabled(directory: string) {
if (meta.disposed) return false
const [childStore] = input.sync.child(directory)
return hasPermissionPromptRules(childStore.config.permission)
},
}
}
@@ -0,0 +1,15 @@
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,3 +17,19 @@ 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
}
@@ -31,19 +31,3 @@ 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,17 +11,13 @@ 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,
timelineRemovedSessionIDs,
visibleTimelineMessages,
} from "./controller-projection"
import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
@@ -72,6 +68,14 @@ export function createTimelineController(input: { session: TimelineSessionSource
input.session.data.info()?.revert?.messageID,
)
})
const pendingUserMessageIDs = createMemo(() => {
const id = input.session.identity.sessionID()
return new Set(
(id ? data.session.pending.list(id) : []).flatMap((item) =>
item.type === "user" && item.delivery === "steer" ? [item.id] : [],
),
)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
const parentMessages = createMemo(() => {
@@ -101,6 +105,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
sessionMessages: projectedMessages,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -171,7 +176,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
return false
})
if (!success) return false
const removed = timelineRemovedSessionIDs(data.session.list(), id)
const removed = removedSessionIDs(data.session.list(), id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true
@@ -8,6 +8,7 @@ export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
}) {
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
@@ -75,7 +76,12 @@ export function createTimelineProjection(input: {
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status()),
Timeline.constructSessionMessageRows(
input.sessionMessages(),
input.showReasoningSummaries(),
input.status(),
input.pendingUserMessageIDs(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
+1 -4
View File
@@ -1,4 +1,3 @@
import { useParams } from "@solidjs/router"
import { onCleanup } from "solid-js"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
@@ -6,7 +5,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
export function useSettingsDialog(defaultValue?: string) {
const dialog = useDialog()
const params = useParams<{ id?: string }>()
let run = 0
let dead = false
@@ -16,10 +14,9 @@ export function useSettingsDialog(defaultValue?: string) {
return () => {
const current = ++run
const sessionID = params.id
void import("@/settings/shell").then((module) => {
if (dead || run !== current) return
void dialog.show(() => <module.DialogSettings sessionID={sessionID} defaultValue={defaultValue} />)
void dialog.show(() => <module.DialogSettings defaultValue={defaultValue} />)
})
}
}
@@ -1,7 +1,6 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/session/requests/permission"
import {
monoDefault,
monoFontFamily,
@@ -17,43 +16,11 @@ import {
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
import { createSoundPreviewController, type ShellOption } from "./behavior"
import { ServerConnection } from "@/runtime/server/registry"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { useServerCtx } from "@/runtime/server/runtime"
export { createShellOptions, createSoundPreviewController } from "./behavior"
export type { ShellOption, ShellSelectOption } from "./behavior"
export function createPermissionScopeController(
server: Accessor<ServerConnection.Any | undefined>,
sessionID: Accessor<string | undefined>,
) {
const serverCtx = useServerCtx(server)
const permission = () => serverCtx()?.permission
const directory = createMemo(() => {
const s = server()
const id = sessionID()
if (!s || !id) return undefined
return serverCtx()?.data.session.get(id)?.location.directory
})
return {
accepting: createMemo(() => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission()?.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission()?.enableAutoAccept(id, dir)
permission()?.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
const serverCtx = useServerCtx(server)
const [shells] = createResource(
@@ -172,7 +139,6 @@ export function createSoundSettingsController() {
}
}
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
+5 -12
View File
@@ -13,11 +13,9 @@ import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import {
createAppearanceSettingsController,
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
} from "./controllers"
import "@/settings/settings.css"
@@ -47,8 +45,9 @@ const fontSettings = {
input: "setTerminal",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const AutoApprovePermissionsSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
return (
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
@@ -56,9 +55,8 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
>
<div data-action="settings-auto-accept-permissions">
<Switch
checked={props.controller.accepting()}
disabled={!props.controller.enabled()}
onChange={props.controller.set}
checked={settings.permissions.autoApprove()}
onChange={(checked) => settings.permissions.setAutoApprove(checked)}
/>
</div>
</SettingsRow>
@@ -262,7 +260,6 @@ const LanguageSetting = () => {
}
export const SettingsGeneral: Component<{
sessionID?: string
server?: ServerConnection.Any
}> = (props) => {
const language = useLanguage()
@@ -270,10 +267,6 @@ export const SettingsGeneral: Component<{
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(
() => props.server,
() => props.sessionID,
)
const shell = createShellSettingsController(() => props.server)
const desktop = createMemo(() => platform.platform === "desktop")
@@ -297,7 +290,7 @@ export const SettingsGeneral: Component<{
<LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} />
<AutoApprovePermissionsSetting />
<ShellSetting controller={shell} />
<TerminalPlacementSetting />
+4
View File
@@ -724,6 +724,8 @@
display: flex;
flex-direction: column;
gap: 0;
max-height: 480px;
overflow-y: auto;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
@@ -885,6 +887,8 @@
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
+2 -3
View File
@@ -23,7 +23,6 @@ import { ServerConnection, useServers } from "@/runtime/server/registry"
import "@/settings/settings.css"
export const DialogSettings: Component<{
sessionID?: string
defaultValue?: string
}> = (props) => {
const language = useLanguage()
@@ -68,7 +67,7 @@ export const DialogSettings: Component<{
})
const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
void dialog.show(() => <DialogSettings defaultValue="providers" />)
}
return (
@@ -144,7 +143,7 @@ export const DialogSettings: Component<{
</Tabs.List>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral server={server()} sessionID={props.sessionID} />
<SettingsGeneral server={server()} />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />
@@ -2,14 +2,15 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
directory: Accessor<string>,
sessionId: Accessor<string>,
root?: Accessor<boolean>,
) {
const global = useGlobal()
const settings = useSettings()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const serverCtx = useServerCtx(connection)
const sessions = createMemo(() => {
@@ -23,12 +24,10 @@ export function useSessionTabAvatarState(
})
})
const hasPermissions = createMemo(() => {
if (settings.permissions.autoApprove()) return false
const ctx = serverCtx()
if (!ctx) return false
const permission = ctx.permission
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
const hasQuestions = createMemo(() => {
const data = serverCtx()?.data
@@ -15,7 +15,6 @@ export function SessionTabAvatar(props: {
}) {
const state = useSessionTabAvatarState(
() => props.server,
() => props.directory,
() => props.sessionId,
)
return (
@@ -9,10 +9,10 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { playSoundById } from "@/shell/notifications/sound"
import { playSoundByIdOnce } from "@/shell/notifications/sound"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { type DraftTab, useTabs } from "@/shell/tabs/tabs"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
@@ -112,6 +112,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const tabs = useTabs()
const empty: Notification[] = []
const [store, setStore, _, ready] = persisted(
@@ -215,14 +216,17 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
dispatchEvent(new PopStateEvent("popstate"))
}
const handleSessionIdle = (sessionID: string, time: number) => {
const handleSessionIdle = (sessionID: string, eventID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.agentEnabled()
) {
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
}
append({
@@ -245,14 +249,18 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const handleSessionError = (
sessionID: string,
error: ErrorNotification["error"],
eventID: string,
time: number,
) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
if (
sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
settings.sounds.errorsEnabled()
) {
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
}
append({
@@ -278,10 +286,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(event.data.sessionID, event.data.error, time)
handleSessionError(event.data.sessionID, event.data.error, event.id, time)
return
}
handleSessionIdle(event.data.sessionID, time)
handleSessionIdle(event.data.sessionID, event.id, time)
})
onCleanup(() => {
meta.disposed = true
@@ -74,6 +74,9 @@ function getLoads() {
}
const cache = new Map<SoundID, Promise<string | undefined>>()
const claimed = new Set<string>()
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
const MAX_CLAIMED = 500
export function soundSrc(id: string | undefined) {
const loads = getLoads()
@@ -100,3 +103,34 @@ export function playSound(src: string | undefined) {
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
const play = async () => {
if (!claim(eventID)) return
await playSoundById(id)
}
if (typeof navigator === "undefined" || !navigator.locks) return play()
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
}
function claim(eventID: string) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
@@ -1,6 +1,7 @@
import { DataProvider } from "@opencode-ai/session-ui/context"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, type ParentProps } from "solid-js"
import { useProviders } from "@/providers/catalog/providers"
import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
@@ -30,7 +31,11 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const providers = useProviders(directory)
const sessionUIData = createMemo(() => ({
provider: providers.ready()
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
: undefined,
session: data.session.list(),
session_status: Object.fromEntries(
data.session
+10 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -47,6 +47,15 @@ test("session tab identity stays rooted while its href follows the child route",
expect(tabHref(child)).toContain("/session/child")
})
test("finds open root and routed session tabs", () => {
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
expect(sessionIDHasOpenTab(tabs, "other" as ServerConnection.Key, "root")).toBe(false)
})
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
+5 -1
View File
@@ -51,11 +51,15 @@ export const tabKey = (tab: Tab) =>
tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${sessionHref(tab.server, tab.sessionId)}`
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
return sessionIDHasOpenTab(tabs, server, session.id)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
(tab) =>
tab.type === "session" &&
tab.server === server &&
(tab.sessionId === session.id || tab.routeSessionId === session.id),
(tab.sessionId === sessionID || tab.routeSessionId === sessionID),
)
}
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test"
import { windowsMenuAccelerator } from "./windows-menu"
describe("Windows app menu", () => {
test("resolves the new window accelerator", () => {
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N", ctrlKey: true, shiftKey: true }))).toBe(
"window.new",
)
})
test("ignores the accelerator without its modifiers", () => {
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N" }))).toBeUndefined()
})
})
@@ -1,9 +1,10 @@
import { For, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { For, onMount, type JSX } from "solid-js"
import { Menu } from "@opencode-ai/ui/menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useCommand } from "@/shell/commands/command"
import { matchKeybind, parseKeybind, useCommand } from "@/shell/commands/command"
import {
DESKTOP_MENU,
desktopMenuVisible,
@@ -13,6 +14,15 @@ import {
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
const accelerators = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).flatMap((entry) => {
if (entry.type === "separator" || !entry.action || !entry.accelerator?.windows) return []
return [{ action: entry.action, keybind: parseKeybind(entry.accelerator.windows) }]
})
export function windowsMenuAccelerator(event: KeyboardEvent) {
return accelerators.find((entry) => matchKeybind(entry.keybind, event))?.action
}
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform>
@@ -50,6 +60,22 @@ export function WindowsAppMenu(props: {
if (entry.href) props.platform.openExternal(entry.href)
}
onMount(() => {
makeEventListener(
document,
"keydown",
(event) => {
if (event.defaultPrevented) return
const action = windowsMenuAccelerator(event)
if (!action) return
event.preventDefault()
event.stopPropagation()
runAction(action)
},
{ capture: true },
)
})
return (
<Menu appearance="standard" gutter={4} modal={false} placement="bottom-start">
<div
+5 -1
View File
@@ -185,7 +185,11 @@ async function compileExecutable(item: (typeof allTargets)[number]) {
headers: { Accept: "application/octet-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
})
if (!response.ok) throw new Error(`Failed to download ${name} from Bun release ${release}: ${response.status}`)
await Bun.write(archive, response)
// Stream to disk instead of `Bun.write(archive, response)`: passing the Response object
// hangs forever if it gets GC'd mid-download (https://github.com/oven-sh/bun/issues/40278).
const sink = Bun.file(archive).writer()
for await (const chunk of response.body!) await sink.write(chunk)
await sink.end()
await $`unzip -oq ${archive} -d ${cache}`
await rm(archive)
return executable
+1 -1
View File
@@ -4,7 +4,7 @@
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"noUncheckedIndexedAccess": false
},
"exclude": ["dist", "dist-node"]
+33 -5
View File
@@ -2,20 +2,26 @@
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const dryRun = Bun.argv.includes("--dry-run")
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
value.replace("./src/", "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js")
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
@@ -29,16 +35,38 @@ try {
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
import: output(value),
types: output(value, true),
},
]
}),
)
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
const consumer = await mkdtemp(join(tmpdir(), "opencode-codemode-"))
try {
await Bun.write(
join(consumer, "package.json"),
JSON.stringify({
private: true,
type: "module",
dependencies: { [pkg.name]: `file:${join(process.cwd(), tarball)}` },
}),
)
await $`npm install --ignore-scripts --no-audit --no-fund`.cwd(consumer)
await $`node --input-type=module -e ${`await import(${JSON.stringify(pkg.name)})`}`.cwd(consumer)
await $`bun --conditions=workerd -e ${`await import(${JSON.stringify(pkg.name)})`}`.cwd(consumer)
} finally {
await rm(consumer, { recursive: true, force: true })
}
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
-1
View File
@@ -3,7 +3,6 @@ import { defineConfig } from "drizzle-kit"
export default defineConfig({
out: "./migrations/",
strict: true,
schema: ["./src/**/*.sql.ts"],
verbose: true,
dialect: "mysql",
+2 -9
View File
@@ -3,19 +3,11 @@ import { Resource } from "@opencode-ai/console-resource"
export * from "drizzle-orm"
import { Client } from "@planetscale/database"
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
import type { MySqlTransactionConfig } from "drizzle-orm/mysql-core"
import { Context } from "../context"
import { memo } from "../util/memo"
export namespace Database {
export type Transaction = MySqlTransaction<
PlanetscaleQueryResultHKT,
PlanetScalePreparedQueryHKT,
Record<string, never>,
any
>
const client = memo(() => {
const result = new Client({
host: Resource.Database.host,
@@ -26,6 +18,7 @@ export namespace Database {
return db
})
export type Transaction = Parameters<Parameters<ReturnType<typeof client>["transaction"]>[0]>[0]
export type TxOrDb = Transaction | ReturnType<typeof client>
const TransactionContext = Context.create<{
+3 -3
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "3fb67508-0196-4bae-b2bd-c08ece7583fd",
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"ddl": [
{
"name": "account_state",
@@ -1442,7 +1442,7 @@
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@@ -6,7 +6,7 @@ import { EffectCache } from "drizzle-orm/cache/core/cache-effect"
import { EffectLogger } from "drizzle-orm/effect-core"
import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteEffectDatabase } from "../sqlite-core/effect/db.js"
import type { DrizzleConfig } from "drizzle-orm/utils"
import { jitCompatCheck } from "../internal/drizzle-utils.js"
@@ -53,12 +53,13 @@ export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extend
const cache = yield* EffectCache
const logger = yield* EffectLogger
const dialect = new SQLiteAsyncDialect()
const useJitMappers = jitCompatCheck(config.jit)
const dialect = new SQLiteDialect({ useJitMappers })
const relations = config.relations ?? ({} as TRelations)
const session = new EffectSQLiteSession(client, dialect, relations, {
logger,
cache,
useJitMappers: jitCompatCheck(config.jit),
useJitMappers,
})
const db = new EffectSQLiteDatabase(dialect, session, relations) as EffectSQLiteDatabase<TRelations> & {
$client: SqlClient
@@ -14,7 +14,7 @@ import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations } from "drizzle-orm/relations"
import type { RelationalQueryMapperConfig } from "drizzle-orm/relations"
import type { Query } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import {
SQLiteEffectPreparedQuery,
SQLiteEffectSession,
@@ -59,7 +59,7 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
constructor(
private client: SqlClient,
dialect: SQLiteAsyncDialect,
dialect: SQLiteDialect,
protected relations: TRelations,
private options: EffectSQLiteSessionOptions,
) {
@@ -41,14 +41,34 @@ export function orderSelectedFields<TColumn extends Column>(
): SelectedFieldsOrdered {
return Object.entries(fields).flatMap(([name, field]) => {
const path = pathPrefix ? [...pathPrefix, name] : [name]
if (is(field, Column) || is(field, SQL) || is(field, SQL.Aliased) || is(field, Subquery)) {
return [{ path, field }] as SelectedFieldsOrdered
}
if (is(field, Column)) return [{ path, field, fieldType: "Column", column: field }] as SelectedFieldsOrdered
if (is(field, SQL)) return [{ path, field, fieldType: "SQL" }] as SelectedFieldsOrdered
if (is(field, SQL.Aliased)) return [{ path, field, fieldType: "SQL.Aliased" }] as SelectedFieldsOrdered
if (is(field, Subquery)) return [{ path, field, fieldType: "Subquery" }] as SelectedFieldsOrdered
if (is(field, Table)) return orderSelectedFields(getTableColumnsRuntime(field as SQLiteTable), path)
return orderSelectedFields(field as Record<string, unknown>, path)
}) as SelectedFieldsOrdered
}
export function resolveNullableObjectPaths(
columns: SelectedFieldsOrdered,
joinsNotNullableMap: Record<string, boolean> | undefined,
) {
if (!joinsNotNullableMap) return undefined
const tableOf = columns
.filter((column) => column.path.length > 1 && column.fieldType === "Column")
.reduce<Record<string, string | false>>((result, column) => {
const key = column.path[0]!
const tableName = getTableName(getColumnTable(column.field as Column))
const current = result[key]
result[key] = current === undefined ? tableName : current === tableName ? current : false
return result
}, {})
return Object.entries(tableOf)
.filter(([, tableName]) => typeof tableName === "string" && !joinsNotNullableMap[tableName])
.map(([key]) => key)
}
export function mapUpdateSet<TTable extends SQLiteTable>(table: TTable, values: SQLiteUpdateSetSource<TTable>) {
const entries = Object.entries(values).filter(([, value]) => value !== undefined)
if (entries.length === 0) throw new Error("No values to set")
@@ -9,7 +9,7 @@ import type { TypedQueryBuilder } from "drizzle-orm/query-builders/query-builder
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SelectionProxyHandler } from "drizzle-orm/selection-proxy"
import { type ColumnsSelection, type SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import { QueryBuilder } from "drizzle-orm/sqlite-core/query-builders/query-builder"
import type { SelectedFields } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
@@ -45,7 +45,7 @@ export class SQLiteEffectDatabase<
constructor(
/** @internal */
readonly dialect: SQLiteAsyncDialect,
readonly dialect: SQLiteDialect,
/** @internal */
readonly session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
relations: TRelations,
@@ -3,14 +3,15 @@ import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { SelectResultFields } from "drizzle-orm/query-builders/select.types"
import type { TypedQueryBuilder } from "drizzle-orm/query-builders/query-builder"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import type { Query, SQLWrapper } from "drizzle-orm/sql/sql"
import { Param, SQL, sql } from "drizzle-orm/sql/sql"
import { SQL, sql } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { IndexColumn } from "drizzle-orm/sqlite-core/indexes"
import type {
SQLiteInsertConfig,
SQLiteInsertSelectQueryBuilder,
SQLiteInsertSelection,
SQLiteInsertValue,
} from "drizzle-orm/sqlite-core/query-builders/insert"
import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types"
@@ -152,34 +153,24 @@ export class SQLiteEffectInsertBuilder<
if (values.length === 0) {
throw new Error("values() must be called with at least one value")
}
const mappedValues = values.map((entry) => {
const result: Record<string, Param | SQL> = {}
const cols = getTableColumnsRuntime(this.table)
for (const colKey of Object.keys(entry)) {
const colValue = entry[colKey as keyof typeof entry]
result[colKey] = is(colValue, SQL) ? colValue : new Param(colValue, cols[colKey])
}
return result
})
return new SQLiteEffectInsertBase(this.table, mappedValues, this.session, this.dialect, this.withList)
return new SQLiteEffectInsertBase(this.table, values, this.session, this.dialect, this.withList)
}
select(
selectQuery: (qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable>,
selectQuery: (qb: QueryBuilder) => TypedQueryBuilder<SQLiteInsertSelection<TTable>>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery: (qb: QueryBuilder) => SQL,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(selectQuery: SQL): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery: SQLiteInsertSelectQueryBuilder<TTable>,
selectQuery: TypedQueryBuilder<SQLiteInsertSelection<TTable>>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery:
| SQL
| SQLiteInsertSelectQueryBuilder<TTable>
| ((qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable> | SQL),
| TypedQueryBuilder<SQLiteInsertSelection<TTable>>
| ((qb: QueryBuilder) => TypedQueryBuilder<SQLiteInsertSelection<TTable>> | SQL),
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery
@@ -5,7 +5,7 @@ import { entityKind } from "drizzle-orm/entity"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import type { PreparedQuery } from "drizzle-orm/session"
import type { Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
type SQLiteEffectRawAction = "all" | "get" | "values" | "run"
@@ -29,7 +29,7 @@ export class SQLiteEffectRaw<TResult, TEffectHKT extends QueryEffectHKTBase = Qu
/** @internal */
public getSQL: () => SQL,
private action: SQLiteEffectRawAction,
private dialect: SQLiteAsyncDialect,
private dialect: SQLiteDialect,
private mapBatchResult: (result: unknown) => unknown,
) {}
@@ -15,7 +15,7 @@ import { SQL } from "drizzle-orm/sql/sql"
import type { ColumnsSelection, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteSelectQueryBuilderBase } from "drizzle-orm/sqlite-core/query-builders/select"
import { SQLiteSelectBase } from "drizzle-orm/sqlite-core/query-builders/select"
import type {
CreateSQLiteSelectFromBuilderMode,
SelectedFields,
@@ -97,8 +97,8 @@ export class SQLiteEffectSelectBuilder<
>
: CreateSQLiteSelectFromBuilderMode<
TBuilderMode,
SQLiteEffectSelectHKT<TEffectHKT>,
GetSelectTableName<TFrom>,
"async",
TRunResult,
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
TSelection extends undefined ? "single" : "partial"
@@ -164,10 +164,9 @@ export interface SQLiteEffectSelectBase<
out TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLiteSelectQueryBuilderBase<
> extends SQLiteSelectBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
@@ -193,10 +192,9 @@ export class SQLiteEffectSelectBase<
out TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
extends SQLiteSelectQueryBuilderBase<
extends SQLiteSelectBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
@@ -215,7 +213,7 @@ export class SQLiteEffectSelectBase<
}
/** @internal */
getSQL(): SQL {
override getSQL(): SQL {
return this.dialect.buildSelectQuery(this.effectConfig)
}
@@ -240,7 +238,7 @@ export class SQLiteEffectSelectBase<
return query as ReturnType<this["prepare"]>
}
$withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) {
override $withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) {
this.cacheConfig =
config === undefined
? { config: {}, enabled: true, autoInvalidate: true }
@@ -21,12 +21,12 @@ import type {
import { makeJitRqbMapper } from "drizzle-orm/relations"
import type { PreparedQuery } from "drizzle-orm/session"
import { fillPlaceholders, type Query, type SQL, sql } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import { upgradeIfNeeded } from "../../up-migrations/effect-sqlite.js"
import { assertUnreachable, makeJitQueryMapper, type RowsMapper } from "drizzle-orm/utils"
import { mapResultRow } from "../../internal/drizzle-utils.js"
import { mapResultRow, resolveNullableObjectPaths } from "../../internal/drizzle-utils.js"
import { SQLiteEffectDatabase } from "./db.js"
type MigrationConfigWithInit = MigrationConfig & { init?: boolean }
@@ -155,7 +155,10 @@ export class SQLiteEffectPreparedQuery<
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["all"]>) ??
makeJitQueryMapper<T["all"]>(this.fields!, this.joinsNotNullableMap))(rows as unknown[][])
makeJitQueryMapper<T["all"]>(
this.fields!,
resolveNullableObjectPaths(this.fields!, this.joinsNotNullableMap),
))(rows as unknown[][])
: (rows as unknown[][]).map((row) => mapResultRow(this.fields!, row, this.joinsNotNullableMap))
}
@@ -187,7 +190,10 @@ export class SQLiteEffectPreparedQuery<
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["get"][]>) ??
makeJitQueryMapper<T["get"][]>(this.fields!, this.joinsNotNullableMap))([row as unknown[]])[0]
makeJitQueryMapper<T["get"][]>(
this.fields!,
resolveNullableObjectPaths(this.fields!, this.joinsNotNullableMap),
))([row as unknown[]])[0]
: mapResultRow(this.fields!, row as unknown[], this.joinsNotNullableMap)
}
@@ -311,7 +317,7 @@ export abstract class SQLiteEffectSession<
> {
static readonly [entityKind]: string = "SQLiteEffectSession"
constructor(readonly dialect: SQLiteAsyncDialect) {}
constructor(readonly dialect: SQLiteDialect) {}
abstract prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
@@ -412,7 +418,7 @@ export abstract class SQLiteEffectTransaction<
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
constructor(
dialect: SQLiteAsyncDialect,
dialect: SQLiteDialect,
session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
protected relations: TRelations,
) {
+2
View File
@@ -44,6 +44,7 @@ import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
export const migrations = [
m00,
@@ -91,4 +92,5 @@ export const migrations = [
m42,
m43,
m44,
m45,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,27 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260823191254_nullable_workspace_binding",
foreignKeys: false,
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`__new_workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text,
\`created_at\` integer NOT NULL,
\`last_used_at\` integer NOT NULL
);
`)
yield* tx.run(
`INSERT INTO \`__new_workspace\`(\`id\`, \`provider\`, \`binding\`, \`created_at\`, \`last_used_at\`) SELECT \`id\`, \`provider\`, \`binding\`, \`created_at\`, \`last_used_at\` FROM \`workspace\`;`,
)
yield* tx.run(`DROP TABLE \`workspace\`;`)
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
})
},
}
export default migration
+1 -1
View File
@@ -223,7 +223,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text NOT NULL,
\`binding\` text,
\`created_at\` integer NOT NULL,
\`last_used_at\` integer NOT NULL
);
+7 -1
View File
@@ -63,7 +63,13 @@ const baseLayer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const search = yield* FileSystemSearch.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
// Workspace-placed directories exist only inside the workspace, so a host
// realpath probe at boot consults the wrong filesystem and would block
// construction on servers without a matching local directory. Treat the
// configured directory as canonical; local placements keep symlink
// canonicalization. This skip is boot-only: resolve/read/list below still
// access the host filesystem per operation (tracked in #44568).
const root = location.workspaceID ? location.directory : yield* fs.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
+25 -18
View File
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Clock, Context, Deferred, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
@@ -61,7 +61,7 @@ export const ripgrepLayer = Layer.effect(
let index = emptyIndex()
let initialized = false
let settledAt = Number.NEGATIVE_INFINITY
let refreshing = false
let refreshing: Deferred.Deferred<void> | undefined
const scan = Effect.gen(function* () {
const next = emptyIndex()
const previous = index
@@ -84,21 +84,25 @@ export const ripgrepLayer = Layer.effect(
})
index = next
initialized = true
}).pipe(
Effect.orDie,
Effect.ensuring(
Effect.sync(() => {
settledAt = clock.currentTimeMillisUnsafe()
refreshing = false
}),
),
)
const refresh = Effect.sync(() => {
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
refreshing = true
return scan
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
yield* refresh
}).pipe(Effect.orDie)
const refresh = Effect.suspend(() => {
if (refreshing) return initialized ? Effect.void : Deferred.await(refreshing)
if (initialized && clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return Effect.void
const attempt = Deferred.makeUnsafe<void>()
refreshing = attempt
return scan.pipe(
Effect.onExit((exit) =>
Effect.sync(() => {
settledAt = clock.currentTimeMillisUnsafe()
if (refreshing === attempt) refreshing = undefined
Deferred.doneUnsafe(attempt, exit)
}),
),
Effect.forkIn(scope),
Effect.flatMap(() => (initialized ? Effect.void : Deferred.await(attempt))),
)
})
return Service.of({
find: (input) =>
Effect.gen(function* () {
@@ -180,9 +184,12 @@ export const fffLayer = Layer.effect(
export const layer = (options?: Options) =>
Layer.unwrap(
Effect.gen(function* () {
const location = yield* Location.Service
// Workspace-backed Locations resolve in a remote sandbox; fff would index the local server directory
// in-process and serve wrong results. Ripgrep routes through the Location environment spawner.
if (location.workspaceID) return ripgrepLayer
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
return ripgrepLayer
const location = yield* Location.Service
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer
}),
+20 -6
View File
@@ -8,13 +8,27 @@ import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
export const make = Effect.gen(function* () {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
const loadPhoton = yield* Effect.cached(
Effect.tryPromise({
try: () => import("@silvia-odwyer/photon-node"),
catch: () => new ResizerUnavailableError(),
}),
// A runtime without a photon wasm artifact (#photon-wasm resolves to the
// empty string on workerd) has no resizer, by declaration: fail typed
// before touching URLs or module loading. The path resolution and import
// for runtimes that DO have an artifact stay inside the guard too — a
// throw outside it (workerd's undefined import.meta.url was one) is a
// defect that escapes the ResizerUnavailableError handling and turns any
// image-bearing prompt into a 500 instead of degrading to passthrough.
photonWasm === ""
? Effect.fail(new ResizerUnavailableError())
: Effect.tryPromise({
try: async () => {
;(
globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }
).__OPENCODE_PHOTON_WASM_PATH = path.isAbsolute(photonWasm)
? photonWasm
: fileURLToPath(new URL(photonWasm, import.meta.url))
return await import("@silvia-odwyer/photon-node")
},
catch: () => new ResizerUnavailableError(),
}),
)
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
+8 -1
View File
@@ -147,7 +147,14 @@ export function buildLocationServiceMap(
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? Duration.infinity : Duration.zero) },
{
// Workspace-placed directories exist only inside the workspace, so a
// local stat consults the wrong filesystem. Workspace liveness is
// owned by placement; do not probe the sandbox here, which would
// provision lazily-idle workspaces.
idleTimeToLive: (ref) =>
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
},
),
(inner) => ({
...inner,
-4
View File
@@ -6,7 +6,6 @@ import { Global } from "@opencode-ai/util/global"
import { Effect, Stream } from "effect"
import path from "path"
import { Agent } from "../agent.js"
import { Environment } from "../environment/index.js"
import { Permission } from "../permission.js"
import { SessionEvent } from "../session/event.js"
@@ -28,12 +27,9 @@ You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply.
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
const environment = yield* Environment.Service
const global = yield* Global.Service
const directory = path.join(global.home, ".opencode", "plan")
const enterReminder = enter(directory)
yield* environment.files.mkdir(directory).pipe(Effect.orDie)
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
+121 -38
View File
@@ -3,7 +3,7 @@ export * as Workspace from "./workspace.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { eq } from "drizzle-orm"
import { Clock, Context, Duration, Effect, Exit, Layer, Ref, Schedule, Schema, Scope } from "effect"
import { Clock, Context, Deferred, Duration, Effect, Exit, FiberSet, Layer, Ref, Schedule, Schema, Scope } from "effect"
import { systemError } from "effect/PlatformError"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver as EnvironmentDriver } from "./environment/driver.js"
@@ -26,7 +26,12 @@ export class Info extends Schema.Class<Info>("Workspace.Info")({
export class NotFound extends Schema.TaggedError<NotFound>()("Workspace.NotFound", { workspaceID: ID }) {}
export interface Interface {
readonly create: (provider: string) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
/** Instantly commits a logical workspace ID. No provider work happens here. */
readonly create: (provider: string) => Effect.Effect<ID, WorkspaceDriver.ProviderNotFound>
/** Starts or joins the shared attempt that makes the backing resource real, then returns it. */
readonly provision: (
workspaceID: ID,
) => Effect.Effect<Info, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
readonly connect: (
workspaceID: ID,
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
@@ -51,6 +56,8 @@ interface Connection {
readonly scope: Scope.Closeable
}
type ReadinessError = NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound
export const configured = (options: Options = {}) =>
makeGlobalNode({
service: Service,
@@ -66,7 +73,10 @@ const layer = (options: Options) =>
const registry = yield* WorkspaceDriver.RegistryService
const lifetime = yield* Scope.Scope
const connections = new Map<ID, Connection>()
// Destroy cancels the racing provision body by settling the deferred.
const attempts = new Map<ID, Deferred.Deferred<Info, ReadinessError>>()
const locks = KeyedMutex.makeUnsafe<ID>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const idleThreshold = Duration.toMillis(options.idleThreshold ?? Duration.minutes(20))
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
@@ -80,29 +90,75 @@ const layer = (options: Options) =>
return row
})
const saveBinding = (workspaceID: ID, binding: WorkspaceDriver.Binding) =>
db.update(WorkspaceTable).set({ binding }).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
const info = (row: typeof WorkspaceTable.$inferSelect, binding: WorkspaceDriver.Binding) =>
new Info({
id: row.id,
provider: row.provider,
binding,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
})
const provision = Effect.fn("Workspace.provision")((workspaceID: ID) =>
Effect.suspend(() => {
const existing = attempts.get(workspaceID)
if (existing) return Deferred.await(existing)
const attempt = Deferred.makeUnsafe<Info, ReadinessError>()
attempts.set(workspaceID, attempt)
fork(
locks
.withLock(workspaceID)(
Effect.gen(function* () {
const row = yield* load(workspaceID)
if (row.binding) return info(row, row.binding)
const driver = yield* registry.get(row.provider)
const result = yield* driver.create({ workspaceID })
yield* saveBinding(workspaceID, result.binding)
return info(row, result.binding)
}),
)
.pipe(
Effect.raceFirst(Deferred.await(attempt)),
Effect.onExit((exit) =>
Effect.sync(() => {
if (attempts.get(workspaceID) === attempt) attempts.delete(workspaceID)
Deferred.doneUnsafe(attempt, exit)
}),
),
Effect.exit,
Effect.asVoid,
),
)
return Deferred.await(attempt)
}),
)
const open = Effect.fn("Workspace.open")(function* (workspaceID: ID) {
const existing = connections.get(workspaceID)
if (existing) return existing
const row = yield* load(workspaceID)
// Bindings are persisted before provision resolves and never nulled; a raced
// destroy deletes the whole row and surfaces as NotFound from load above.
if (!row.binding) return yield* Effect.die(`workspace ${workspaceID} has no binding after provision`)
const driver = yield* registry.get(row.provider)
const saveBinding = (value: WorkspaceDriver.Binding) =>
db
.update(WorkspaceTable)
.set({ binding: value })
.where(eq(WorkspaceTable.id, workspaceID))
.run()
.pipe(Effect.orDie)
const persistBinding = (binding: WorkspaceDriver.Binding) => saveBinding(workspaceID, binding)
const scope = yield* Scope.fork(lifetime)
const environment = yield* driver.connect({ workspaceID, binding: row.binding, saveBinding }).pipe(
Effect.provideService(Scope.Scope, scope),
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
)
const environment = yield* driver
.connect({ workspaceID, binding: row.binding, saveBinding: persistBinding })
.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
)
const now = yield* Clock.currentTimeMillis
const connection: Connection = {
driver,
environment,
saveBinding,
saveBinding: persistBinding,
lastActivity: yield* Ref.make(now),
active: yield* Ref.make(0),
scope,
@@ -129,6 +185,7 @@ const layer = (options: Options) =>
const lastActivity = yield* Ref.get(connection.lastActivity)
if (now - lastActivity < idleThreshold) return
const row = yield* load(workspaceID)
if (!row.binding) return
// Deliberate: a racing spawn blocks, then wakes cleanly. Unlocking mid-suspend could reattach a sandbox being terminated.
yield* connection.driver.suspendForIdle({
workspaceID,
@@ -151,37 +208,41 @@ const layer = (options: Options) =>
return Service.of({
create: Effect.fn("Workspace.create")(function* (provider) {
const driver = yield* registry.get(provider)
yield* registry.get(provider)
const workspaceID = ID.create()
const result = yield* driver.create({ workspaceID })
const now = yield* Clock.currentTimeMillis
yield* db
.insert(WorkspaceTable)
.values({ id: workspaceID, provider, binding: result.binding, created_at: now, last_used_at: now })
.values({ id: workspaceID, provider, binding: null, created_at: now, last_used_at: now })
.run()
.pipe(Effect.orDie)
return new Info({ id: workspaceID, provider, binding: result.binding, createdAt: now, lastUsedAt: now })
return workspaceID
}),
provision,
connect: Effect.fn("Workspace.connect")(function* (workspaceID) {
const spawner = make((command) =>
Effect.acquireRelease(
locks.withLock(workspaceID)(
Effect.gen(function* () {
const connection = yield* open(workspaceID).pipe(
Effect.mapError((cause) =>
systemError({
_tag: "Unknown",
module: "Workspace",
method: "spawn",
description: `Failed to wake workspace ${workspaceID}`,
cause,
}),
),
)
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
yield* Ref.update(connection.active, (active) => active + 1)
return connection
}),
// A live connection implies the binding is already persisted, so skip the provision hop.
Effect.suspend(() => (connections.has(workspaceID) ? Effect.void : provision(workspaceID))).pipe(
Effect.andThen(
locks.withLock(workspaceID)(
Effect.gen(function* () {
const connection = yield* open(workspaceID)
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
yield* Ref.update(connection.active, (active) => active + 1)
return connection
}),
),
),
Effect.mapError((cause) =>
systemError({
_tag: "Unknown",
module: "Workspace",
method: "spawn",
description: `Failed to wake workspace ${workspaceID}`,
cause,
}),
),
),
(connection) =>
locks.withLock(workspaceID)(
@@ -196,14 +257,32 @@ const layer = (options: Options) =>
return { spawner }
}),
destroy: Effect.fn("Workspace.destroy")(function* (workspaceID) {
// Settling the shared attempt cancels its racing provision body and fails
// waiters with NotFound before teardown commits. Accepted tradeoffs: if the
// locked teardown below fails, those waiters saw NotFound for a workspace
// that still exists (the next provision retries it), and a provision racing
// this window may briefly succeed before teardown destroys its fresh binding.
const attempt = attempts.get(workspaceID)
if (attempt) {
attempts.delete(workspaceID)
Deferred.doneUnsafe(attempt, Exit.fail(new NotFound({ workspaceID })))
}
yield* locks.withLock(workspaceID)(
Effect.gen(function* () {
const row = yield* load(workspaceID)
const connection = connections.get(workspaceID)
connections.delete(workspaceID)
if (connection) yield* Scope.close(connection.scope, Exit.void)
const driver = yield* registry.get(row.provider)
yield* driver.destroy({ workspaceID, binding: row.binding })
// Null binding still reaches the driver: an interrupted or crashed
// provision may have created a resource that was never persisted. A
// provider missing from the registry cannot block deleting a
// never-provisioned row.
yield* registry.get(row.provider).pipe(
Effect.flatMap((driver) => driver.destroy({ workspaceID, binding: row.binding })),
Effect.catchTag("WorkspaceDriver.ProviderNotFound", (error) =>
row.binding ? Effect.fail(error) : Effect.void,
),
)
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
}),
)
@@ -216,4 +295,8 @@ export const node = configured()
// TODO(workspace-plan): add the boot janitor and ~23h safety snapshot rotation in a later PR.
// TODO(workspace-plan): make cold wake interruptible with a re-pin loop against janitor races.
// TODO(workspace-plan): consider RcMap at end-of-series consolidation; idle suspend and destroy need distinct finalizers.
// TODO(workspace-plan): consider extracting a keyed shared-attempt helper (join/cancel, drop-on-settle) beside
// KeyedMutex at end-of-series consolidation; filesystem/search.ts and session/run-coordinator.ts hand-roll the same
// shape. Audited stdlib alternatives (rc.111): RcMap fails twice (refcount release cancels in-flight work when the
// last waiter leaves, and one finalizer path cannot express idle-suspend vs destroy); Cache interrupts the shared
// lookup when its last awaiter is interrupted and cannot fail waiters with NotFound on invalidation.
+19 -1
View File
@@ -24,6 +24,16 @@ export class ProviderNotFound extends Schema.TaggedError<ProviderNotFound>()("Wo
}) {}
export interface Interface {
/**
* Get-or-create the provider resource backing this logical workspace.
*
* MUST be idempotent per `workspaceID`: core retries the same ID after
* failures and process crashes, including a crash between a successful
* create and the binding being persisted, and another process may race the
* same ID. Key the resource by `workspaceID` (a provider tag or a
* deterministic name) and adopt an existing match instead of creating a
* duplicate.
*/
readonly create: (input: {
readonly workspaceID: Workspace.ID
}) => Effect.Effect<{ readonly binding: Binding }, Error>
@@ -37,9 +47,17 @@ export interface Interface {
readonly binding: Binding
readonly saveBinding: (binding: Binding) => Effect.Effect<void>
}) => Effect.Effect<void, Error>
/**
* Release the provider resource for this workspace.
*
* `binding` is null when none was persisted: the workspace was never
* provisioned, or provisioning was interrupted mid-create. Look up any
* resource previously created for `workspaceID` and clean it up, treating
* absence as success.
*/
readonly destroy: (input: {
readonly workspaceID: Workspace.ID
readonly binding: Binding
readonly binding: Binding | null
}) => Effect.Effect<void, Error>
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { WorkspaceDriver } from "./driver.js"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
provider: text().notNull(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>(),
created_at: integer().notNull(),
last_used_at: integer().notNull(),
})
+60 -23
View File
@@ -12,8 +12,25 @@ import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Location } from "@opencode-ai/core/location"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { location } from "../fixture/location"
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
onFind(input)
if (input.onEntry)
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make(entry), type: "file" }))
return []
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
)
describe("FileSystemSearch", () => {
test("honors wildcard directory rules from .gitignore", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
@@ -50,6 +67,44 @@ describe("FileSystemSearch", () => {
}
})
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
try {
// A local file that only an fff index of the server directory could surface.
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
// true; elsewhere the layer choice already falls back to ripgrep.
await Bun.write(path.join(directory, "server-local.ts"), "server local")
let observed: Ripgrep.FindInput | undefined
const ref = Location.Ref.make({
directory: AbsolutePath.make(directory),
workspaceID: Workspace.ID.make("wrk_test"),
})
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }),
),
),
],
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer), Effect.scoped),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("bounds a home scan even when home is detected as a repository", async () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
@@ -63,29 +118,15 @@ describe("FileSystemSearch", () => {
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
observed = input
if (input.onEntry)
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
return []
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Effect.sleep("10 millis")
expect(observed).toBeUndefined()
yield* search.find({ query: "src", type: "directory" })
expect(observed?.limit).toBe(100_000)
expect(observed?.exclude).toEqual([...Protected.names()].map((name) => `${name}/**`))
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
@@ -97,7 +138,6 @@ describe("FileSystemSearch", () => {
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
let scans = 0
const initial = Effect.runSync(Deferred.make<void>())
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
@@ -127,7 +167,6 @@ describe("FileSystemSearch", () => {
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
if (scans === 1) yield* Deferred.succeed(initial, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
@@ -140,7 +179,7 @@ describe("FileSystemSearch", () => {
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(initial)
yield* search.find({ query: "old", type: "file" })
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(1)
@@ -163,7 +202,6 @@ describe("FileSystemSearch", () => {
test("reuses location-owned fuzzy targets across index refreshes", async () => {
let scans = 0
const first = Effect.runSync(Deferred.make<void>())
const second = Effect.runSync(Deferred.make<void>())
const prepare = spyOn(fuzzysort, "prepare")
const cleanup = spyOn(fuzzysort, "cleanup")
@@ -187,7 +225,7 @@ describe("FileSystemSearch", () => {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
@@ -200,7 +238,6 @@ describe("FileSystemSearch", () => {
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(first)
yield* search.find({ query: "index", type: "file" })
yield* TestClock.adjust("10 seconds")
yield* search.find({ query: "index", type: "file" })
+46 -2
View File
@@ -6,16 +6,20 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const provide = (directory: string) =>
const provide = (directory: string, workspaceID?: Workspace.ID) =>
Effect.provide(
LayerNode.compile(FileSystem.node, [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
),
],
]),
)
@@ -56,6 +60,46 @@ describe("FileSystem", () => {
),
)
it.live("skips host canonicalization for workspace locations at boot", () =>
withTmp((directory) =>
Effect.gen(function* () {
// The directory exists only inside the workspace, so boot must not
// require it to exist on the host. Operations still access the host
// filesystem per call (#44568); only boot canonicalization is skipped.
const missing = path.join(directory, "workspace-only")
const workspace = yield* FileSystem.Service.pipe(
provide(missing, Workspace.ID.make("wrk_filesystem")),
Effect.exit,
)
expect(Exit.isSuccess(workspace)).toBe(true)
// A local ref with the same missing directory keeps failing boot:
// host realpath canonicalization stays load-bearing for local placements.
const local = yield* FileSystem.Service.pipe(provide(missing), Effect.exit)
expect(Exit.isFailure(local)).toBe(true)
}),
),
)
it.live("canonicalizes local symlinked directories", () =>
withTmp((directory) =>
Effect.gen(function* () {
const real = path.join(directory, "real")
yield* Effect.promise(() => fs.mkdir(real))
yield* Effect.promise(() => fs.writeFile(path.join(real, "file.txt"), "linked"))
const link = path.join(directory, "link")
yield* Effect.promise(() => fs.symlink(real, link))
// Reads resolve through the symlink only because boot canonicalized
// the location root to the real directory.
const read = yield* FileSystem.Service.pipe(
Effect.flatMap((service) => service.read({ path: RelativePath.make("file.txt") })),
provide(link),
)
expect(new TextDecoder().decode(read.content)).toBe("linked")
}),
),
)
it.live("rejects lexical escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
+47 -5
View File
@@ -3,7 +3,20 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Deferred, Duration, Effect, Equal, Fiber, Hash, Layer, LayerMap, RcMap, Schema, Stream } from "effect"
import {
DateTime,
Deferred,
Duration,
Effect,
Equal,
Fiber,
Hash,
Layer,
LayerMap,
RcMap,
Schema,
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
@@ -23,6 +36,7 @@ import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Workspace } from "@opencode-ai/core/workspace"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
@@ -61,10 +75,9 @@ const activityLocations = Layer.effect(
),
)
const itWithActivity = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]),
[[LocationServiceMap.node, activityLocations]],
),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [
[LocationServiceMap.node, activityLocations],
]),
)
describe("LocationServiceMap", () => {
@@ -115,6 +128,35 @@ describe("LocationServiceMap", () => {
),
)
it.live("keeps a workspace location admitted when its directory does not exist locally", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = AbsolutePath.make(path.join(dir.path, "workspace-only"))
const workspaceRef = Location.Ref.make({ directory, workspaceID: Workspace.ID.make("wrk_liveness") })
const localRef = Location.Ref.make({ directory })
// The directory only exists inside the workspace, so 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 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { DateTime, Effect, Stream, Types } from "effect"
import { DateTime, Effect, Option, Stream, Types } from "effect"
import type { SessionContext } from "@opencode-ai/plugin/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Agent } from "@opencode-ai/core/agent"
@@ -242,10 +242,10 @@ describe("plan plugin reminders", () => {
})
describe("plan plugin mutations", () => {
it.effect("creates the Plan directory", () =>
it.effect("does not create the Plan directory during activation", () =>
Effect.gen(function* () {
const { files } = yield* run()
expect((yield* files.stat(planDirectory)).type).toBe("directory")
expect(Option.isNone(yield* files.stat(planDirectory).pipe(Effect.option))).toBe(true)
}),
)
+187 -7
View File
@@ -7,19 +7,21 @@ import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { WorkspaceTable } from "@opencode-ai/core/workspace/sql"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { Deferred, Effect, Fiber } from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess } from "effect/unstable/process"
import { testEffect } from "./lib/effect"
const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding }> = []
const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding | null }> = []
const memory = makeMemoryDriver()
let failConnect = false
let create: WorkspaceDriver.Interface["create"] = ({ workspaceID }) =>
Effect.succeed({ binding: { workspaceID, generation: 0 } })
const driver = WorkspaceDriver.make({
create: ({ workspaceID }) => {
create: (input) => {
calls.push({ operation: "create" })
return Effect.succeed({ binding: { workspaceID, generation: 0 } })
return create(input)
},
connect: ({ binding }) => {
calls.push({ operation: "connect", binding })
@@ -46,6 +48,18 @@ const it = testEffect(
beforeEach(() => {
calls.splice(0)
failConnect = false
create = ({ workspaceID }) => Effect.succeed({ binding: { workspaceID, generation: 0 } })
})
const gateCreate = Effect.fnUntraced(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
create = ({ workspaceID }) =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({ binding: { workspaceID, generation: 0 } }),
)
return { started, release }
})
it.effect("rejects unregistered workspace providers", () =>
@@ -60,12 +74,178 @@ it.effect("rejects unregistered workspace providers", () =>
}),
)
it.effect("creates and persists an ID without provisioning", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
expect(workspaceID.startsWith("wrk_")).toBe(true)
expect(calls).toEqual([])
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(),
).pipe(Effect.orDie),
).toMatchObject({ id: workspaceID, provider: "fake", binding: null })
}),
)
it.effect("destroys an unprovisioned workspace through the driver with a null binding", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
yield* workspace.destroy(workspaceID)
expect(calls).toEqual([{ operation: "destroy", binding: null }])
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(),
).pipe(Effect.orDie),
).toBeUndefined()
}),
)
it.effect("starts eager provisioning in the background and lets callers join it", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const eager = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(gate.started)
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
expect(calls.map((call) => call.operation)).toEqual(["create"])
yield* Deferred.succeed(gate.release, undefined)
const [eagerResult, waiterResult] = yield* Effect.all([Fiber.join(eager), Fiber.join(waiter)])
expect(eagerResult).toEqual(waiterResult)
expect(eagerResult.binding).toEqual({ workspaceID, generation: 0 })
}),
)
it.effect("starts lazy provisioning on the first spawn", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const environment = yield* workspace.connect(workspaceID)
const gate = yield* gateCreate()
expect(calls).toEqual([])
const spawned = yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("lazy"))).pipe(
Effect.forkScoped({ startImmediately: true }),
)
yield* Deferred.await(gate.started)
expect(calls.map((call) => call.operation)).toEqual(["create"])
yield* Deferred.succeed(gate.release, undefined)
yield* Fiber.await(spawned)
expect(calls.map((call) => call.operation)).toEqual(["create", "connect"])
}),
)
it.effect("shares provisioning between concurrent first spawns", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const environment = yield* workspace.connect(workspaceID)
const gate = yield* gateCreate()
const spawned = yield* Effect.all(
["first", "second"].map((command) =>
Effect.scoped(environment.spawner.spawn(ChildProcess.make(command))).pipe(
Effect.forkScoped({ startImmediately: true }),
),
),
)
yield* Deferred.await(gate.started)
yield* Effect.yieldNow
expect(calls.map((call) => call.operation)).toEqual(["create"])
yield* Deferred.succeed(gate.release, undefined)
yield* Effect.forEach(spawned, Fiber.await)
expect(calls.map((call) => call.operation)).toEqual(["create", "connect"])
}),
)
it.effect("keeps shared provisioning alive when a waiter is interrupted", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const owner = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(gate.started)
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Fiber.interrupt(waiter)
expect(calls.map((call) => call.operation)).toEqual(["create"])
yield* Deferred.succeed(gate.release, undefined)
expect((yield* Fiber.join(owner)).binding).toEqual({ workspaceID, generation: 0 })
expect(calls.map((call) => call.operation)).toEqual(["create"])
}),
)
it.effect("interrupts in-flight provisioning on destroy and fails waiters with NotFound", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(gate.started)
yield* workspace.destroy(workspaceID)
expect(yield* Fiber.join(waiter).pipe(Effect.flip)).toEqual(new Workspace.NotFound({ workspaceID }))
expect(calls.map((call) => call.operation)).toEqual(["create", "destroy"])
expect(calls.at(-1)?.binding).toBeNull()
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(),
).pipe(Effect.orDie),
).toBeUndefined()
}),
)
it.effect("shares a failed attempt and retries the same workspace ID", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create("fake")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let fail = true
create = ({ workspaceID }) =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(
Effect.suspend(() =>
fail
? Effect.fail(new WorkspaceDriver.Error({ message: "create failed" }))
: Effect.succeed({ binding: { workspaceID, generation: 0 } }),
),
),
)
const first = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
const second = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.succeed(release, undefined)
const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)])
expect(firstExit._tag).toBe("Failure")
expect(secondExit._tag).toBe("Failure")
expect(calls.map((call) => call.operation)).toEqual(["create"])
fail = false
expect((yield* workspace.provision(workspaceID)).binding).toEqual({ workspaceID, generation: 0 })
expect(calls.map((call) => call.operation)).toEqual(["create", "create"])
}),
)
it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const created = yield* workspace.create("fake")
const workspaceID = yield* workspace.create("fake")
const created = yield* workspace.provision(workspaceID)
expect(created.id.startsWith("wrk_")).toBe(true)
expect(created.id).toBe(workspaceID)
expect(created.binding).toEqual({ workspaceID: created.id, generation: 0 })
const environment = yield* workspace.connect(created.id)
@@ -97,7 +277,7 @@ it.effect("persists the workspace lifecycle and reconnects after idle suspension
it.effect("surfaces wake failures through the spawn error channel", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const created = yield* workspace.create("fake")
const created = yield* workspace.provision(yield* workspace.create("fake"))
const environment = yield* workspace.connect(created.id)
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("connect"))).pipe(Effect.exit)

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