Compare commits

..
Author SHA1 Message Date
Shoubhit Dash 44c846e1a4 refactor(core): narrow provider context history reads 2026-09-04 21:37:07 +05:30
Shoubhit Dash e6930bad7f chore(schema): regenerate provider context contracts 2026-09-04 21:37:06 +05:30
Shoubhit Dash 84fe6101ef fix(core): encode optional provider context metadata 2026-09-04 21:37:06 +05:30
Shoubhit Dash b7f88bbc78 fix(core): preserve compaction instruction epochs 2026-09-04 21:37:06 +05:30
Shoubhit Dash 008d571539 feat(core): persist provider compaction context 2026-09-04 21:37:06 +05:30
Shoubhit Dash a26a978051 fix(ai): tolerate out-of-band responses notifications (#47316) 2026-09-04 21:36:29 +05:30
opencode-agent[bot]andnexxeln 218a0dde97 fix(tui): clear stale error styling on resumed subagents (#47295)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-04 21:29:27 +05:30
usrnk1 bd54dc508f feat(desktop): add dropzone to new sessions (#47303) 2026-09-04 17:19:05 +02:00
Shoubhit Dash 0b3c45c948 feat(ai): add streamed compaction triggers (#47082) 2026-09-04 14:00:02 +00:00
Shoubhit Dash 689a354a43 fix(ai): preserve websocket replay baselines (#47291) 2026-09-04 13:46:00 +00:00
Shoubhit Dash acb462a173 fix(tui): restore agent and model selection parity (#47260) 2026-09-04 18:16:04 +05:30
opencode-agent[bot] e26cbb935e chore: update nix node_modules hashes 2026-09-04 12:21:18 +00:00
Luke Parker fa6dd9df8f chore: upgrade Bun to 1.4.1 and re-enable bytecode (#47271) 2026-09-04 22:01:12 +10:00
opencode-agent[bot]andBrendonovich b52b79d28f fix(app): align file tool disclosures with Patch (#47269)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 19:47:57 +08:00
Aarav Sareen 632b65854c fix(app): adjust sidebar and settings spacing (#47263) 2026-09-04 09:50:30 +00:00
opencode-agent[bot]andBrendonovich ed877cfebc fix(app): expose skills in slash commands (#47247)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 17:40:39 +08:00
opencode-agent[bot]andBrendonovich 3a08d95907 fix(app): preserve thinking trigger visibility invariant (#47256)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 09:34:18 +00:00
b09a74591c fix(app): omit notices from Used summary (#47245)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-04 09:13:00 +00:00
Brendan Allan e8481973ce refactor(app): make session composer ownership explicit (#47254) 2026-09-04 09:07:47 +00:00
usrnk1 211cd73f1a feat(desktop): update composer dropzone (#42312) 2026-09-04 16:45:42 +08:00
opencode-agent[bot]andBrendonovich 2375e81bd6 fix(app): match optimistic session heading to tab label (#47244)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 08:07:24 +00:00
8352addf6c fix(console): preserve usage reset boundaries (#44729)
Co-authored-by: MrMushrooooom <MrMushrooooom@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
2026-09-04 15:12:54 +08:00
Aiden Cline 32f89748af feat(ai): add tool namespaces (#46548) 2026-09-04 01:09:52 -05:00
Aiden Cline 89478b36f1 refactor(plugin): rename session request kind "session" to "primary" (#47221) 2026-09-04 00:12:18 -05:00
Aiden Cline 46458f0753 feat(core): tag session http hooks with request kind (#47214) 2026-09-03 23:51:26 -05:00
opencode-agent[bot]andrekram1-node c907d2ba27 fix(tui): preserve model release ordering in search (#47183)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 23:18:09 -05:00
210 changed files with 4949 additions and 7177 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.0
bun-version: 1.4.1
- name: Setup git committer
id: committer
@@ -113,7 +113,7 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: bun-v1.4.0
BUN_COMPILE_RELEASE: bun-v1.4.1
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
+6 -197
View File
@@ -62,7 +62,6 @@
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -359,7 +358,6 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -418,8 +416,6 @@
"electron-store": "11.0.2",
"electron-updater": "6.8.9",
"electron-window-state": "^5.0.3",
"lighthouse": "13.4.1",
"puppeteer-core": "25.9.0",
},
"devDependencies": {
"@brendonovich/vite-plugin-opencode": "0.1.3",
@@ -427,8 +423,6 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -439,7 +433,6 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"devtools-protocol": "0.0.1687809",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
@@ -612,21 +605,6 @@
"solid-js",
],
},
"packages/plugin-browser": {
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/posts": {
"name": "@opencode-ai/posts",
"dependencies": {
@@ -1087,6 +1065,7 @@
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.1",
"effect": "catalog:",
"solid-js": "catalog:",
},
@@ -1124,7 +1103,7 @@
"@tanstack/solid-virtual": "3.13.37",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13",
"@types/bun": "1.4.0",
"@types/cross-spawn": "6.0.6",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
@@ -1846,16 +1825,6 @@
"@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="],
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="],
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="],
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -2176,8 +2145,6 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -2242,8 +2209,6 @@
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw=="],
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="],
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="],
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
@@ -2254,8 +2219,6 @@
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="],
"@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="],
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="],
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="],
@@ -2528,8 +2491,6 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@paulirish/trace_engine": ["@paulirish/trace_engine@0.0.65", "", { "dependencies": { "legacy-javascript": "latest", "third-party-web": "latest" } }, "sha512-Qsm6F5C8xf6ZzQXbQc2+wcpe6sggfs/gvc/ytqSurdvYg3kyW0ECHCqE0CWBKZpqgjVfPNX9c7SCS3r2nEIRGg=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.9.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q=="],
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
@@ -2586,8 +2547,6 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
"@puppeteer/browsers": ["@puppeteer/browsers@3.2.1", "", { "dependencies": { "modern-tar": "^0.8.0", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A=="],
"@radix-ui/colors": ["@radix-ui/colors@1.0.1", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="],
@@ -2786,18 +2745,10 @@
"@sentry/feedback": ["@sentry/feedback@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0" } }, "sha512-4+zMAmn1DcbYBtFE0pWt6zo8vWoBHTuP/UhtCMTCoGB4sVYvmwRxv9Hc9OpRHSzE9kSl8beD0zKFxuoVHizKQQ=="],
"@sentry/node": ["@sentry/node@10.72.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0", "@sentry/node-core": "10.72.0", "@sentry/opentelemetry": "10.72.0", "@sentry/server-utils": "10.72.0", "import-in-the-middle": "^3.0.0" } }, "sha512-eQHQFxSX26MhG/nB+tAY4QRslnPvJse7pww/hd3zXAqNULRa5lFtjSLALcHx/KUVDCZdTPdUVZEj4nGidcHrow=="],
"@sentry/node-core": ["@sentry/node-core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0", "@sentry/opentelemetry": "10.72.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-xYuYWmWEWnN8h3YHa7mds212ANFgrxfrbmLvVg0p0wf9m6MFjLowOTmjaiK0XW20sM/veSelicre650bx8UFtQ=="],
"@sentry/opentelemetry": ["@sentry/opentelemetry@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-ZVbAM1rGU/awN7cH/jvC87WpQw0NOJx5id6dKnnefStXpP/kUnZXYhtX9gfBnofYvJWa5I5VUSeuKCLUcKL75w=="],
"@sentry/replay": ["@sentry/replay@10.71.0", "", { "dependencies": { "@sentry/browser-utils": "10.71.0", "@sentry/core": "10.71.0" } }, "sha512-ytEgVg7isvavQL6hgsgYeuSCkcA3EyOKm9lMLQOZTLkiUCtFT/ItWwGNhzS46vQ6wsTv3Uc0Y1JlcJHSFKe/Kg=="],
"@sentry/replay-canvas": ["@sentry/replay-canvas@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0", "@sentry/replay": "10.71.0" } }, "sha512-YTqesxBh9arExI49LrTm4Y2/xPX40q2GWi0gWRw6lNfYtyNz7/ZfHi8/1N6JEc8dldTslqFhII6ZFecUyU9iaA=="],
"@sentry/server-utils": ["@sentry/server-utils@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.72.0" } }, "sha512-CWpHMYW81RDBSVBdnxulGUvvBhkBb/OpSr72ubSe1UkKTcgT0NDMCWxE3dLFXqSxzT4DaF5UlUVv9ii5Sse53w=="],
"@sentry/solid": ["@sentry/solid@10.71.0", "", { "dependencies": { "@sentry/browser": "10.71.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.71.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-/vVy9GmB/RejmomWEMZ+P3xlwrhfpIYJ3F0+UcTE836uuH92r8CSnmucdGQqke8c/6ullQFjZrLd/dTDPXj1aA=="],
"@sentry/vite-plugin": ["@sentry/vite-plugin@5.4.0", "", { "dependencies": { "@sentry/bundler-plugins": "^10.64.0" } }, "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA=="],
@@ -3082,7 +3033,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
@@ -3364,8 +3315,6 @@
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -3536,7 +3485,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -3594,16 +3543,10 @@
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="],
"chromium-bidi": ["chromium-bidi@17.0.2", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="],
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="],
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
"clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
@@ -3660,8 +3603,6 @@
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
"configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
@@ -3698,8 +3639,6 @@
"crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
"csp_evaluator": ["csp_evaluator@1.1.8", "", {}, "sha512-EwOnfYuNbTytvbMKsLixTrRgnjOa0WZCxGy8A9nnSYAicrdwn+T/epU/yjgymmOxlgKnvH+8wXt+7p/8ak5Feg=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="],
@@ -3804,8 +3743,6 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
@@ -3854,8 +3791,6 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"devtools-protocol": ["devtools-protocol@0.0.1687809", "", {}, "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g=="],
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
"diacritics": ["diacritics@1.3.0", "", {}, "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="],
@@ -3960,8 +3895,6 @@
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -4006,7 +3939,7 @@
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
@@ -4308,8 +4241,6 @@
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
@@ -4334,12 +4265,8 @@
"ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
"image-ssim": ["image-ssim@0.2.0", "", {}, "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg=="],
"immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"import-in-the-middle": ["import-in-the-middle@3.3.3", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA=="],
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
@@ -4358,8 +4285,6 @@
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
"ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -4478,16 +4403,12 @@
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
"js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "js-beautify": "js/bin/js-beautify.js", "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="],
"js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="],
"js-library-detector": ["js-library-detector@6.7.0", "", {}, "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="],
@@ -4560,16 +4481,8 @@
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"legacy-javascript": ["legacy-javascript@0.0.1", "", {}, "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
"lighthouse": ["lighthouse@13.4.1", "", { "dependencies": { "@paulirish/trace_engine": "0.0.65", "@sentry/node": "^10.0.0", "axe-core": "^4.12.1", "chrome-launcher": "^1.2.1", "configstore": "^7.0.0", "csp_evaluator": "1.1.8", "devtools-protocol": "0.0.1663043", "enquirer": "^2.3.6", "http-link-header": "^1.1.1", "intl-messageformat": "^10.5.3", "jpeg-js": "^0.4.4", "js-library-detector": "^6.7.0", "lighthouse-logger": "^2.0.2", "lighthouse-stack-packs": "1.12.3", "lodash-es": "^4.17.21", "lookup-closest-locale": "6.2.0", "open": "^8.4.0", "puppeteer-core": "^25.3.0", "robots-parser": "^3.0.1", "speedline-core": "^1.4.3", "third-party-web": "^0.29.2", "tldts-icann": "^7.4.9", "web-features": "^3.34.0", "ws": "^7.0.0", "yargs": "^17.3.1", "yargs-parser": "^21.0.0" }, "bin": { "lighthouse": "cli/index.js", "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js", "chrome-debug": "core/scripts/manual-chrome-launcher.js" } }, "sha512-fDu8lt3QLK/lTqIxtp1HkzQNJ32rsFHhbadYOepcMZFLgA8oINhxutMbMv8XXnpTOvZ0TXCo4JCk1LDTWaRLnA=="],
"lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="],
"lighthouse-stack-packs": ["lighthouse-stack-packs@1.12.3", "", {}, "sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
@@ -4616,8 +4529,6 @@
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lookup-closest-locale": ["lookup-closest-locale@6.2.0", "", {}, "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
@@ -4650,8 +4561,6 @@
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -4812,14 +4721,8 @@
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"modern-tar": ["modern-tar@0.8.4", "", {}, "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g=="],
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
@@ -5130,8 +5033,6 @@
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
"puppeteer-core": ["puppeteer-core@25.9.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.3" } }, "sha512-U61rCwSMha62CA/Opy6tCx2Fx+ck7ouiKnbpEApzSoLYMoEu9F71nuFpHL55vmIt33/GYm6eKZVhH2ev0nAIeg=="],
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
@@ -5258,8 +5159,6 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
@@ -5294,8 +5193,6 @@
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="],
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="],
@@ -5454,8 +5351,6 @@
"spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="],
"speedline-core": ["speedline-core@1.4.3", "", { "dependencies": { "@types/node": "*", "image-ssim": "^0.2.0", "jpeg-js": "^0.4.1" } }, "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
@@ -5590,8 +5485,6 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"third-party-web": ["third-party-web@0.29.2", "", {}, "sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g=="],
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
@@ -5614,10 +5507,6 @@
"titleize": ["titleize@4.0.0", "", {}, "sha512-ZgUJ1K83rhdu7uh7EHAC2BgY5DzoX8V5rTvoWI4vFysggi6YjLe5gUXABPWAU7VkvGP7P/0YiWq+dcPeYDsf1g=="],
"tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="],
"tldts-icann": ["tldts-icann@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" } }, "sha512-1p+NDJ7FUYCliESmsQl9EW5Um8JIyFheiy6Y6ZoHho27d+TLAGFa0gMf1VOVy02vNY1aQo6xeKiaGj45+7P+PA=="],
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
@@ -5680,8 +5569,6 @@
"typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
"typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="],
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
@@ -5842,8 +5729,6 @@
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"web-features": ["web-features@3.36.0", "", {}, "sha512-B5W9HbyXT76soRG7Fn3AELZkuDQpmpSxszSmJZLIPUHYxIGkufNz0vcoEhfqnUF1HAfaducWIBaaKuusq9Am2g=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
@@ -5852,8 +5737,6 @@
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
@@ -5930,8 +5813,6 @@
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
@@ -6404,12 +6285,6 @@
"@opencode-ai/www/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="],
"@opentelemetry/sdk-trace/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
"@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="],
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
@@ -6442,8 +6317,6 @@
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
"@puppeteer/browsers/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@sentry/bundler-plugins/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
@@ -6454,18 +6327,6 @@
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"@sentry/node/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@sentry/node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="],
"@sentry/node/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="],
"@sentry/node-core/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="],
"@sentry/opentelemetry/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="],
"@sentry/server-utils/@sentry/core": ["@sentry/core@10.72.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA=="],
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
@@ -6480,10 +6341,6 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "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-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
@@ -6574,10 +6431,6 @@
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6596,8 +6449,6 @@
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
@@ -6642,8 +6493,6 @@
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"esast-util-from-js/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"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=="],
@@ -6688,8 +6537,6 @@
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
@@ -6698,16 +6545,10 @@
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="],
"lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
"matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
@@ -6772,10 +6613,6 @@
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1666840", "", {}, "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg=="],
"puppeteer-core/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -6844,8 +6681,6 @@
"unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
@@ -7084,8 +6919,6 @@
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
@@ -7364,8 +7197,6 @@
"@opencode-ai/www/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
@@ -7374,20 +7205,10 @@
"@pierre/trees/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"@puppeteer/browsers/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="],
"@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="],
"@solidjs/start/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="],
"@solidjs/start/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="],
@@ -7556,8 +7377,6 @@
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -7594,8 +7413,6 @@
"electron/@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
@@ -7618,12 +7435,6 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
@@ -8368,8 +8179,6 @@
"@opencode-ai/www/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@puppeteer/browsers/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
"x86_64-linux": "sha256-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
}
}
+3 -2
View File
@@ -5,7 +5,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",
"packageManager": "bun@1.4.1",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
@@ -43,7 +43,7 @@
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/bun": "1.4.0",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
@@ -159,6 +159,7 @@
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.1",
"effect": "catalog:",
"solid-js": "catalog:"
},
+38 -1
View File
@@ -247,6 +247,8 @@ it does not repair or truncate them.
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
`testLayer`.
@@ -259,7 +261,7 @@ This is different from prompt caching, server-side history storage, or truncatio
### Explicit compaction
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor.
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
@@ -279,6 +281,41 @@ Generation-only body overlays such as `stream` and `store` are not sent to the c
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
### Streamed checkpoint compaction
OpenAI Responses also exposes a separate, explicitly selected mechanism:
```ts
const result =
yield *
LLMClient.compact(request, {
mechanism: "trigger",
webSocket, // Optional: without it, the request uses HTTP/SSE.
})
result.checkpoint // Successful encrypted CompactionPart.
result.responseID
result.usage
```
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
The result is **not a replacement window**. The caller selects retained history, combines it with `result.checkpoint`, and durably installs it before continuing. The operation does not choose a retention budget, prune messages, or modify the original request.
The supplied WebSocket executor can reuse a compatible append baseline for the compaction request. On completion the protocol supplies no continuation checkpoint, clearing the old baseline so the next generation sends the newly installed window in full. Validation occurs before transport completion is acknowledged. There is no operation-level retry or fallback to `/responses/compact`; existing safe transport fallback may use SSE, with full history and no connection-local response ID.
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
```ts
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
}
```
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
### Advanced: in-band compaction
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
+17 -11
View File
@@ -11,7 +11,7 @@
// Manual `cache: CacheHint` placements on individual parts are preserved and
// count against the four-breakpoint budget; auto only fills remaining slots.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages.js"
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"
const AUTO: CachePolicyObject = {
tools: true,
@@ -50,18 +50,24 @@ interface Budget {
remaining: number
}
const markLastTool = (
tools: ReadonlyArray<ToolDefinition>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<ToolDefinition> => {
if (tools.length === 0) return tools
const last = tools.length - 1
if (tools[last]!.cache || budget.remaining === 0) return tools
const markLastTool = (tools: ReadonlyArray<ToolEntry>, hint: CacheHint, budget: Budget): ReadonlyArray<ToolEntry> => {
const target = tools.at(-1)
if (target === undefined) return tools
if (target.type === "namespace") {
const nested = markLastTool(target.tools, hint, budget)
return nested === target.tools ? tools : [...tools.slice(0, -1), { ...target, tools: nested }]
}
if (target.cache || budget.remaining === 0) return tools
budget.remaining -= 1
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
return [...tools.slice(0, -1), new ToolDefinition({ ...target, cache: hint })]
}
const countToolHints = (tools: ReadonlyArray<ToolEntry>): number =>
tools.reduce(
(count, tool) => count + (tool.type === "tool" ? (tool.cache === undefined ? 0 : 1) : countToolHints(tool.tools)),
0,
)
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
if (system.length === 0) return system
let changed = false
@@ -122,7 +128,7 @@ const markMessages = (
}
const countHints = (request: LLMRequest) =>
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
countToolHints(request.tools) +
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
request.messages.reduce(
(count, message) =>
+4 -3
View File
@@ -12,9 +12,10 @@ import {
LanguageModel,
SystemPart,
ToolChoice,
ToolDefinition,
ToolEntry,
type ContentPart,
type LanguageModelProviderOptions,
type ToolEntryInput,
} from "./schema/index.js"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool.js"
@@ -27,7 +28,7 @@ export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageM
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input>
readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly tools?: ReadonlyArray<ToolEntryInput>
readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: NoInfer<LanguageModelProviderOptions<SelectedLanguageModel>>
@@ -56,7 +57,7 @@ export const request = <const SelectedLanguageModel extends LanguageModel>(
...rest,
system: SystemPart.content(requestSystem),
messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])],
tools: tools?.map(ToolDefinition.make) ?? [],
tools: tools?.map(ToolEntry.make) ?? [],
toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined,
generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration),
providerOptions: requestProviderOptions,
@@ -1067,10 +1067,11 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const flattened = ProviderShared.flattenToolRequest(request)
const tools =
request.tools.length === 0
flattened.tools.length === 0
? undefined
: request.tools.map((tool) =>
: flattened.tools.map((tool) =>
lowerTool(
breakpoints,
tool,
@@ -1088,7 +1089,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
}))
const messages = yield* lowerMessages(request, breakpoints)
const messages = yield* lowerMessages(flattened.request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
@@ -415,7 +415,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
@@ -424,21 +427,22 @@ const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArra
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const flattened = ProviderShared.flattenToolRequest(request)
const generation = request.generation
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const toolConfig = (() => {
if (request.tools.length === 0) return undefined
if (flattened.tools.length === 0) return undefined
return {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, flattened.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
})()
const system = lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)
const messages = yield* lowerMessages(flattened.request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
+4 -3
View File
@@ -465,7 +465,8 @@ function mapSafetySettings(value: unknown) {
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0
const flattened = ProviderShared.flattenToolRequest(request)
const hasTools = flattened.tools.length > 0
const generation = request.generation
const options = resolveOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
@@ -483,7 +484,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
return {
cachedContent: options.cachedContent,
contents: yield* lowerMessages(request),
contents: yield* lowerMessages(flattened.request),
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
@@ -491,7 +492,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
tools: hasTools
? [
{
functionDeclarations: request.tools.map((tool) =>
functionDeclarations: flattened.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
},
+3 -2
View File
@@ -414,10 +414,11 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
tool: (name) => ({ type: "function" as const, function: { name } }),
})
: undefined
const flattened = ProviderShared.flattenToolRequest(request)
return {
model: request.model.id,
messages: yield* lowerMessages(request),
tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined,
messages: yield* lowerMessages(flattened.request),
tools: flattened.tools.length > 0 ? flattened.tools.map(lowerTool) : undefined,
tool_choice: toolChoice,
stream: true as const,
max_tokens: request.generation?.maxTokens,
@@ -113,8 +113,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 }
// Keepalives and provider notifications carry no response state and may precede response.created.
if (!event.type.startsWith("response.")) return { type: "frame", frame }
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
@@ -42,6 +42,7 @@ const canonical = (value: unknown): string => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
return `{${Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort()
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
.join(",")}}`
@@ -57,7 +58,12 @@ const comparable = (value: unknown) => {
if (value.type === "message" && value.role === "assistant")
return {
role: "assistant",
content: value.content,
// Annotations and logprobs describe the response, not the text replayed in model input.
content: Array.isArray(value.content)
? value.content.map((part) =>
ProviderShared.isRecord(part) && part.type === "output_text" ? { type: part.type, text: part.text } : part,
)
: value.content,
...(value.phase === undefined ? {} : { phase: value.phase }),
}
if (value.type === "function_call")
@@ -121,7 +127,7 @@ const rejected = (
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
let output: unknown[] = []
let output: OpenResponses.StreamItem[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
@@ -149,6 +155,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
}
if (observation.type !== "completed") return observation
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
if (
Array.isArray(request.input) &&
request.input.some((item) => ProviderShared.isRecord(item) && item.type === "compaction_trigger")
)
return observation
const responseID = event.response?.id
if (!responseID || responseID.trim().length === 0) return observation
return {
@@ -159,7 +171,14 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
version: VERSION,
responseID,
request,
output: event.response?.output ? [...event.response.output] : output.slice(),
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
output: event.response?.output
? event.response.output.map((item) =>
item.type === "reasoning" && item.id !== undefined
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
: item,
)
: output.slice(),
} satisfies CheckpointValue,
},
}
+39 -9
View File
@@ -189,6 +189,7 @@ export const InputItem = Schema.Union([
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
namespace: Schema.optional(Schema.String),
arguments: Schema.String,
}),
Schema.Struct({
@@ -315,6 +316,7 @@ export const StreamItem = Schema.StructWithRest(
id: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
namespace: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
}),
@@ -488,6 +490,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
...(id === undefined ? {} : { id }),
call_id: part.id,
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
}
}
@@ -807,14 +810,15 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
request: LLMRequest,
adapter: ProviderAdapter,
) {
const projected = ProviderShared.flattenToolRequest(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
...(yield* lowerConversation(request, adapter)),
...(yield* lowerConversation(projected.request, adapter)),
...lowerGeneration(request),
tools:
request.tools.length === 0
projected.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
: yield* Effect.forEach(projected.tools, (tool) =>
lowerTool(
adapter.name,
tool,
@@ -919,7 +923,7 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
const outputItemID = (state: ParserState, event: Event) =>
const outputItemID = (state: Pick<ParserState, "outputItems">, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
@@ -931,7 +935,11 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
const resolveItem = (
state: Pick<ParserState, "outputItems">,
item: StreamItem,
index: number | undefined,
): OutputItem => ({
...item,
id:
item.id ??
@@ -941,7 +949,7 @@ const resolveItem = (state: ParserState, item: StreamItem, index: number | undef
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
export const normalize = (state: Pick<ParserState, "outputItems">, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
@@ -1094,11 +1102,20 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name ?? "",
namespace: item.namespace,
input: item.arguments ?? "",
providerMetadata: metadata,
}),
},
[...events, LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", providerMetadata: metadata })],
[
...events,
LLMEvent.toolInputStart({
id: item.call_id,
name: item.name ?? "",
namespace: item.namespace,
providerMetadata: metadata,
}),
],
]
}
@@ -1217,7 +1234,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
namespace: item.namespace,
providerMetadata: metadata,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -1228,7 +1250,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const resultEvents =
registered || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
: [
LLMEvent.toolInputStart({
id: item.call_id,
name: item.name,
namespace: item.namespace,
providerMetadata: metadata,
}),
...finished,
]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
+5 -4
View File
@@ -736,6 +736,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const flattened = ProviderShared.flattenToolRequest(request)
const provider = String(request.model.provider)
const baseURL = request.model.route.endpoint.baseURL
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
@@ -748,16 +749,16 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
const zaiToolStream =
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = request.tools.length > 0
const hasActiveTools = flattened.tools.length > 0
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
messages: yield* lowerMessages(flattened.request, options),
tools:
request.tools.length === 0
flattened.tools.length === 0
? hasHistory
? []
: undefined
: request.tools.map((tool) =>
: flattened.tools.map((tool) =>
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
+79 -8
View File
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
@@ -13,6 +13,7 @@ import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -75,7 +76,18 @@ const OpenAIResponsesHostedToolItem = Schema.Union([
),
])
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
const OpenAIResponsesNamespace = Schema.Struct({
type: Schema.tag("namespace"),
name: Schema.String,
description: Schema.String,
tools: Schema.Array(OpenResponses.Tool),
})
const OpenAIResponsesTools = Schema.Union([
OpenResponses.Tool,
OpenAIResponsesNamespace,
OpenAIResponsesImageGenerationTool,
])
const OpenAIResponsesToolChoice = Schema.Union([
OpenResponses.ToolChoice,
@@ -103,6 +115,18 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
/** Request control, never conversation content. */
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
),
})
const adapter = {
id: ADAPTER,
name: NAME,
@@ -128,13 +152,33 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe
return yield* OpenResponses.lowerTool(NAME, tool, inputSchema)
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
// Native namespaces hold only function tools, so deeper levels flatten into
// the leaf names the same way non-native protocols flatten the whole tree.
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (
tool: ToolEntry,
compatibility: Parameters<typeof ToolSchemaProjection.modelCompatibility>[1],
) {
if (tool.type === "tool")
return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))
// OpenAI requires a namespace description; fall back to a generic one so a
// missing description never blocks the request.
return {
type: "namespace" as const,
name: tool.name,
description: tool.description ?? `Tools in the ${tool.name} namespace.`,
tools: yield* Effect.forEach(ProviderShared.flattenTools(tool.tools), (leaf) =>
OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, compatibility)),
),
}
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolEntry>) =>
ProviderShared.matchToolChoice(NAME, toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "required" as const,
tool: (name) =>
tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined)
tools.some((tool) => tool.type === "tool" && tool.name === name && nativeImageTool(tool) !== undefined)
? ({ type: "image_generation" } as const)
: { type: "function" as const, name },
})
@@ -153,15 +197,42 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)),
tool_choice:
OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
})
})
const checkpointBody = {
schema: CheckpointBody,
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
const overlay = request.http?.body
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
if (
overlay?.input !== undefined ||
overlay?.previous_response_id !== undefined ||
overlay?.conversation !== undefined
)
return yield* ProviderShared.invalidRequest(
"Trigger compaction requires complete canonical history, not an input or continuation override",
)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
...mergeJsonRecords(native, overlay),
input: [...native.input, { type: "compaction_trigger" }],
stream: true,
store: false,
parallel_tool_calls: true,
tool_choice: undefined,
context_management: undefined,
text: undefined,
max_output_tokens: undefined,
max_tool_calls: undefined,
})
}),
}
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) {
@@ -239,7 +310,7 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: ResponsesCompaction.make(adapter),
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
+37 -1
View File
@@ -9,11 +9,14 @@ import {
UnsupportedOperationError,
AIError,
HttpContext,
LLMRequest,
Message,
ToolDefinition,
type ContentPart,
type LLMRequest,
type MediaPart,
type ProviderID,
type TextPart,
type ToolEntry,
type ToolResultPart,
} from "../schema/index.js"
import { isRecord } from "../utils/record.js"
@@ -46,6 +49,7 @@ export const promptCacheKey = (request: LLMRequest): string | undefined => {
export interface ToolAccumulator {
readonly id: string
readonly name: string
readonly namespace?: string
readonly input: string
}
@@ -279,6 +283,38 @@ export const unsupportedOperation = (input: {
}),
})
/**
* Lower namespaces to flat definitions for protocols without a native
* namespace construct. Leaf names join their namespace path with `_` because
* `.` is not broadly accepted in provider tool names.
*/
export const flattenTools = (tools: ReadonlyArray<ToolEntry>, path: ReadonlyArray<string> = []) => {
const flat = tools.flatMap((tool): ReadonlyArray<ToolDefinition> => {
if (tool.type === "namespace") return flattenTools(tool.tools, [...path, tool.name])
if (path.length === 0) return [tool]
return [new ToolDefinition({ ...tool, name: [...path, tool.name].join("_") })]
})
return [...new Map(flat.map((tool) => [tool.name, tool])).values()]
}
export const flattenToolRequest = (request: LLMRequest) => {
const messages = request.messages.map((message) => {
const content = message.content.map((part) => {
if ((part.type !== "tool-call" && part.type !== "tool-result") || part.namespace === undefined) return part
return { ...part, name: `${part.namespace}_${part.name}`, namespace: undefined }
})
return content.every((part, index) => part === message.content[index])
? message
: new Message({ ...message, content })
})
return {
tools: flattenTools(request.tools),
request: messages.every((message, index) => message === request.messages[index])
? request
: LLMRequest.update(request, { messages }),
}
}
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
route: string,
name: string,
@@ -0,0 +1,122 @@
import { Effect, Schema, Stream } from "effect"
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
import { Protocol } from "../../route/protocol.js"
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
import { OpenResponses } from "../open-responses.js"
import { ProviderShared } from "../shared.js"
interface State {
readonly parser: Pick<OpenResponses.ParserState, "id" | "provider" | "outputItems">
readonly checkpoints: Readonly<Record<string, CompactionCheckpointResponse["checkpoint"]>>
readonly responseID?: string
}
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
state: State,
input: OpenResponses.Event,
) {
const event = OpenResponses.normalize(state.parser, input)
const item = event.item
if (!item) return state
const parser =
event.output_index === undefined || state.parser.outputItems[event.output_index] === item.id
? state.parser
: { ...state.parser, outputItems: { ...state.parser.outputItems, [event.output_index]: item.id } }
const next = parser === state.parser ? state : { ...state, parser }
if (event.type === "response.output_item.added" || item.type !== "compaction") return next
if (
event.output_index !== undefined &&
Object.entries(state.parser.outputItems).some(
([index, id]) => id === item.id && Number(index) !== event.output_index,
)
)
return yield* ProviderShared.eventError(parser.id, "Compaction checkpoint appeared in multiple output slots")
if (!item.encrypted_content)
return yield* ProviderShared.eventError(parser.id, "Compaction output is missing its encrypted content")
const previous = state.checkpoints[item.id]
if (previous && previous.encrypted !== item.encrypted_content)
return yield* ProviderShared.eventError(parser.id, "Compaction output changed after completion")
if (previous) return next
return {
...next,
checkpoints: {
...state.checkpoints,
[item.id]: { type: "compaction", provider: parser.provider, id: item.id, encrypted: item.encrypted_content },
},
} satisfies State
})
/** Collect a trigger response before acknowledging transport completion. No generation output escapes. */
export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
Effect.fn("ResponsesCheckpoint.execute")(function* (request, executor, options) {
const source = request.model.route
let result: CompactionCheckpointResponse | undefined
// Route registries erase the frame type. The codec validates that boundary before parsing.
const event: Schema.Codec<OpenResponses.Event, unknown> = OpenResponses.protocol.stream.event
const protocol = Protocol.make({
id: source.protocol,
body,
stream: {
event,
initial: (request: LLMRequest): State => ({
parser: { id: source.id, provider: request.model.provider, outputItems: {} },
checkpoints: {},
}),
terminal: OpenResponses.terminal,
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
if (event.type === "error" || event.type === "response.failed")
return yield* OpenResponses.providerFailure(event, "Compaction request failed")
if (event.type === "response.incomplete")
return yield* ProviderShared.eventError(source.id, "Compaction response was incomplete")
if (event.type === "response.output_item.added" || event.type === "response.output_item.done")
return [yield* onOutputItem(state, event), []] as const
if (event.type !== "response.completed") return [state, []] as const
const responseID = event.response?.id
if (!responseID?.trim())
return yield* ProviderShared.eventError(source.id, "Compaction response is missing its response ID")
if (event.response?.status !== undefined && event.response.status !== "completed")
return yield* ProviderShared.eventError(source.id, "Compaction response did not complete successfully")
let next = state
for (const [index, item] of (event.response?.output ?? []).entries()) {
next = yield* onOutputItem(next, { type: "response.output_item.done", output_index: index, item })
}
const checkpoints = Object.values(next.checkpoints)
const checkpoint = checkpoints[0]
if (checkpoints.length !== 1 || !checkpoint)
return yield* ProviderShared.eventError(
source.id,
"Compaction response must contain exactly one checkpoint",
)
result = new CompactionCheckpointResponse({
checkpoint,
responseID,
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
})
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
}),
},
})
const route = Route.make({
id: source.id,
provider: source.provider,
providerMetadataKey: source.providerMetadataKey,
protocol,
endpoint: source.endpoint,
auth: source.auth,
transport: source.transport,
})
const native = yield* body.from(request)
// The body builder already applied and validated overlays. Do not let transport reapply them.
const preparedRequest = LLMRequest.update(request, {
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
})
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
return result
})
export * as ResponsesCheckpoint from "./responses-checkpoint.js"
+10 -1
View File
@@ -55,6 +55,7 @@ const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
})
@@ -63,6 +64,7 @@ const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
text,
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
})
@@ -85,6 +87,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
@@ -94,7 +97,12 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
}
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
LLMEvent.toolInputEnd({
id: tool.id,
name: tool.name,
namespace: tool.namespace,
providerMetadata: tool.providerMetadata,
}),
event,
]
@@ -150,6 +158,7 @@ export const appendOrStart = <K extends StreamKey>(
const tool = {
id,
name,
namespace: current?.namespace,
input: `${current?.input ?? ""}${delta.text}`,
providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata,
+2 -2
View File
@@ -1,5 +1,5 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactOperation } from "./route/client.js"
import type { CompactionOperations } from "./route/client.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@@ -10,7 +10,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
}
+4 -3
View File
@@ -1,7 +1,7 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -39,6 +39,7 @@ export type Settings = ProviderPackage.Settings &
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
const responsesRoute = OpenAIResponses.route.with({
compact: { endpoint: OpenAIResponses.route.compact.endpoint },
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
@@ -102,7 +103,7 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
@@ -168,7 +169,7 @@ const config = (settings: Settings): Config => {
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
CompactOperation
typeof responsesRoute.compact
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
+7 -6
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -73,7 +73,7 @@ const defaults = (input: Config) => {
return rest
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
@@ -132,10 +132,11 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
typeof OpenAIResponses.route.compact
>["model"] = (modelID, settings) => {
return configure(config(settings)).responses(modelID)
}
+7 -6
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
@@ -32,7 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: XAIResponses.compact,
compact: { endpoint: XAIResponses.compact },
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
@@ -103,10 +103,11 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<
Settings,
XAIProviderOptionsInput,
typeof responsesRoute.compact
>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+121 -42
View File
@@ -14,6 +14,7 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
CompactionCheckpointResponse,
AIErrorReason,
GenerationOptions,
HttpOptions,
@@ -38,7 +39,7 @@ export interface RouteBody<Body> {
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
readonly compact: Compact
readonly id: string
@@ -53,7 +54,15 @@ export interface Route<
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
): Route<Body, Prepared, Next>
(
patch: Omit<RoutePatch<Body, Prepared>, "compact"> & { readonly compact?: undefined },
): Route<Body, Prepared, Compact>
(patch: RoutePatch<Body, Prepared>): Route<Body, Prepared>
}
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options, Compact>
@@ -74,7 +83,7 @@ export interface Route<
// Normal call sites use `OpenAIChat.route`; callers only need body types
// when preparing a request with a protocol-specific type assertion.
// oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
export type AnyRoute<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Route<
any,
any,
Compact
@@ -101,6 +110,7 @@ export interface RouteDefaultsInput {
}
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly compact?: CompactionOperations
readonly id?: string
readonly provider?: string | ProviderID
readonly providerMetadataKey?: string
@@ -111,7 +121,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
route: AnyRoute<Compact>,
mapped: RouteMappedLanguageModelInput,
) => {
@@ -162,10 +172,7 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
readonly compact: (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly compact: CompactMethod
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -189,12 +196,64 @@ export type CompactOperation = (
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
export type CompactionRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, CompactOperation>
export type TriggerCompactOperation = (
request: LLMRequest,
executor: RequestExecutor.Interface,
options: TriggerCompactOptions,
) => Effect.Effect<CompactionCheckpointResponse, AIError>
/** Protocol capabilities, not deployment/model eligibility. */
export interface CompactionOperations {
readonly endpoint?: CompactOperation
readonly trigger?: TriggerCompactOperation
}
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
request.model.route.compact !== undefined
export interface EndpointCompactOptions extends Pick<StreamOptions, "http"> {
readonly mechanism?: "endpoint"
readonly webSocket?: never
}
export interface TriggerCompactOptions extends StreamOptions {
readonly mechanism: "trigger"
}
// Keep the required route shape explicit: the schema class's self type erases its model parameter in assignability.
export type CompactionRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, { readonly endpoint: CompactOperation }>
}
export type CheckpointRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, { readonly trigger: TriggerCompactOperation }>
}
export interface CompactMethod<R = never> {
(request: CheckpointRequest, options: TriggerCompactOptions): Effect.Effect<CompactionCheckpointResponse, AIError, R>
(request: CompactionRequest, options?: EndpointCompactOptions): Effect.Effect<CompactionResponse, AIError, R>
}
export function canCompact(
request: LLMRequest,
options?: { readonly mechanism?: "endpoint" },
): request is CompactionRequest
export function canCompact(
request: LLMRequest,
options: { readonly mechanism: "trigger" },
): request is CheckpointRequest
export function canCompact(request: LLMRequest, options?: { readonly mechanism?: string }) {
if (options?.mechanism === "trigger") return request.model.route.compact?.trigger !== undefined
if (options?.mechanism !== undefined && options.mechanism !== "endpoint") return false
return request.model.route.compact?.endpoint !== undefined
}
const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefined) => {
if (mechanism !== undefined && mechanism !== "endpoint" && mechanism !== "trigger")
return ProviderShared.invalidRequest(`Unknown compaction mechanism: ${mechanism}`)
return ProviderShared.unsupportedOperation({
operation: mechanism === "trigger" ? "compact.trigger" : "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support ${mechanism === "trigger" ? "trigger" : "explicit"} compaction`,
})
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -216,7 +275,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
}
export interface MakeInput<Body, Frame, Event, State> {
readonly compact?: CompactOperation
readonly compact?: CompactionOperations
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -238,7 +297,7 @@ export interface MakeInput<Body, Frame, Event, State> {
}
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
readonly compact?: CompactOperation
readonly compact?: CompactionOperations
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -326,9 +385,10 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
...routeInput,
compact: "compact" in patch ? compact : routeInput.compact,
id: id ?? routeInput.id,
provider: provider ?? routeInput.provider,
providerMetadataKey:
@@ -343,7 +403,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@@ -440,12 +500,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
}
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, Prepared, CompactOperation>
export function make<Body, Frame, Event, State>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
export function make<Body, Prepared, Frame, Event, State, Compact extends CompactionOperations>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: Compact },
): Route<Body, Prepared, Compact>
export function make<Body, Frame, Event, State, Compact extends CompactionOperations>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: Compact },
): Route<Body, HttpTransport.HttpPrepared<Frame>, Compact>
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
@@ -487,10 +547,14 @@ export function make<Body, Prepared, Frame, Event, State>(
}
const prepareRequest = (request: LLMRequest) => {
const original = applyCachePolicy(resolveRequestOptions(request))
const original = resolveRequestOptions(request)
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
// Deduplicate per sibling level; a tool and a namespace may share a name.
const dedupe = (tools: LLMRequest["tools"]): LLMRequest["tools"] =>
[...new Map(tools.map((tool) => [`${tool.type}:${tool.name}`, tool])).values()].map((tool) =>
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
)
const resolved = applyCachePolicy(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) }))
const headers = resolved.model.route.headers?.({ request: resolved })
return headers === undefined
? resolved
@@ -557,14 +621,23 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
})
}
export const compact = (
export function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
export function compact(
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
): Effect.Effect<CompactionResponse, AIError, Service> =>
Effect.gen(function* () {
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError, Service>
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.gen(function* () {
const client = yield* Service
return yield* client.compact(request, options)
if (options?.mechanism === "trigger" && canCompact(request, options)) return yield* client.compact(request, options)
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
return yield* client.compact(request, options)
return yield* unsupportedCompaction(request, options?.mechanism)
})
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
@@ -578,21 +651,27 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const stream = streamRequestWith({ http: executor })
function compact(
request: CompactionRequest,
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError>
function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError>
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
if (options?.mechanism === "trigger" && canCompact(request, options))
return request.model.route.compact.trigger(prepareRequest(request), executor, options)
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
return request.model.route.compact.endpoint(prepareRequest(request), executor, options)
return unsupportedCompaction(request, options?.mechanism)
})
}
return Service.of({
stream,
generate: generateWith(stream),
compact: (request, options) =>
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.unsupportedOperation({
operation: "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
})
return operation(prepareRequest(request), executor, options)
}),
compact,
})
}),
)
+6
View File
@@ -9,6 +9,12 @@ export type {
Interface as LLMClientShape,
Service as LLMClientService,
StreamOptions,
CompactMethod,
CompactionOperations,
CompactionRequest,
CheckpointRequest,
EndpointCompactOptions,
TriggerCompactOptions,
} from "./client.js"
export * from "./executor.js"
export { Auth } from "./auth.js"
+34 -3
View File
@@ -97,6 +97,21 @@ export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.Co
usage: Schema.optional(Usage),
}) {}
/** A checkpoint only; retained history and replacement-window construction belong to the caller. */
export class CompactionCheckpointResponse extends Schema.Class<CompactionCheckpointResponse>(
"LLM.CompactionCheckpointResponse",
)({
checkpoint: CompactionPart.pipe(
Schema.refine(
(part): part is CompactionPart & { readonly encrypted: string; readonly text?: never } =>
part.encrypted !== undefined && part.encrypted.length > 0,
{ message: "A checkpoint response requires encrypted compaction content" },
),
),
responseID: Schema.String.check(Schema.isPattern(/\S/)),
usage: Schema.optional(Usage),
}) {}
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
index: Schema.Number,
@@ -155,6 +170,7 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
@@ -164,6 +180,7 @@ export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
text: Schema.String,
/** Best-effort parse of all input fragments received through this delta. */
input: Schema.optional(Schema.Unknown),
@@ -174,6 +191,7 @@ export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
@@ -183,6 +201,7 @@ export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
raw: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputError" })
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
@@ -191,6 +210,7 @@ export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -201,6 +221,7 @@ export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
result: ToolResultValue,
output: Schema.optional(ToolOutput),
providerExecuted: Schema.optional(Schema.Boolean),
@@ -212,6 +233,7 @@ export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"),
id: ToolCallID,
name: Schema.String,
namespace: Schema.optional(Schema.String),
message: Schema.String,
error: Schema.optional(Schema.Defect()),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -385,6 +407,7 @@ interface ContentAssembly {
interface ToolInputAssembly {
readonly name: string
readonly namespace?: string
readonly text: string
readonly providerMetadata?: ProviderMetadata
}
@@ -522,12 +545,17 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
...state,
toolInputs: {
...state.toolInputs,
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
[event.id]: {
name: event.name,
namespace: event.namespace,
text: "",
providerMetadata: event.providerMetadata,
},
},
})
const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => {
const current = state.toolInputs[event.id] ?? { name: event.name, text: "" }
const current = state.toolInputs[event.id] ?? { name: event.name, namespace: event.namespace, text: "" }
return {
...state,
toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } },
@@ -535,7 +563,7 @@ const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): Resp
}
const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => {
const current = state.toolInputs[event.id] ?? { name: event.name, text: "" }
const current = state.toolInputs[event.id] ?? { name: event.name, namespace: event.namespace, text: "" }
return {
...state,
toolInputs: {
@@ -543,6 +571,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
[event.id]: {
...current,
name: event.name,
namespace: event.namespace,
providerMetadata: event.providerMetadata ?? current.providerMetadata,
},
},
@@ -553,6 +582,7 @@ const toolCallContent = (event: ToolCall): ContentPart =>
ToolCallPart.make({
id: event.id,
name: event.name,
namespace: event.namespace,
input: event.input,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }),
@@ -562,6 +592,7 @@ const toolResultContent = (event: ToolResult): ContentPart =>
ToolResultPart.make({
id: event.id,
name: event.name,
namespace: event.namespace,
result: event.result,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }),
+63 -4
View File
@@ -135,6 +135,7 @@ export const ToolCallPart = Object.assign(
type: Schema.Literal("tool-call"),
id: Schema.String,
name: Schema.String,
namespace: Schema.optional(Schema.String),
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
@@ -152,6 +153,7 @@ export const ToolResultPart = Object.assign(
type: Schema.Literal("tool-result"),
id: Schema.String,
name: Schema.String,
namespace: Schema.optional(Schema.String),
result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
@@ -168,6 +170,7 @@ export const ToolResultPart = Object.assign(
type: "tool-result",
id: input.id,
name: input.name,
namespace: input.namespace,
result: ToolResultValue.make(input.result, input.resultType),
providerExecuted: input.providerExecuted,
cache: input.cache,
@@ -266,7 +269,7 @@ export namespace Message {
make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
}
export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefinition")({
const toolDefinitionFields = {
name: Schema.String,
description: Schema.String,
inputSchema: JsonSchema,
@@ -274,15 +277,71 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefini
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
}
export type ToolDefinitionInput = Schema.Struct.Type<typeof toolDefinitionFields>
export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefinition")({
type: Schema.Literal("tool"),
...toolDefinitionFields,
}) {
constructor(input: ToolDefinitionInput) {
super({ ...input, type: "tool" })
}
}
export namespace ToolDefinition {
export type Input = ToolDefinition | ConstructorParameters<typeof ToolDefinition>[0]
export type Input = ToolDefinition | ToolDefinitionInput
/** Normalize tool definition input into the canonical `ToolDefinition` class. */
export const make = (input: Input) => (input instanceof ToolDefinition ? input : new ToolDefinition(input))
}
export type ToolNamespace = {
readonly type: "namespace"
readonly name: string
readonly description?: string
readonly tools: ReadonlyArray<ToolEntry>
}
export type ToolNamespaceInput = Omit<ToolNamespace, "type" | "tools"> & {
readonly tools: ReadonlyArray<ToolEntryInput>
}
export type ToolNamespaceEntryInput = ToolNamespaceInput & { readonly type: "namespace" }
export const ToolNamespace: Schema.Codec<ToolNamespace> & {
readonly make: (input: ToolNamespace | ToolNamespaceInput) => ToolNamespace
} = Object.assign(
Schema.Struct({
type: Schema.Literal("namespace"),
name: Schema.String,
description: Schema.optional(Schema.UndefinedOr(Schema.String)),
tools: Schema.Array(Schema.suspend((): Schema.Codec<ToolEntry> => ToolEntry)),
}).annotate({ identifier: "LLM.ToolNamespace" }),
{
make: (input: ToolNamespace | ToolNamespaceInput): ToolNamespace => ({
...input,
type: "namespace",
tools: input.tools.map(ToolEntry.make),
}),
},
)
export type ToolEntry = ToolDefinition | ToolNamespace
export type ToolEntryInput = ToolDefinition.Input | ToolNamespaceEntryInput
export const ToolEntry: Schema.Codec<ToolEntry> & {
readonly make: (input: ToolEntryInput) => ToolEntry
} = Object.assign(
Schema.Union([ToolDefinition, ToolNamespace]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "LLM.ToolEntry" }),
),
{
make: (input: ToolEntryInput): ToolEntry =>
"type" in input && input.type === "namespace" ? ToolNamespace.make(input) : ToolDefinition.make(input),
},
)
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
type: Schema.Literals(["auto", "none", "required", "tool"]),
name: Schema.optional(Schema.String),
@@ -312,7 +371,7 @@ const requestSchema = Schema.Struct({
model: LanguageModelSchema,
system: Schema.Array(SystemPart),
messages: Schema.Array(Message),
tools: Schema.Array(ToolDefinition),
tools: Schema.Array(ToolEntry),
toolChoice: Schema.optional(ToolChoice),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
+8 -8
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute, CompactOperation } from "../route/client.js"
import type { AnyRoute, CompactionOperations } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -175,7 +175,7 @@ export namespace LanguageModelCompatibility {
export class LanguageModel<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
@@ -194,7 +194,7 @@ export class LanguageModel<
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
>(input: LanguageModel.Input<Compact>) {
return new LanguageModel<Options, Compact>({
id: ModelID.make(input.id),
@@ -206,7 +206,7 @@ export class LanguageModel<
})
}
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static input<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
return {
@@ -218,11 +218,11 @@ export class LanguageModel<
}
}
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options, Compact>,
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
): LanguageModel<Options, Compact>
@@ -241,7 +241,7 @@ export class LanguageModel<
}
export namespace LanguageModel {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
export type ConstructorInput<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute<Compact>
@@ -249,7 +249,7 @@ export namespace LanguageModel {
readonly compatibility?: LanguageModelCompatibility
}
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
export type Input<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
+39 -11
View File
@@ -1,10 +1,17 @@
export * as TestLLM from "./testing.js"
import { LLMClient } from "./route/client.js"
import {
LLMClient,
type CompactionRequest,
type CheckpointRequest,
type EndpointCompactOptions,
type TriggerCompactOptions,
} from "./route/client.js"
import {
LLMEvent,
LLMResponse,
CompactionResponse,
CompactionCheckpointResponse,
type FinishReasonDetails,
type AIError,
type LLMRequest,
@@ -13,7 +20,11 @@ import {
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
export type Response =
| readonly LLMEvent[]
| Stream.Stream<LLMEvent, AIError>
| CompactionResponse
| CompactionCheckpointResponse
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -132,21 +143,38 @@ const make = (options: LayerOptions) =>
Stream.unwrap(
take(request).pipe(
Effect.map((response) => {
if (response instanceof CompactionResponse)
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
return Stream.die("TestLLM generation requires an event response")
return Stream.isStream(response) ? response : Stream.fromIterable(response)
}),
),
)
const test = Test.of({
compact: (request) =>
take(request).pipe(
Effect.flatMap((response) =>
response instanceof CompactionResponse
function compact(
request: CompactionRequest,
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError>
function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError>
function compact(
request: LLMRequest,
options?: EndpointCompactOptions | TriggerCompactOptions,
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
return take(request).pipe(
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
if (options?.mechanism === "trigger")
return response instanceof CompactionCheckpointResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse"),
),
),
: Effect.die("TestLLM trigger compaction requires a CompactionCheckpointResponse")
return response instanceof CompactionResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse")
}),
)
}
const test = Test.of({
compact,
stream,
generate: (request) =>
stream(request).pipe(
+13 -4
View File
@@ -37,7 +37,13 @@ function missingToolResults(calls: Iterable<ToolCallPart>) {
return new Message({
role: "tool",
content: [...calls].map((call) =>
ToolResultPart.make({ id: call.id, name: call.name, result: MISSING_TOOL_RESULT, resultType: "error" }),
ToolResultPart.make({
id: call.id,
name: call.name,
namespace: call.namespace,
result: MISSING_TOOL_RESULT,
resultType: "error",
}),
),
})
}
@@ -47,7 +53,7 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
if (part.type !== "tool-result" || part.providerExecuted === true) return part
const call = pending.get(part.id)
if (call) pending.delete(part.id)
return normalizeToolResult(part, call?.name ?? part.name)
return normalizeToolResult(part, call)
})
if (content.length === 0) return undefined
if (content.every((part, index) => part === message.content[index])) return message
@@ -61,8 +67,11 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
})
}
function normalizeToolResult(part: ToolResultPart, name: string): ToolResultPart {
const named = part.name === name ? part : { ...part, name }
function normalizeToolResult(part: ToolResultPart, call: ToolCallPart | undefined): ToolResultPart {
const named =
call === undefined || (part.name === call.name && part.namespace === call.namespace)
? part
: { ...part, name: call.name, namespace: call.namespace }
if (named.result.type === "text" && named.result.value === "")
return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (named.result.type === "error" && named.result.value === "")
+12 -4
View File
@@ -21,10 +21,11 @@ export interface DispatchResult extends ToolSettlement {
/** Execute one canonical tool call without owning provider IO or continuation. */
export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
const tool = tools[call.name]
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${call.name}` }))
const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}`
const tool = tools[name]
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` }))
if (!tool.execute)
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${call.name}` }))
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` }))
return decodeAndExecute(tool, call).pipe(
Effect.map((value) => result(call, value)),
@@ -38,7 +39,11 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
tool._decode(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((decoded) =>
tool.execute!(decoded, { id: call.id, name: call.name }).pipe(
tool.execute!(decoded, {
id: call.id,
name: call.name,
namespace: call.namespace,
}).pipe(
Effect.flatMap((value) =>
tool._encode(value).pipe(
Effect.mapError(
@@ -71,6 +76,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
LLMEvent.toolError({
id: call.id,
name: call.name,
namespace: call.namespace,
message: String(settlement.result.value),
error,
providerMetadata: call.providerMetadata,
@@ -78,6 +84,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
LLMEvent.toolResult({
id: call.id,
name: call.name,
namespace: call.namespace,
result: settlement.result,
providerMetadata: call.providerMetadata,
}),
@@ -86,6 +93,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
LLMEvent.toolResult({
id: call.id,
name: call.name,
namespace: call.namespace,
result: settlement.result,
output: settlement.output,
providerMetadata: call.providerMetadata,
+1
View File
@@ -16,6 +16,7 @@ export type ToolSchema<T> = Schema.Codec<T, any, never, never>
export interface ToolExecuteContext {
readonly id: ToolCallPart["id"]
readonly name: ToolCallPart["name"]
readonly namespace?: ToolCallPart["namespace"]
}
export type ToolExecute<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
+53
View File
@@ -215,6 +215,35 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("deduplicates tools before counting cache hints", () =>
Effect.gen(function* () {
const manual = new CacheHint({ type: "ephemeral" })
const duplicate = (description: string) => ({
name: "lookup",
description,
inputSchema: { type: "object" },
cache: manual,
})
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
tools: [
duplicate("first"),
duplicate("second"),
duplicate("third"),
duplicate("fourth"),
{ name: "lookup", description: "final", inputSchema: { type: "object" } },
],
cache: { tools: true },
}),
)
expect(prepared.body.tools).toEqual([
expect.objectContaining({ name: "lookup", description: "final", cache_control: { type: "ephemeral" } }),
])
}),
)
it.effect("auto policy preserves manual CacheHints on other parts", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -281,6 +310,30 @@ describe("applyCachePolicy", () => {
}),
)
test("marks the final leaf inside a tool namespace", () => {
const request = LLM.request({
model: anthropicModel,
tools: [
{
type: "namespace",
name: "crm",
tools: [
{ name: "lookup", description: "lookup", inputSchema: {} },
{ name: "orders", description: "orders", inputSchema: {} },
],
},
],
cache: { tools: true },
})
const applied = applyCachePolicy(request)
const namespace = applied.tools[0]
expect(namespace?.type).toBe("namespace")
if (namespace?.type !== "namespace") throw new Error("Expected namespace")
expect(namespace.tools[0]).not.toHaveProperty("cache")
expect(namespace.tools[1]).toHaveProperty("cache", { type: "ephemeral" })
})
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+62 -2
View File
@@ -1,8 +1,16 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolDefinition, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import {
LLM,
LLMRequest,
Message,
ToolCallPart,
ToolDefinition,
ToolNamespace,
mergeProviderOptions,
} from "../src/index.js"
import { AnthropicMessages, OpenAIChat, OpenAIResponses } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { it } from "./lib/effect.js"
@@ -106,6 +114,58 @@ describe("request option precedence", () => {
}),
)
it.effect("deduplicates tools within each namespace", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAIResponses.route.model({ id: "gpt-5.4" }),
tools: [
ToolDefinition.make({ name: "crm", description: "Top-level CRM tool", inputSchema: {} }),
ToolNamespace.make({
name: "crm",
description: "CRM tools",
tools: [
ToolDefinition.make({ name: "lookup", description: "old", inputSchema: {} }),
ToolDefinition.make({ name: "search", description: "search", inputSchema: {} }),
ToolDefinition.make({ name: "lookup", description: "new", inputSchema: {} }),
],
}),
ToolNamespace.make({
name: "support",
description: "Support tools",
tools: [ToolDefinition.make({ name: "lookup", description: "support", inputSchema: {} })],
}),
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "crm",
description: "Top-level CRM tool",
parameters: {},
strict: false,
},
{
type: "namespace",
name: "crm",
description: "CRM tools",
tools: [
{ type: "function", name: "lookup", description: "new", parameters: {}, strict: false },
{ type: "function", name: "search", description: "search", parameters: {}, strict: false },
],
},
{
type: "namespace",
name: "support",
description: "Support tools",
tools: [{ type: "function", name: "lookup", description: "support", parameters: {}, strict: false }],
},
])
}),
)
it.effect("normalizes tool history before protocol lowering", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+48 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { CacheHint, LLM, LLMResponse } from "../src/index.js"
import { Schema } from "effect"
import { CacheHint, LLM, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
import {
@@ -17,6 +18,52 @@ const chatRoute = OpenAIChat.route
const responsesRoute = OpenAIResponses.route
describe("llm constructors", () => {
test("normalizes recursive tool namespaces", () => {
const request = LLM.request({
model: LanguageModel.make({ id: "fake-model", provider: "fake", route: responsesRoute }),
tools: [
{
type: "namespace",
name: "crm",
description: "Customer management",
tools: [
{ name: "lookup", description: "Look up a customer", inputSchema: { type: "object" } },
{
type: "namespace",
name: "orders",
tools: [{ name: "list", description: "List orders", inputSchema: { type: "object" } }],
},
],
},
],
})
expect(request.tools[0]).toEqual({
type: "namespace",
name: "crm",
description: "Customer management",
tools: [
expect.objectContaining({ type: "tool", name: "lookup" }),
{
type: "namespace",
name: "orders",
description: undefined,
tools: [expect.objectContaining({ type: "tool", name: "list" })],
},
],
})
expect(request.tools[0]).toEqual(
ToolNamespace.make({
name: "crm",
description: "Customer management",
tools: request.tools[0]!.type === "namespace" ? request.tools[0].tools : [],
}),
)
expect(Schema.decodeUnknownSync(ToolEntry)(Schema.encodeUnknownSync(ToolEntry)(request.tools[0]))).toEqual(
request.tools[0],
)
})
test("builds canonical schema classes from ergonomic input", () => {
const request = LLM.request({
id: "req_1",
@@ -0,0 +1,129 @@
import { Effect } from "effect"
import {
CompactionCheckpointResponse,
CompactionResponse,
LanguageModel,
LLM,
LLMClient,
LLMRequest,
} from "../../src/index.js"
import {
Anthropic,
Azure,
AmazonBedrock,
AmazonBedrockMantle,
OpenAI,
OpenAICompatibleResponses,
XAI,
} from "../../src/providers.js"
import type { WebSocketChannelExecutor } from "../../src/route.js"
import type { RoutePatch } from "../../src/route/client.js"
import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js"
import type { Prepared } from "../../src/protocols/open-responses-channel.js"
declare const webSocket: WebSocketChannelExecutor
const model = OpenAI.configure().responses("fixture")
const request = LLM.request({ model, prompt: "hello" })
LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse))
LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe(
Effect.map((result) => {
result satisfies CompactionCheckpointResponse
result.checkpoint.encrypted satisfies string
result.responseID satisfies string
// @ts-expect-error A trigger does not return replacement history.
result.replacement
}),
)
// @ts-expect-error Endpoint compaction does not accept a WebSocket executor.
LLMClient.compact(request, { mechanism: "endpoint", webSocket })
// @ts-expect-error Omitting mechanism selects the HTTP endpoint.
LLMClient.compact(request, { webSocket })
// @ts-expect-error Unknown mechanisms do not have a permissive fallback overload.
LLMClient.compact(request, { mechanism: "other" })
for (const selected of [
model,
OpenAI.model("fixture", {}),
model.route.with({ headers: { fixture: "test" } }).model({ id: "fixture" }),
LanguageModel.make(LanguageModel.input(model)),
LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }),
]) {
LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" })
}
LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" })
LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" })
const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture")
const xai = XAI.configure().responses("fixture")
// @ts-expect-error Azure must not inherit OpenAI's trigger operation.
LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" })
LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), {
// @ts-expect-error Azure's package entrypoint must preserve its narrower capability.
mechanism: "trigger",
})
// @ts-expect-error xAI endpoint support does not imply trigger support.
LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" })
// @ts-expect-error xAI's package entrypoint must preserve its narrower capability.
LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" })
const unsupported = {
bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
openai: LLM.request({ model: OpenAI.configure().chat("fixture") }),
azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }),
xai: LLM.request({ model: XAI.configure().chat("fixture") }),
compatible: LLM.request({
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
}),
}
// @ts-expect-error Bedrock does not expose trigger compaction.
LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" })
// @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol.
LLMClient.compact(unsupported.mantle, { mechanism: "trigger" })
// @ts-expect-error Anthropic does not expose trigger compaction.
LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" })
// @ts-expect-error OpenAI Chat does not expose trigger compaction.
LLMClient.compact(unsupported.openai, { mechanism: "trigger" })
// @ts-expect-error Azure Chat does not expose trigger compaction.
LLMClient.compact(unsupported.azure, { mechanism: "trigger" })
// @ts-expect-error xAI Chat does not expose trigger compaction.
LLMClient.compact(unsupported.xai, { mechanism: "trigger" })
// @ts-expect-error Generic protocol compatibility does not grant trigger support.
LLMClient.compact(unsupported.compatible, { mechanism: "trigger" })
// @ts-expect-error Changing the model replaces its capability.
LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" })
// @ts-expect-error Changing the route replaces its capability.
LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" })
LLMClient.compact(
LLM.request({
model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }),
}),
// @ts-expect-error Replacing route operations does not retain the old trigger capability.
{ mechanism: "trigger" },
)
declare const dynamic: LLMRequest
declare const patch: Partial<LLMRequest.Input>
declare const routePatch: RoutePatch<OpenAIResponsesBody, Prepared>
// @ts-expect-error A dynamic operation override cannot preserve trigger support.
LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), {
mechanism: "trigger",
})
// @ts-expect-error Explicitly removing operations removes trigger support.
LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), {
mechanism: "trigger",
})
// @ts-expect-error Dynamic models must be narrowed.
LLMClient.compact(dynamic, { mechanism: "trigger" })
if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) {
LLMClient.compact(dynamic, { mechanism: "trigger" })
LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" }))
}
if (LLMClient.canCompact(dynamic)) {
LLMClient.compact(dynamic)
// @ts-expect-error Endpoint narrowing does not grant trigger support.
LLMClient.compact(dynamic, { mechanism: "trigger" })
}
// @ts-expect-error A dynamic model override cannot preserve trigger support.
LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" })
@@ -0,0 +1,243 @@
import { expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMClient, LLMRequest } from "../../src/index.js"
import { Azure, OpenAI } from "../../src/providers.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" })
const trigger = { mechanism: "trigger" } as const
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toBe("/v1/responses")
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
expect(request.headers.authorization).toBe("Bearer fixture")
expect(request.headers["chatgpt-account-id"]).toBe("fixture-account")
expect(request.headers["x-codex-beta-features"]).toBe("remote_compaction_v2")
const body = JSON.parse(text)
expect(body).toMatchObject({
model: "fixture",
stream: true,
store: false,
instructions: "Keep the context",
parallel_tool_calls: true,
prompt_cache_key: "session-key",
service_tier: "priority",
reasoning: { effort: "high", summary: "auto" },
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
})
expect(body.tools).toHaveLength(1)
expect(body.tools[0].name).toBe("lookup")
expect(body.tool_choice).toBeUndefined()
expect(body.context_management).toBeUndefined()
expect(body.text).toBeUndefined()
expect(body.max_output_tokens).toBeUndefined()
expect(body.previous_response_id).toBeUndefined()
return respond(
sseEvents({
type: "response.completed",
response: {
id: "resp_1",
output: [checkpoint],
usage: {
input_tokens: 100,
input_tokens_details: { cached_tokens: 40 },
output_tokens: 5,
total_tokens: 105,
},
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
Effect.gen(function* () {
const calls: string[] = []
const input = LLM.request({
model: request.model,
system: "Keep the context",
prompt: "hello",
promptCacheKey: "session-key",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
toolChoice: { type: "tool", name: "lookup" },
generation: { maxTokens: 1 },
providerOptions: {
store: true,
reasoningEffort: "high",
reasoningSummary: "auto",
contextManagement: [{ type: "compaction" }],
},
http: {
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
query: { trace: "request" },
body: {
service_tier: "priority",
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
store: true,
stream: false,
text: { format: { type: "json_object" } },
tool_choice: "required",
},
},
})
const original = LLMRequest.input(input)
const result = yield* LLMClient.compact(input, {
...trigger,
http: (request, next) => {
calls.push("http")
return next(request)
},
})
expect(result.checkpoint).toMatchObject({
type: "compaction",
provider: "openai",
id: "cmp_1",
encrypted: "opaque",
})
expect(result.responseID).toBe("resp_1")
expect(result.usage).toMatchObject({
inputTokens: 100,
outputTokens: 5,
totalTokens: 105,
cacheReadInputTokens: 40,
})
expect(LLMRequest.input(input)).toEqual(original)
expect(calls).toEqual(["http"])
}),
)
const idless = { type: "compaction", encrypted_content: "opaque" }
testEffect(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" },
},
{ type: "response.output_text.delta", delta: "do not expose this" },
{ type: "response.output_item.added", output_index: 1, item: { type: "compaction" } },
{ type: "response.output_item.done", output_index: 1, item: idless },
{ type: "response.output_item.done", output_index: 1, item: idless },
{
type: "response.completed",
response: {
id: "resp_1",
output: [{ type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" }, idless],
},
},
),
),
).effect("correlates an ID-less checkpoint across repeated events and ignores other output", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(request, trigger)
expect(result.checkpoint.encrypted).toBe("opaque")
expect(result.checkpoint.id).toBeString()
expect("message" in result).toBe(false)
}),
)
for (const [name, events] of Object.entries({
missing: [{ type: "response.completed", response: { id: "resp_1", output: [] } }],
multiple: [
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, { ...checkpoint, id: "cmp_2" }] } },
],
duplicateSlots: [{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, checkpoint] } }],
malformed: [
{ type: "response.completed", response: { id: "resp_1", output: [{ type: "compaction", id: "cmp_1" }] } },
],
noResponseID: [{ type: "response.completed", response: { output: [checkpoint] } }],
incomplete: [
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.incomplete", response: { id: "resp_1", incomplete_details: { reason: "max_output_tokens" } } },
],
failed: [
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.failed", response: { id: "resp_1", error: { code: "server_error", message: "failed" } } },
],
})) {
const wire = events.map((event) => ({ ...event, fixture_extra: "preserved" }))
testEffect(
fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }),
).effect(`rejects ${name} checkpoint response and preserves original error context`, () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
expect(error.reason.body).toBe(JSON.stringify(wire.at(-1)))
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } })
}),
)
}
testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: checkpoint }))).effect(
"rejects clean EOF without response.completed",
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
for (const body of [{ input: [] }, { previous_response_id: "stale" }]) {
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
}
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
"endpoint support does not grant trigger support, including for untyped callers",
() =>
Effect.gen(function* () {
const client = yield* LLMClient.Service
const unsupported = LLM.request({ model: Azure.configure({ resourceName: "fixture" }).responses("fixture") })
expect(LLMClient.canCompact(unsupported)).toBe(true)
expect(LLMClient.canCompact(unsupported, trigger)).toBe(false)
// @ts-expect-error Exercise untyped consumers; the service must reject before sending.
const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip)
expect(serviceError.reason._tag).toBe("UnsupportedOperation")
// @ts-expect-error Exercise an unknown mechanism supplied by JavaScript.
const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
testEffect(fixedResponse("must not use HTTP")).effect(
"invalid checkpoint output is rejected before acknowledging channel completion",
() =>
Effect.gen(function* () {
let completed = 0
const operation = LLMClient.compact(request, {
mechanism: "trigger",
webSocket: {
execute: () =>
Effect.succeed({
frames: Stream.make(
JSON.stringify({
type: "response.completed",
response: { id: "resp_1", output: [] },
}),
),
complete: Effect.sync(() => {
completed++
}),
}),
},
})
const error = yield* operation.pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(completed).toBe(0)
}),
)
@@ -1,6 +1,6 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLM, LLMRequest, Message, ToolDefinition } from "../../src/index.js"
import { LLMClient, Route } from "../../src/route/client.js"
import { Auth } from "../../src/route/auth.js"
import { Endpoint } from "../../src/route/endpoint.js"
@@ -134,7 +134,12 @@ for (const model of [
[
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
ToolDefinition.make({
name: "unsupported",
description: "Generation only",
inputSchema: {},
native: { unsupported: {} },
}),
],
}),
"InvalidRequest",
@@ -326,7 +326,6 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("mints an id for a done-only tool that never had one", () =>
Effect.gen(function* () {
const events = yield* collect(
@@ -357,9 +356,16 @@ describe("Open Responses basic-item lifecycles", () => {
const events = yield* collect({ type: "response.output_item.done", item }, completed)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
{ type: "tool-input-start", id: "call_1", name: "lookup", namespace: undefined, providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", namespace: undefined, providerMetadata },
{
type: "tool-call",
id: "call_1",
name: "lookup",
namespace: undefined,
input: { query: "weather" },
providerMetadata,
},
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
@@ -171,6 +171,66 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("flattens tool namespaces", () =>
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,
tools: [
{
type: "namespace",
name: "acme",
tools: [
{
type: "namespace",
name: "billing",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup billing", inputSchema: {} })],
},
ToolDefinition.make({ name: "users", description: "Lookup users", inputSchema: {} }),
],
},
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "acme_billing_lookup",
description: "Lookup billing",
parameters: {},
strict: false,
},
{ type: "function", name: "acme_users", description: "Lookup users", parameters: {}, strict: false },
])
}),
)
it.effect("flattens tool namespaces in history", () =>
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({ type: "tool-call", id: "call_1", name: "lookup", namespace: "crm", input: {} }),
Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "crm_lookup", namespace: undefined, arguments: "{}" },
{ type: "function_call_output", call_id: "call_1", output: "done" },
])
}),
)
it.effect("lowers canonical parallel tool control", () =>
Effect.gen(function* () {
const model = configure({
@@ -12,6 +12,7 @@ import {
LanguageModel,
ToolCallPart,
ToolDefinition,
ToolNamespace,
ToolResultPart,
TransportError,
Usage,
@@ -143,6 +144,94 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers tool namespaces without flattening leaf names", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Find a customer and their orders.",
tools: [
ToolNamespace.make({
name: "crm",
description: "Customer management",
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up a customer", inputSchema: {} }),
ToolDefinition.make({ name: "orders", description: "List customer orders", inputSchema: {} }),
],
}),
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "namespace",
name: "crm",
description: "Customer management",
tools: [
{ type: "function", name: "lookup", description: "Look up a customer", parameters: {}, strict: false },
{ type: "function", name: "orders", description: "List customer orders", parameters: {}, strict: false },
],
},
])
}),
)
it.effect("flattens nested levels within a native tool namespace", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
tools: [
{
type: "namespace",
name: "crm",
description: "Customer management",
tools: [
{
type: "namespace",
name: "orders",
description: "Order management",
tools: [ToolDefinition.make({ name: "list", description: "List orders", inputSchema: {} })],
},
],
},
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "namespace",
name: "crm",
description: "Customer management",
tools: [{ type: "function", name: "orders_list", description: "List orders", parameters: {}, strict: false }],
},
])
}),
)
it.effect("defaults tool namespace descriptions", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
tools: [
{
type: "namespace",
name: "crm",
tools: [ToolDefinition.make({ name: "lookup", description: "Look up a customer", inputSchema: {} })],
},
],
}),
)
expect(prepared.body.tools).toEqual([
expect.objectContaining({ type: "namespace", name: "crm", description: "Tools in the crm namespace." }),
])
}),
)
it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -437,7 +526,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("tolerates keepalive frames before response.created", () =>
it.effect("tolerates keepalive and provider notifications before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
@@ -445,6 +534,7 @@ describe("OpenAI Responses route", () => {
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
@@ -568,7 +658,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a promoted steer after the completed assistant output", () =>
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
@@ -582,7 +672,7 @@ describe("OpenAI Responses route", () => {
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello" }],
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
@@ -610,42 +700,37 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues store-false reasoning while retaining the output item ID", () =>
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
const reasoning = {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
}
const first = continuationDriver(request)
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
item: reasoning,
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
ProviderShared.encodeJson({
type: "response.completed",
response: { id: "resp_1", output: [{ ...reasoning, encrypted_content: "terminal-encrypted" }] },
}),
),
)
const next = continuationDriver({
...request,
input: [
...firstInput,
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
input: [...firstInput, reasoning, { role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
const continued = yield* next.create(saved)
@@ -655,6 +740,11 @@ describe("OpenAI Responses route", () => {
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
const edited = yield* continuationDriver({
...request,
input: [...firstInput, { ...reasoning, encrypted_content: "edited" }, { role: "user", content: "Continue" }],
}).create(saved)
expect(edited.mode).toBe("full")
}),
)
@@ -2130,6 +2220,71 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves tool namespaces through streaming and history replay", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
namespace: "crm",
name: "lookup",
arguments: "",
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "fc_1",
delta: '{"id":"123"}',
},
{
type: "response.output_item.done",
output_index: 0,
item: { ...item, arguments: '{"id":"123"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
const toolEvents = response.events.filter((event) => event.type.startsWith("tool-"))
expect(toolEvents).toEqual([
expect.objectContaining({ type: "tool-input-start", name: "lookup", namespace: "crm" }),
expect.objectContaining({ type: "tool-input-delta", name: "lookup", namespace: "crm" }),
expect.objectContaining({ type: "tool-input-end", name: "lookup", namespace: "crm" }),
expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }),
])
expect(response.message.content).toEqual([
expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }),
])
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: { customer: "Ada" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "fc_1",
call_id: "call_1",
namespace: "crm",
name: "lookup",
arguments: '{"id":"123"}',
},
{ type: "function_call_output", call_id: "call_1", output: '{"customer":"Ada"}' },
])
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
+26
View File
@@ -3,6 +3,7 @@ import {
AIError,
CompactionPart,
CompactionResponse,
CompactionCheckpointResponse,
LanguageModel,
LLM,
LLMClient,
@@ -79,6 +80,31 @@ describe("TestLLM legacy client", () => {
})
describe("TestLLM first-class client", () => {
it.effect("returns scripted checkpoints only for trigger compaction", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" })
const checkpoint = new CompactionCheckpointResponse({
checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" },
responseID: "resp_fixture",
})
const endpoint = new CompactionResponse({ replacement: [] })
yield* client.push(checkpoint, endpoint, checkpoint, checkpoint)
const operation = LLMClient.compact(request, { mechanism: "trigger" })
expect(yield* client.requests()).toEqual([])
expect(yield* operation).toBe(checkpoint)
expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM trigger compaction requires a CompactionCheckpointResponse",
)
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM compaction requires a CompactionResponse",
)
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM generation requires an event response",
)
}),
)
it.effect("rejects response fixtures for the wrong operation", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
+10 -7
View File
@@ -15,13 +15,7 @@ describe("tool history normalization", () => {
Message.assistant(toolCall("trailing")),
])
expect(normalized.map((message) => message.role)).toEqual([
"assistant",
"tool",
"tool",
"user",
"assistant",
])
expect(normalized.map((message) => message.role)).toEqual(["assistant", "tool", "tool", "user", "assistant"])
expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" })
expect(normalized[2]?.content).toEqual([
{ type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } },
@@ -74,4 +68,13 @@ describe("tool history normalization", () => {
expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted])
})
test("uses a matching call as the complete tool identity", () => {
const normalized = normalizeToolHistory([
Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })),
Message.tool(ToolResultPart.make({ id: "call_1", name: "wrong", namespace: "stale", result: "done" })),
])
expect(normalized[1]?.content[0]).toMatchObject({ name: "lookup", namespace: undefined })
})
})
+22
View File
@@ -7,6 +7,7 @@ import {
LLMEvent,
LLMRequest,
LLMResponse,
ToolCallPart,
ToolChoice,
ToolOutput,
toDefinitions,
@@ -36,6 +37,27 @@ const baseRequest = LLM.request({
})
const weatherFailureCause = new Error("weather lookup denied")
test("dispatches namespaced calls by qualified identity", async () => {
let context: ToolExecuteContext | undefined
const lookup = Tool.make({
description: "Look up a customer.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: (_, value) => {
context = value
return Effect.succeed("customer")
},
})
const call = ToolCallPart.make({ id: "call_1", namespace: "crm", name: "lookup", input: {} })
const result = await Effect.runPromise(
ToolRuntime.dispatch({ "crm.lookup": lookup, lookup: schema_only_weather }, call),
)
expect(result.result).toEqual({ type: "text", value: "customer" })
expect(context).toEqual({ id: "call_1", namespace: "crm", name: "lookup" })
expect(result.events).toEqual([expect.objectContaining({ type: "tool-result", namespace: "crm", name: "lookup" })])
})
const get_weather = Tool.make({
description: "Get current weather for a city.",
parameters: Schema.Struct({ city: Schema.String }),
+1 -1
View File
@@ -5,5 +5,5 @@
"noEmit": true,
"rootDir": "."
},
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
"include": ["test/**/*.types.ts", "test/testing.test.ts", "test/provider/checkpoint.test.ts"]
}
+71 -10
View File
@@ -88,27 +88,88 @@ story("renders a draft once and supports editing, caret restoration, and failure
await expect(input).toHaveText("Preserve this draft on failure")
})
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
story("shows the thinking level control while relevant", async ({ mount, page }) => {
story("shows thinking on composer hover or when a non-default variant is selected", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const composer = component.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
const control = composer.getByRole("button", { name: "Choose model variant" })
await component.getByRole("status").click()
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await expect(control).toBeVisible()
await expect(control).toHaveText("balanced")
await expect(control).toHaveCSS("opacity", "1")
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await component.getByRole("status").click()
await expect(control).toHaveText("default")
await expect(control).toHaveCSS("opacity", "0")
await expect(control).toHaveCSS("pointer-events", "none")
await input.hover()
await expect(control).toHaveCSS("opacity", "1")
await expect(control).toHaveCSS("pointer-events", "auto")
await input.click()
await page.mouse.move(0, 0)
await expect(input).toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
await input.hover()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).toHaveAttribute("aria-expanded", "true")
await expect(control).toHaveCSS("opacity", "1")
await expect(high).toBeVisible()
await high.click()
await component.getByRole("status").click()
await expect(control).toHaveText("high")
await expect(control).toHaveCSS("opacity", "1")
await input.focus()
await expect(control).toBeVisible()
await input.blur()
await expect(control).toBeVisible()
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await component.getByRole("status").click()
await expect(control).toHaveCSS("opacity", "0")
})
story("keeps default thinking accessible by keyboard without composer hover", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const control = component.getByRole("button", { name: "Choose model variant" })
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await input.click()
await page.mouse.move(0, 0)
await expect(control).toHaveCSS("opacity", "0")
// Tab through Add, Agent, and Model to the visually hidden thinking trigger.
for (let count = 0; count < 4; count++) await page.keyboard.press("Tab")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Enter")
await expect(control).toHaveAttribute("aria-expanded", "true")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Escape")
await expect(control).toHaveAttribute("aria-expanded", "false")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await page.keyboard.press("End")
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeFocused()
await page.keyboard.press("Enter")
await expect(control).toHaveText("high")
await expect(control).toBeFocused()
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await page.keyboard.press("Enter")
await expect(control).toHaveText("default")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Tab")
await expect(control).not.toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
})
@@ -0,0 +1,195 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/slash-skills"
const projectID = "proj_slash_skills"
const sessionID = "ses_slash_skills"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
async function setup(page: Page, queued = false) {
const prompts: Record<string, unknown>[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "slash-skills",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
projectID,
directory,
title: "Slash skills",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
sessionStatus: queued ? { [sessionID]: { type: "running" } } : {},
inbox: queued
? [
{
id: "inb_slash_skill",
sessionID,
timeCreated: 1700000000000,
type: "user",
payload: { text: "Explain caching" },
delivery: "queue",
},
]
: [],
findFiles: ({ query }) =>
query.includes("cache")
? [
{
name: "cache.ts",
path: "src/cache.ts",
absolute: `${directory}/src/cache.ts`,
type: "file",
ignored: false,
},
]
: [],
onPrompt: ({ body }) => prompts.push(body),
})
await page.route("**/api/skill?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [
{ id: "show-me", slash: true, autoinvoke: false },
{ id: "hidden", slash: false },
{ id: "implicit" },
{ id: "review", slash: true },
{ id: "model", slash: true },
].map((skill) => ({
name: skill.id === "show-me" ? "Show Me" : skill.id,
description: "Explain the current topic visually",
location: `/skills/${skill.id}/SKILL.md`,
content: "Explain visually",
...skill,
})),
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.route("**/api/command?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [{ name: "review", description: "Review changes" }],
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const editor = composer.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable()
return { prompts, composer, editor }
}
for (const selection of ["keyboard", "pointer"]) {
test(`selects and submits a manual-only slash skill with ${selection}`, async ({ page }) => {
const { prompts, editor } = await setup(page)
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toContainText("/show-me")
await page.screenshot({ path: test.info().outputPath("slash-menu.png") })
if (selection === "keyboard") await editor.press("Enter")
if (selection === "pointer") await skill.click()
await expect(editor).toHaveText("/show-me ")
await expect(editor).toBeFocused()
await editor.pressSequentially("explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
await expect(editor).toBeEmpty()
})
}
test("respects slash flags and command precedence without hiding context skills", async ({ page }) => {
const { editor } = await setup(page)
await editor.fill("/")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="custom.review"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="model.choose"]')).toBeVisible()
for (const id of ["hidden", "implicit", "review", "model"]) {
await expect(page.locator(`[data-suggestion-id="skill:${id}"]`)).toHaveCount(0)
}
await editor.press("Escape")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toHaveCount(0)
await expect(editor).toBeFocused()
await editor.fill("@hidden")
const hidden = page.locator('[data-suggestion-id="skill:hidden"]')
await expect(hidden).toContainText("@hidden")
await hidden.click()
await expect(editor).toHaveText("@hidden ")
await expect(editor).toBeFocused()
})
test("preserves structured attachments when adding a slash skill from the command menu", async ({ page }) => {
const { prompts, composer, editor } = await setup(page)
await editor.fill("explain @cache")
const file = page.locator('[data-suggestion-id="file:src/cache.ts"]')
await expect(file).toBeVisible()
await file.click()
await expect(editor).toHaveText("explain @src/cache.ts ")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me explain @src/cache.ts ")
await expect(editor).toBeFocused()
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
files: [{ name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } }],
})
})
for (const select of [true, false]) {
test(`keeps slash skills in queued edits (${select ? "selected" : "typed"})`, async ({ page }) => {
const { prompts, editor } = await setup(page, true)
const row = page.locator('[data-component="session-queue-row"]')
await row.getByText("Explain caching", { exact: true }).click()
await expect(editor).toHaveText("Explain caching")
if (select) {
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me ")
await editor.pressSequentially("explain caching")
}
if (!select) await editor.fill("/show-me explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
delivery: "queue",
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
})
}
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_dropzone"
const directory = "C:/OpenCode/NewSessionDropzone"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the dropzone and attaches a dropped file", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_dropzone",
worktree: directory,
vcs: "git",
name: "new-session-dropzone",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
const surface = page.locator('[data-component="new-session"]')
const dropzone = page.locator('[data-component="session-dropzone"]')
const transfer = await page.evaluateHandle(() => {
const value = new DataTransfer()
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
return value
})
await expect(dropzone).toHaveCount(0)
await surface.dispatchEvent("dragover", { dataTransfer: transfer })
await expect(dropzone).toHaveAttribute("data-visible", "true")
await expect(dropzone).toContainText("Drop files to add")
await surface.dispatchEvent("drop", { dataTransfer: transfer })
await expect(page.locator('[data-component="composer-attachments"]')).toContainText("dropzone.txt")
await expect(dropzone).toHaveCount(0)
})
@@ -134,7 +134,7 @@ for (const viewport of [
await expect(page).toHaveURL(pending.url)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
await expect(pending.title).toHaveText("New session")
await expect(pending.title).toHaveText("Session")
await expect(editor).toHaveText(followUp)
expect(mock.calls).toEqual(["worktree"])
@@ -238,7 +238,7 @@ for (const direction of ["ltr", "rtl"]) {
!frame.message ||
!frame.spinner ||
frame.draft !== followUp ||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
!["Session", "Created workspace session"].includes(frame.title ?? ""),
),
).toEqual([])
const after = await title.boundingBox()
@@ -598,7 +598,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
const title = preparing.getByRole("heading", { level: 1 })
await expect(preparing).toBeVisible()
await expect(title).toHaveText("New session")
await expect(title).toHaveText("Session")
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -8,7 +8,7 @@ const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the thinking level control while relevant", async ({ page }) => {
test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -52,30 +52,57 @@ test("shows the thinking level control while relevant", async ({ page }) => {
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
const control = composer.getByRole("button", { name: "Choose model variant" })
await expectAppVisible(composer)
await idleComposer(page)
await expect(control).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toHaveText("default")
await expect(control).toHaveCSS("opacity", "0")
await expect(control).toHaveCSS("pointer-events", "none")
await input.hover()
await expect(control).toHaveCSS("opacity", "1")
await input.click()
await page.mouse.move(0, 0)
await expect(input).toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
await input.hover()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).toHaveAttribute("aria-expanded", "true")
await expect(control).toHaveCSS("opacity", "1")
await expect(high).toBeVisible()
await high.click()
await idleComposer(page)
await input.focus()
await expect(control).toBeVisible()
await idleComposer(page)
await expect(control).toBeVisible()
})
async function idleComposer(page: Page) {
await input.click()
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
}
await expect(control).toHaveText("high")
await expect(control).toHaveCSS("opacity", "1")
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await input.click()
await page.mouse.move(0, 0)
await expect(control).toHaveText("default")
await expect(control).toHaveCSS("opacity", "0")
// The single-agent fixture has only Add and Model before the thinking trigger.
await page.keyboard.press("Tab")
await expect(composer.getByRole("button", { name: "Add images and files" })).toBeFocused()
await page.keyboard.press("Tab")
await expect(composer.getByRole("button", { name: "Thinking Model" })).toBeFocused()
await page.keyboard.press("Tab")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Escape")
await expect(control).toBeFocused()
await page.keyboard.press("Tab")
await expect(control).toHaveCSS("opacity", "0")
})
-1
View File
@@ -65,7 +65,6 @@
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -1,4 +1,4 @@
import { onMount } from "solid-js"
import { onCleanup, onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { ComposerAttachment, ComposerPrompt } from "../types"
@@ -78,6 +78,7 @@ export type ComposerAttachmentConfig = {
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
onDragCancel?: (callback: () => void) => () => void
store?: (file: File) => Promise<{ id: string; url: string }>
}
@@ -90,6 +91,9 @@ export function createComposerAttachments(
setDraggingType: (type: "image" | "@mention" | null) => void
},
) {
const clearDrag = () => {
input.setDraggingType(null)
}
const capture = () => {
const prompt = input.capture()
const editor = input.editor()
@@ -178,7 +182,7 @@ export function createComposerAttachments(
const handleDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
input.setDraggingType(null)
clearDrag()
const plainText = event.dataTransfer?.getData("text/plain")
if (plainText?.startsWith("file:")) {
const path = plainText.slice("file:".length)
@@ -191,6 +195,8 @@ export function createComposerAttachments(
}
onMount(() => {
const cancel = input.onDragCancel?.(clearDrag)
if (cancel) onCleanup(cancel)
makeEventListener(document, "dragover", (event) => {
if (input.isDialogActive()) return
event.preventDefault()
@@ -198,7 +204,10 @@ export function createComposerAttachments(
else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
})
makeEventListener(document, "dragleave", (event) => {
if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
if (!input.isDialogActive() && !event.relatedTarget) clearDrag()
})
makeEventListener(document, "keydown", (event) => {
if (event.key === "Escape") clearDrag()
})
makeEventListener(document, "drop", handleDrop)
})
@@ -134,6 +134,7 @@ function ComposerStory(props: {
},
variant: {
options: () => [
{ id: "default", label: "default" },
{ id: "balanced", label: "balanced" },
{ id: "high", label: "high" },
],
+87
View File
@@ -0,0 +1,87 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { createAnimatedPresence } from "@/runtime/animated-presence"
export function ComposerDropzone(props: {
active: boolean
input?: { image?: boolean; pdf?: boolean }
identity?: () => unknown
}) {
const language = useLanguage()
const [elements, setElements] = createStore<{ dropzone?: HTMLDivElement }>({})
const label = createMemo(() => {
if (!props.input?.image && !props.input?.pdf) return language.t("ui.promptInput.dropFiles")
if (!props.input.pdf) return language.t("ui.promptInput.dropFiles.image")
if (!props.input.image) return language.t("ui.promptInput.dropFiles.pdf")
return language.t("ui.promptInput.dropFiles.imagePdf")
})
const presence = createAnimatedPresence(
() => (props.active ? label() : undefined),
() => elements.dropzone ?? null,
props.identity,
)
return (
<>
<div
data-slot="session-dropzone-blur"
data-visible={props.active}
class="pointer-events-none absolute inset-0 z-[79] rounded-[inherit] opacity-[0.001] backdrop-blur-[1.5px] transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] will-change-[opacity,backdrop-filter] data-[visible=true]:opacity-100 motion-reduce:transition-none"
style={{
"-webkit-mask-image":
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
"-webkit-mask-composite": "source-in",
"mask-image":
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
"mask-composite": "intersect",
}}
/>
<Show when={presence.present()}>
<div
ref={(element) => setElements("dropzone", element)}
data-component="session-dropzone"
data-visible={props.active}
class="pointer-events-none absolute inset-0 z-[80] grid place-items-center overflow-hidden rounded-[inherit] bg-[color-mix(in_srgb,var(--v2-text-text-base)_var(--session-dropzone-wash),transparent)] opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:opacity-0 motion-reduce:transition-none"
>
<div class="absolute inset-0 bg-v2-background-bg-base/25" />
<div
class="absolute inset-y-0 left-1/2 w-full -translate-x-1/2 md:max-w-200 2xl:max-w-[1000px]"
style={{
"-webkit-mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
"mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
}}
>
<div
data-slot="session-dropzone-stripes"
class="absolute inset-0 opacity-60"
style={{
background:
"repeating-linear-gradient(135deg, transparent 0px, transparent 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 24px)",
"-webkit-mask-image":
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
"mask-image":
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
}}
/>
</div>
<div
data-slot="session-dropzone-content"
class="relative flex translate-y-0 flex-col items-center gap-5 opacity-100 transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:translate-y-1 data-[visible=false]:opacity-0 motion-reduce:transition-none"
data-visible={props.active}
>
<div
data-slot="session-dropzone-upload"
class="flex size-10 items-center justify-center rounded-full bg-[var(--session-dropzone-card)] text-v2-icon-icon-muted shadow-[var(--v2-elevation-floating)]"
aria-hidden="true"
>
<Icon name="arrow-up" size="normal" class="text-v2-icon-icon-muted" />
</div>
<div class="text-[15px] font-[530] leading-6 text-v2-text-text-base">{presence.value()}</div>
</div>
</div>
</Show>
</>
)
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerPersistedState } from "../types"
import { createComposerEditorActions } from "./actions"
@@ -97,4 +98,26 @@ describe("Composer store", () => {
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
})
test("prepends a slash skill to an attachment-only draft without flattening it", () => {
const prompt = createPromptStore()
prompt.setPrompt([{ type: "file", path: "one", content: "@one", start: 0, end: 4 }], 4)
prompt.addMention(
{
type: "skill",
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
content: "/show-me",
start: 0,
end: 0,
},
{ start: 0, end: 0 },
)
expect(prompt.state.prompt).toMatchObject([
{ type: "skill", id: "show-me", content: "/show-me", start: 0, end: 8 },
{ type: "text", content: " ", start: 8, end: 9 },
{ type: "file", path: "one", content: "@one", start: 9, end: 13 },
])
expect(prompt.state.cursor).toBe(9)
})
})
+9 -3
View File
@@ -69,12 +69,15 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
clearRetry()
},
addMention(mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart) {
addMention(
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
range?: { start: number; end: number },
) {
const text = store()
.prompt.map((part) => ("content" in part ? part.content : ""))
.join("")
const end = store().cursor ?? text.length
const start = text.slice(0, end).lastIndexOf("@")
const end = range?.end ?? store().cursor ?? text.length
const start = range?.start ?? text.slice(0, end).lastIndexOf("@")
setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention))
setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1)
clearRetry()
@@ -113,6 +116,9 @@ function insertMention(
end: number,
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
): ComposerPrompt {
if (start === 0 && end === 0) {
return withOffsets([mention, { type: "text", content: " ", start: 0, end: 0 }, ...prompt])
}
let position = 0
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (part.type === "image") return [part]
@@ -2,6 +2,11 @@
content: "\200B";
}
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
+3 -7
View File
@@ -118,7 +118,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
onSubmit={(event) => {
event.preventDefault()
@@ -129,12 +128,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
onDragLeave={props.controller.onDragLeave}
onDrop={props.controller.onDrop}
>
<Show when={state.drag === "active"}>
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
{i18n.t("ui.promptInput.dropFiles")}
</div>
</Show>
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
@@ -270,6 +263,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
class={control.current() === "default" ? "composer-variant-default" : undefined}
/>
</Show>
)}
@@ -603,12 +597,14 @@ function ComposerEditorConfiguredSelect(props: {
keybind?: string[]
control: ComposerSelectControl
model?: boolean
class?: string
}) {
const current = () => props.control.current()
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
return (
<ComposerEditorSelect
title={props.title}
class={props.class}
keybind={props.control.keybind?.() ?? props.keybind}
options={props.control.options()}
current={current()}
@@ -145,7 +145,7 @@ export function createComposerEditor(input: {
return
}
if (command.type === "mention.add") {
if (command.item.mention) draft.addMention(command.item.mention)
if (command.item.mention) draft.addMention(command.item.mention, command.range)
return
}
if (command.type === "popover.filter") {
+35 -8
View File
@@ -21,7 +21,7 @@ import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { createComposerSubmit, withSlashSkill } from "./submit"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -42,9 +42,14 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const interaction = createComposerEditorState(prompt.mode.current())
createEffect(
on(adapter.ready, (ready) => {
if (ready) interaction[1]("mode", prompt.mode.current())
}),
on(
() => (adapter.ready() ? prompt.mode.current() : undefined),
(mode) => {
if (!mode) return
// Project external draft changes without another mode write clearing restored retry metadata.
interaction[1](mode === "shell" ? { mode, popover: { type: "closed" } } : { mode })
},
),
)
const mode = () => interaction[0].mode
const history = createComposerHistory()
@@ -236,22 +241,42 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
type: "builtin" as const,
})),
])
const commands = createMemo<ComposerSuggestion[]>(() =>
slashCommands().map((item) => ({
const slashSkills = createMemo(() =>
skills().filter((skill) => skill.slash === true && !slashCommands().some((item) => item.trigger === skill.id)),
)
const commands = createMemo<ComposerSuggestion[]>(() => [
...slashCommands().map((item) => ({
id: item.id,
kind: "command",
kind: "command" as const,
label: `/${item.trigger}`,
trigger: item.trigger,
title: item.title,
description: item.description,
keybind: command.keybindParts(item.id),
})),
)
...slashSkills().map((skill) => ({
id: `skill:${skill.id}`,
kind: "skill" as const,
label: `/${skill.id}`,
trigger: skill.id,
title: skill.name,
description: skill.description,
mention: {
type: "skill" as const,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content: `/${skill.id}`,
start: 0,
end: 0,
},
})),
])
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
skills: slashSkills,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
@@ -346,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
onDragCancel: platform.onDragCancel,
store: platform.draftStore?.putBlob,
},
view: {
@@ -377,6 +403,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
prompt.set(withSlashSkill(prompt.current(), slashSkills()))
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
+132 -5
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Skill } from "@opencode-ai/schema/skill"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
@@ -31,6 +32,16 @@ const selection = {
},
} satisfies ModelSelection
const slashSkill = Skill.Info.make({
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
description: "Explain visually",
slash: true,
autoinvoke: false,
location: AbsolutePath.make("/skills/show-me/SKILL.md"),
content: "Explain visually",
})
function controls(): ComposerControls {
return {
agents: {
@@ -53,11 +64,13 @@ function submitInput(
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
skills: () => readonly Skill.Info[] | undefined = () => [],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
skills,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -109,6 +122,107 @@ function session(input: {
}
describe("Composer submission", () => {
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
{ type: "text", content: "/show-me explain ", start: 0, end: 17 },
{ type: "file", path: "src/cache.ts", content: "@src/cache.ts", start: 17, end: 30 },
])
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [slashSkill],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe("/show-me explain @src/cache.ts")
expect(request.skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
expect(request.files).toEqual([
{ uri: "file:///C:/repo/src/cache.ts", name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } },
])
})
test.each([
{ text: "/show-me", slash: true, expected: ["show-me"] },
{ text: "/show-me\nexplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me\texplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me", slash: false, expected: [] },
{ text: "/show-me", slash: undefined, expected: [] },
{ text: "/show-me-extra", slash: true, expected: [] },
{ text: "Explain /show-me", slash: true, expected: [] },
])("resolves raw slash skill input $text with slash=$slash", async ({ text, slash, expected }) => {
const state = createMemoryComposerState({ prompt: text }).capture()
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [{ ...slashSkill, slash }],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe(text)
expect(request.skills?.map((skill) => skill.id)).toEqual([...expected])
})
test("captures slash skills before creating a session in a new worktree", async () => {
const state = createMemoryComposerState({ prompt: "/show-me" }).capture()
let skills: readonly Skill.Info[] | undefined = [slashSkill]
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
skills = undefined
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => skills,
).submit(new Event("submit"))
expect((await admitted.promise).skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
})
test("sends one captured value with explicit delivery after selection switches", async () => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
const calls: string[] = []
@@ -357,8 +471,8 @@ describe("Composer submission", () => {
expect(promoted.mode.current()).toBe("shell")
})
test("reuses the message ID when an unacknowledged admission is retried", async () => {
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
test.each(["retry me", "/show-me retry me"])("restores and retries an unacknowledged admission: %s", async (text) => {
const state = createMemoryComposerState({ prompt: text }).capture()
const attempts: string[] = []
const statuses: ("idle" | "running")[] = []
const first = Promise.withResolvers<void>()
@@ -368,6 +482,7 @@ describe("Composer submission", () => {
statuses,
prompt: async (value) => {
attempts.push(value.id ?? "")
expect(value.skills?.map((skill) => skill.id)).toEqual(text.startsWith("/") ? ["show-me"] : [])
throw new Error("network unavailable")
},
})
@@ -386,7 +501,13 @@ describe("Composer submission", () => {
missingSelection() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const submission = submitInput(adapter, notify)
const submission = submitInput(
adapter,
notify,
"normal",
() => [],
() => [slashSkill],
)
await submission.submit(new Event("submit"))
await first.promise
@@ -396,7 +517,7 @@ describe("Composer submission", () => {
expect(attempts).toHaveLength(4)
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
expect(state.current()).toMatchObject([{ type: "text", content: text }])
})
test("forwards structured mentions to custom commands", async () => {
@@ -472,7 +593,13 @@ describe("Composer submission", () => {
},
}
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
await submitInput(
adapter,
undefined,
"normal",
() => catalog,
() => [{ ...slashSkill, id: Skill.ID.make("review") }],
).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
expect(requests).toEqual([
+25 -1
View File
@@ -1,5 +1,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionMessageUser, SkillInfo } from "@opencode-ai/client/promise"
import { Skill } from "@opencode-ai/schema/skill"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
@@ -28,6 +29,7 @@ type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
skills: Accessor<readonly Pick<SkillInfo, "id" | "name" | "slash">[] | undefined>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
@@ -68,6 +70,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
if (value.mode === "normal" && !command) value.prompt = withSlashSkill(value.prompt, input.skills())
try {
const started =
@@ -287,6 +290,27 @@ function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text
return { command, arguments: arguments_.join(" ") }
}
export function withSlashSkill(prompt: Prompt, skills: ReturnType<ComposerSubmitInput["skills"]>): Prompt {
const first = prompt[0]
if (first?.type !== "text") return prompt
const name = /^\/(\S+)(?:\s|$)/.exec(first.content)?.[1]
const skill = skills?.find((item) => item.slash === true && item.id === name)
if (!skill || prompt.some((part) => part.type === "skill" && part.id === skill.id)) return prompt
const content = `/${skill.id}`
return [
{
type: "skill",
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content,
start: 0,
end: content.length,
},
{ ...first, content: first.content.slice(content.length), start: content.length },
...prompt.slice(1),
]
}
async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
@@ -35,7 +35,7 @@ export type ComposerInteractionEvent =
export type ComposerInteractionCommand =
| { type: "draft.setText"; value: string }
| { type: "draft.addText"; value: string }
| { type: "mention.add"; item: ComposerSuggestion }
| { type: "mention.add"; item: ComposerSuggestion; range?: { start: number; end: number } }
| { type: "popover.filter"; popover: "command" | "context"; query: string }
| { type: "suggestion.select"; id: string }
| { type: "focus.editor" }
@@ -178,7 +178,13 @@ function suggestionSelected(
: replaceTrigger(current, "/", `${item.label} `),
})
} else {
commands.push({ type: "mention.add", item })
commands.push({
type: "mention.add",
item,
...(item.kind === "skill" && item.label.startsWith("/")
? { range: { start: 0, end: state.popover.type === "command-menu" ? 0 : current.length } }
: {}),
})
}
commands.push({ type: "focus.editor" })
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands)
-10
View File
@@ -4,16 +4,6 @@ export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
BrowserPaneCommand,
BrowserPaneEndpoint,
BrowserPaneEvent,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneState,
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
+63
View File
@@ -319,6 +319,69 @@
}
}
@keyframes session-dropzone-enter {
from {
opacity: 0;
}
}
@keyframes session-dropzone-content-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.96);
}
}
@keyframes session-dropzone-upload-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.92);
}
}
@keyframes session-dropzone-stripes-drift {
to {
background-position: 33.941px 0;
}
}
[data-component="session-dropzone"][data-visible="true"] {
animation: session-dropzone-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"] {
animation: session-dropzone-content-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"] {
animation: session-dropzone-upload-enter 240ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
animation: session-dropzone-stripes-drift 3s linear infinite;
}
[data-component="session-dropzone"] {
--session-dropzone-wash: 4%;
--session-dropzone-stripe: 7%;
--session-dropzone-card: var(--v2-background-bg-base);
}
[data-color-scheme="dark"] [data-component="session-dropzone"] {
--session-dropzone-wash: 6%;
--session-dropzone-stripe: 14%;
--session-dropzone-card: var(--v2-background-bg-layer-01);
}
@media (prefers-reduced-motion: reduce) {
[data-component="session-dropzone"][data-visible="true"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
animation: none;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
+5
View File
@@ -6,6 +6,7 @@ import { Show, createMemo, createSignal } from "solid-js"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
import type { ComposerModel } from "@/composer/model"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/new-session/workspace/selector"
import {
@@ -54,6 +55,10 @@ export function NewSessionView(props: {
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<ComposerDropzone
active={props.composer.state.drag === "active"}
input={props.composer.model.selection.current()?.capabilities.input}
/>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
+1 -12
View File
@@ -666,11 +666,9 @@ export const dict = {
"session.tab.usage": "Usage",
"session.view.select": "Session view",
"session.tab.review": "Review",
"session.tab.browser": "Browser",
"session.tab.add": "Add tab",
"session.tab.context": "Context",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review, files, and browser",
"session.panel.reviewAndFiles": "Review and files",
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
@@ -812,10 +810,6 @@ export const dict = {
"PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.",
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
"session.browser.address": "Browser address",
"session.browser.replaced": "Browser control moved to another desktop window.",
"session.browser.address.placeholder": "Enter a URL",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.channel.local": "Local",
@@ -1043,8 +1037,6 @@ export const dict = {
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
"settings.general.row.browserPane.title": "Browser pane",
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
@@ -1234,9 +1226,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Search the web",
"settings.permissions.tool.browser_read.description": "Read pages and capture screenshots in the browser",
"settings.permissions.tool.browser_navigate.description": "Navigate the browser to a URL",
"settings.permissions.tool.browser_interact.description": "Click, type, and interact with pages in the browser",
"settings.permissions.tool.external_directory.title": "External Directory",
"settings.permissions.tool.external_directory.description": "Access files outside the project directory",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
@@ -1,27 +0,0 @@
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
export type BrowserPaneLayout = {
tabID: Browser.TabID
visible: boolean
bounds?: { x: number; y: number; width: number; height: number }
background?: readonly [number, number, number, number]
radius?: number
}
export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
close(): void
}
export type BrowserPanePlatform = {
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
}
@@ -6,7 +6,6 @@ import { ServerConnection } from "@/runtime/server/registry"
import type { WslServersPlatform } from "@/servers/wsl/types"
import type { UpdaterPlatform } from "@/shell/updates/types"
import type { DraftStore } from "@/runtime/persistence/drafts"
import type { BrowserPanePlatform } from "./browser-pane"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -60,6 +59,9 @@ type PlatformBase = {
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string
/** Observe native drag cancellation that does not reach the renderer event loop. */
onDragCancel?(callback: () => void): () => void
/** Open a native save file dialog and write content to the selected path (desktop only) */
saveFile?(opts: SaveFilePickerOptions, content: string): Promise<boolean>
@@ -116,9 +118,6 @@ type PlatformBase = {
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
}
export type Platform = PlatformBase &
-152
View File
@@ -1,152 +0,0 @@
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
import { createStore, reconcile } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import { useServer } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import type { SessionModel } from "../model"
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
export function createSessionBrowser(session: SessionModel) {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const server = useServer()
const [state, setState] = createStore({
registration: undefined as BrowserPaneRegistration | undefined,
browser: null as BrowserPaneState,
error: undefined as string | undefined,
// The connected server has no browser plugin.
unsupported: false,
})
const eligible = createMemo(
() =>
!!platform.browserPane &&
settings.ready() &&
settings.general.experimentalBrowser() &&
!!session.identity.sessionID() &&
!server.health?.incompatible &&
!state.unsupported,
)
const available = createMemo(() => eligible() && session.isDesktop())
const browserTabs = createMemo(
() => state.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ?? [],
)
const opened = () => state.registration !== undefined && browserTabs().length > 0
const focus = (tabID: Browser.TabID) => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
const key = sessionBrowserTab(tabID)
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
tabs.setActive(key)
}
const command = (command: BrowserPaneCommand) => {
setState("error", undefined)
const owner = session.ownership.capture()
void state.registration?.command(command).catch(() => {
if (owner.current()) setState("error", language.t("common.requestFailed"))
})
}
createEffect(
on(
() => session.layout.tabs().active(),
(active) => {
const tab = state.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active)
if (tab && tab.id !== state.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id })
},
),
)
createEffect(
on(
() => session.layout.tabs().all(),
(current, previous) => {
previous
?.filter((key) => isSessionBrowserTab(key) && !current.includes(key))
.forEach((key) => {
const tab = state.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key)
if (tab) command({ type: "tabs.close", tabID: tab.id })
})
},
),
)
createEffect(() => {
const sessionID = session.identity.sessionID()
const pane = platform.browserPane
setState({ registration: undefined, browser: null, error: undefined })
if (!eligible() || !sessionID || !pane) return
const owner = session.ownership.capture()
const target = { sessionID, endpoint: server.conn.http }
let registration: BrowserPaneRegistration | undefined
let retry: ReturnType<typeof setTimeout> | undefined
let attempts = 0
const register = () => {
if (registration) return
registration = pane.register(target, (event) =>
owner.run(() => {
if (event.type === "focus") return focus(event.tabID)
if (event.error === "browser.pane.unsupported") return setState("unsupported", true)
if (event.error === "browser.pane.replaced") {
registration?.close()
registration = undefined
setState({ registration: undefined, browser: null, error: language.t("session.browser.replaced") })
return
}
// The desktop dropped the attachment (server restart, attach race).
// Re-register so the agent's browser tool comes back without a reload.
if (event.error === "browser.pane.registration.closed") {
registration?.close()
registration = undefined
setState({ registration: undefined, browser: null, error: undefined })
retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** attempts++))
return
}
if (event.state) attempts = 0
batch(() => {
const known = new Set(state.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)) ?? [])
setState("browser", reconcile(event.state))
setState("error", event.error ? language.t("common.requestFailed") : undefined)
const tabs = session.layout.tabs()
const ids = event.state?.tabs.map((tab) => sessionBrowserTab(tab.id)) ?? []
tabs
.all()
.filter((key) => isSessionBrowserTab(key) && !ids.includes(key))
.forEach(tabs.close)
const current = tabs.all()
const added = ids.filter((key) => !known.has(key) && !current.includes(key))
if (added.length) tabs.setAll([...current, ...added])
})
}),
)
setState({ registration, browser: null, error: undefined })
}
// A new session appears in the UI before its server-side creation finishes.
const unsubscribe = session.shared.data.on("session.created", (event) => {
if (event.data.sessionID === sessionID) register()
})
if (!session.shared.data.session.creating(sessionID)) register()
onCleanup(() => {
unsubscribe()
clearTimeout(retry)
registration?.close()
})
})
return {
available,
opened,
state: () => state.browser,
tabs: browserTabs,
active: () =>
browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ??
browserTabs().find((tab) => tab.id === state.browser?.focusedTabID) ??
browserTabs()[0],
error: () => state.error,
registration: () => state.registration,
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
open: () => command({ type: "tabs.open" }),
command,
}
}
-180
View File
@@ -1,180 +0,0 @@
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Loader } from "@opencode-ai/ui/loader"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, For, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: {
registration: BrowserPaneRegistration
browser: ReturnType<typeof createSessionBrowser>
visible: boolean
}) {
const platform = usePlatform()
const language = useLanguage()
const dialog = useDialog()
const state = props.browser.active
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
let surface: HTMLDivElement | undefined
let frame: number | undefined
let layout: string | undefined
let until = 0
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
// The native page always paints above the DOM, so hide it while a floating
// menu, select, or popover overlaps it. Tooltips are excluded.
const covered = (rect: DOMRect) =>
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const measure = () => {
frame = undefined
if (!surface) return
const tab = state()
if (!tab) {
props.registration.setLayout()
return
}
const rect = surface.getBoundingClientRect()
const zoom = platform.webviewZoom?.() ?? 1
const left = Math.round(rect.left * zoom)
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
).backgroundColor
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
if (next !== layout) {
layout = next
// Let the browser resolve the semantic backdrop color, including custom
// themes using color formats that Electron's color parser cannot read.
if (paint) {
paint.clearRect(0, 0, 1, 1)
paint.fillStyle = color
paint.fillRect(0, 0, 1, 1)
}
const rgba = paint?.getImageData(0, 0, 1, 1).data
props.registration.setLayout({
tabID: tab.id,
visible,
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
radius: Math.round(10 * zoom),
})
}
if (performance.now() < until) frame = requestAnimationFrame(measure)
}
const schedule = (duration = 0) => {
until = Math.max(until, performance.now() + duration)
if (frame === undefined) frame = requestAnimationFrame(measure)
}
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
createEffect(
on(
[
() => platform.webviewZoom?.(),
() => dialog.active,
() => store.visible,
() => props.visible,
() => state()?.id,
],
() => schedule(300),
),
)
createResizeObserver(() => surface, schedule.bind(null, 0))
createEventListener(window, "resize", () => schedule(300))
// Floating content portals directly into <body>; keep measuring briefly so
// the positioner has settled before the overlap check runs.
const portals = new MutationObserver(() => schedule(300))
portals.observe(document.body, { childList: true })
onCleanup(() => portals.disconnect())
const appearance = new MutationObserver(() => schedule(300))
appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
onCleanup(() => appearance.disconnect())
createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300))
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
props.registration.setLayout()
})
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
<For each={["back", "forward"] as const}>
{(direction) => (
<IconButton
{...button}
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: direction, tabID: tab.id })
}}
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
/>
)}
</For>
<IconButton
{...button}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
}}
icon={
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
<Loader />
</Show>
}
/>
<form
class="min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault()
const tab = state()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
}}
>
<input
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
value={store.address}
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
onInput={(event) => setStore("address", event.currentTarget.value)}
/>
</form>
</div>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
</aside>
)
}
+2 -4
View File
@@ -4,16 +4,14 @@ import { useComposerState } from "@/composer/persistence"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { SessionModel } from "../model"
export function createActiveComposerAdapter(input: {
session: SessionModel
sessionID: string
controls: Accessor<ComposerControls>
submitted: () => void
setEditor: (element: HTMLDivElement) => void
}) {
const id = input.session.identity.params.id
if (!id) throw new Error("Active Composer requires a Session ID")
const id = input.sessionID
const prompt = useComposerState()
prompt.current()
@@ -0,0 +1,57 @@
import { createEffect, createMemo, on, type Accessor } from "solid-js"
import type { ComposerControls } from "@/composer/adapter"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerModel } from "@/composer/model"
import { useSettings } from "@/settings/model"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
import { createSessionComposerRegionController } from "./session-composer-region-controller"
export function createSessionComposerController(input: {
sessionID: string
controls: Accessor<ComposerControls>
dock: Parameters<typeof createSessionComposerRegionController>[0]
}) {
const settings = useSettings()
const region = createSessionComposerRegionController(input.dock)
let editor: HTMLDivElement | undefined
const adapter = createActiveComposerAdapter({
sessionID: input.sessionID,
controls: input.controls,
submitted: region.onResponseSubmit,
setEditor: (element) => {
editor = element
region.setPromptRef(element)
},
})
const queue = createSessionQueue({
sessionID: input.sessionID,
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
restoreFocus: (cursor) => {
const target = editor
if (!target) return
requestAnimationFrame(() => {
target.focus()
setCursorPosition(target, cursor)
})
},
})
const composer = createComposerModel(adapter, { queue })
const editable = createMemo(() => region.showComposer() && !region.child())
// Requests hide the view without disposing its draft or queue edit.
createEffect(on(editable, () => composer.onDragLeave()))
return {
region,
queue,
composer,
drop: {
active: () => editable() && composer.state.drag === "active",
input: () => composer.model.selection.current()?.capabilities.input,
},
}
}
export type SessionComposerController = ReturnType<typeof createSessionComposerController>
+5 -6
View File
@@ -4,7 +4,6 @@ import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerModel } from "@/composer/model"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
@@ -30,7 +29,7 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
composer: Accessor<ComposerModel | undefined>
restoreFocus: (cursor: number) => void
}) {
const data = useData()
const server = useServerSDK()
@@ -159,9 +158,9 @@ export function createSessionQueue(input: {
},
})
const text = queuedPromptText(item)
input.composer()?.dispatch({ type: "mode.normal" })
input.draft.mode.set("normal")
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
input.composer()?.restoreFocus(text.length)
input.restoreFocus(text.length)
return true
}
const cancelEdit = () => {
@@ -170,10 +169,10 @@ export function createSessionQueue(input: {
setState("editing", undefined)
// Mode first, then prompt, then retry: mode and prompt writes both clear
// the retry marker.
input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" })
input.draft.mode.set(editing.stash.mode)
input.draft.set(editing.stash.prompt, editing.stash.cursor)
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
input.composer()?.restoreFocus(editing.stash.cursor)
input.restoreFocus(editing.stash.cursor)
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
+31 -62
View File
@@ -4,9 +4,8 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, on, onMount } from "solid-js"
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
import { Composer } from "@/composer/composer"
import { createComposerModel, type ComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
import { setCursorPosition } from "@/composer/editor/dom"
@@ -25,18 +24,16 @@ import { restorePromptModel, syncPromptModel, syncSessionModel } from "../sessio
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
import { createSessionComposerRegionController } from "./session-composer-region-controller"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
import { createSessionComposerController, type SessionComposerController } from "./controller"
import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { useSettings } from "@/settings/model"
export function createActiveSessionRegion(input: {
session: SessionModel
screen: SessionScreenLayout
timeline: SessionTimelineInteraction
visible: Accessor<boolean>
}) {
const command = useCommand()
const dialog = useDialog()
@@ -180,7 +177,30 @@ export function createActiveSessionRegion(input: {
},
])
const dock = {
state,
parentID: input.session.data.parentID,
centered: input.screen.centered,
onResponseSubmit: input.timeline.actions.resume,
openParent,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
setDockRef: input.timeline.view.setDockRef,
}
const active = createMemo(
on(
() => (input.visible() ? input.session.identity.sessionID() : undefined),
(sessionID) => (sessionID ? createSessionComposerController({ sessionID, controls, dock }) : undefined),
),
)
return {
active,
drop: {
active: () => active()?.drop.active() ?? false,
input: () => active()?.drop.input(),
},
actions: {
timeline: {
get revert() {
@@ -190,76 +210,25 @@ export function createActiveSessionRegion(input: {
openAttachment,
} satisfies SessionUserActions,
},
region: {
centered: input.screen.centered,
openParent,
prompt,
setDockRef: input.timeline.view.setDockRef,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
state,
},
input: {
controls,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
},
submitted: () => input.timeline.actions.resume(),
requests: state,
workspaceMoveEligible: () => true,
}
}
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: {
model: ActiveSessionRegionModel
session: SessionModel
onResponseSubmit: () => void
}) {
const settings = useSettings()
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
centered: props.model.region.centered,
onResponseSubmit: props.onResponseSubmit,
openParent: props.model.region.openParent,
setPromptRef: props.model.region.setPromptRef,
setDockRef: props.model.region.setDockRef,
})
const adapter = createActiveComposerAdapter({
session: props.session,
controls: props.model.input.controls,
submitted: props.model.submitted,
setEditor: props.model.input.setPromptRef,
})
let composer: ComposerModel | undefined
const queue = createSessionQueue({
sessionID: requireSessionID(props.session),
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
composer: () => composer,
})
composer = createComposerModel(adapter, { queue })
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
return (
<SessionComposerRegion
controller={region}
controller={props.model.region}
composer={
<div class="relative">
<SessionQueuePanel queue={queue} />
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay />
<Composer model={props.model.composer} borderUnderlay />
</div>
</div>
}
/>
)
}
function requireSessionID(session: SessionModel) {
const id = session.identity.params.id
if (!id) throw new Error("Active Composer requires a Session ID")
return id
}
@@ -12,7 +12,6 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Menu } from "@opencode-ai/ui/menu"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import FileTree from "@/session/files/file-tree"
@@ -21,8 +20,6 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
const browserTabID = "session-side-panel-browser-tab"
const browserTabPanelID = "session-side-panel-browser-tabpanel"
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab } from "@/session/files/session-context-tab"
import { SortableTab } from "@/session/files/tab"
@@ -36,8 +33,6 @@ import { useSettings } from "@/settings/model"
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
import {
SESSION_OPEN_FILE_TAB,
isSessionBrowserTab,
sessionBrowserTab,
createOpenSessionFileTab,
createSessionTabs,
shouldShowFileTree,
@@ -46,8 +41,6 @@ import {
import { setSessionHandoff } from "@/session/handoff"
import { useSessionLayout } from "@/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
import { SessionBrowserPane } from "@/session/browser/pane"
import type { createSessionBrowser } from "@/session/browser/model"
type ReviewDiff = FileDiffInfo
type RenderDiff = FileDiffInfo
@@ -72,7 +65,6 @@ export function SessionSidePanel(props: {
reviewPresent?: boolean
size: Sizing
stacked?: boolean
browser: ReturnType<typeof createSessionBrowser>
}) {
const layout = useLayout()
const settings = useSettings()
@@ -173,7 +165,6 @@ export function SessionSidePanel(props: {
review: reviewTab,
hasReview: () => props.canReview,
fileBrowser: () => true,
browser: props.browser.opened,
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
@@ -211,7 +202,7 @@ export function SessionSidePanel(props: {
openReviewPanel()
tabs().setActive(next)
}
const fileTab = createMemo(() => {
const browserTab = createMemo(() => {
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
@@ -221,11 +212,11 @@ export function SessionSidePanel(props: {
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
return openedTabs().length > 0 || openFileOpen() || !!fileTab()
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
@@ -307,7 +298,7 @@ export function SessionSidePanel(props: {
onCleanup(stop)
}}
>
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky left-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
</div>
<Show when={reviewTab() && props.canReview}>
@@ -412,73 +403,26 @@ export function SessionSidePanel(props: {
</Show>
)}
</For>
<For each={props.browser.tabs()}>
{(tab) => (
<Tabs.Trigger
value={sessionBrowserTab(tab.id)}
id={`${browserTabID}-${tab.id}`}
aria-controls={
activeTab() === sessionBrowserTab(tab.id) ? browserTabPanelID : undefined
}
onMiddleClick={() => props.browser.close(tab.id)}
closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom" gutter={10}>
<Tabs.CloseButton
onClick={() => props.browser.close(tab.id)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
>
<div class="flex items-center gap-1.5">
<Icon name="window-cursor" size="small" />
<span class="max-w-40 truncate">
{tab.title || language.t("session.tab.browser")}
</span>
</div>
</Tabs.Trigger>
)}
</For>
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
aria-label={language.t("session.tab.add")}
// The tablist redirects focus entering it to the selected
// tab, which counts as focus-outside and closes the menu.
onPointerDown={(event: PointerEvent) => event.preventDefault()}
/>
<Menu.Portal>
<Menu.Content>
<Menu.Item
onSelect={openFileBrowser}
shortcut={
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
}
>
<div class="flex items-center gap-2">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Menu.Item>
<Show when={props.browser.available()}>
<Menu.Item onSelect={props.browser.open}>
<div class="flex items-center gap-2">
<Icon name="window-cursor" size="small" />
<span>{language.t("session.tab.browser")}</span>
</div>
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
<div class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<Tooltip
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
</Tooltip>
</div>
</Tabs.List>
@@ -530,30 +474,6 @@ export function SessionSidePanel(props: {
</Tabs.Content>
</Show>
<Show when={props.browser.opened()}>
<div
id={browserTabPanelID}
role="tabpanel"
aria-labelledby={
props.browser.active() ? `${browserTabID}-${props.browser.active()?.id}` : undefined
}
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
inert={!isSessionBrowserTab(activeTab()) || undefined}
>
<Show when={props.browser.registration()} keyed>
{(registration) => (
<SessionBrowserPane
registration={registration}
browser={props.browser}
visible={isSessionBrowserTab(activeTab())}
/>
)}
</Show>
</div>
</Show>
<Show when={fileBrowserMounted()}>
<div
id={fileBrowserTabPanelID}
@@ -564,11 +484,11 @@ export function SessionSidePanel(props: {
inert={!fileBrowserVisible() || undefined}
>
<SessionFileBrowserTab
tab={fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={
(fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
}
active={file.pathFromTab(fileTab() ?? activeFileTab() ?? "")}
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
kinds={kinds()}
state={props.fileBrowserState}
onSelect={(path) => previewTab(file.tab(path))}
-38
View File
@@ -2,8 +2,6 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_BROWSER_TAB,
sessionBrowserTab,
SESSION_OPEN_FILE_TAB,
createOpenReviewFile,
createOpenSessionFileTab,
@@ -235,40 +233,4 @@ describe("createSessionTabs", () => {
dispose()
})
})
test("exposes one browser tab without treating it as a file tab", () => {
createRoot((dispose) => {
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
const result = createSessionTabs({
tabs,
pathFromTab: () => undefined,
normalizeTab: (tab) => tab,
browser: () => true,
})
expect(result.panelTabs()).toEqual([])
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
dispose()
})
})
test("keeps multiple browser tabs distinct from file tabs", () => {
createRoot((dispose) => {
const first = sessionBrowserTab("first")
const second = sessionBrowserTab("second")
const result = createSessionTabs({
tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }),
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice(7) : undefined),
normalizeTab: (tab) => tab,
browser: () => true,
})
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
expect(result.activeTab()).toBe(second)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(second)
dispose()
})
})
})
+5 -14
View File
@@ -2,14 +2,9 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/runtime/persistence/equality"
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
export {
SESSION_BROWSER_TAB,
SESSION_OPEN_FILE_TAB,
sessionBrowserTab,
isSessionBrowserTab,
} from "@/shell/state/session-tabs"
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
const emptyTabs: string[] = []
@@ -25,7 +20,6 @@ type TabsInput = {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: Accessor<boolean>
browser?: Accessor<boolean>
}
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
@@ -36,7 +30,6 @@ export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fileBrowser = input.fileBrowser ?? (() => false)
const browser = input.browser ?? (() => false)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openFileOpen = createMemo(
() =>
@@ -50,7 +43,7 @@ export const createSessionTabs = (input: TabsInput) => {
.tabs()
.all()
.flatMap((tab) => {
if (tab === "context" || tab === "review" || isSessionBrowserTab(tab)) return []
if (tab === "context" || tab === "review") return []
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
@@ -68,7 +61,6 @@ export const createSessionTabs = (input: TabsInput) => {
const active = input.tabs().active()
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (active === "review" && review()) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
@@ -78,16 +70,15 @@ export const createSessionTabs = (input: TabsInput) => {
if (review() && hasReview()) return "review"
return "empty"
})
const activeFileTab = createMemo<string | undefined>(() => {
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
const closableTab = createMemo<string | undefined>(() => {
const closableTab = createMemo(() => {
const active = activeTab()
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (!openedTabs().includes(active)) return
return active
})
+1 -7
View File
@@ -13,7 +13,6 @@ import { SessionSidePanel } from "../files/session-side-panel"
import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { createSessionBrowser } from "../browser/model"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
@@ -145,11 +144,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
)
}
export function SessionDesktopReview(props: {
review: SessionReviewModel
browser: ReturnType<typeof createSessionBrowser>
present?: boolean
}) {
export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) {
return (
<Suspense>
<SessionSidePanel
@@ -173,7 +168,6 @@ export function SessionDesktopReview(props: {
reviewPresent={props.present}
size={props.review.screen.size}
stacked={props.review.screen.side.layout().stacked}
browser={props.browser}
/>
</Suspense>
)
+12 -10
View File
@@ -15,6 +15,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
import { ComposerDropzone } from "@/composer/dropzone"
import type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
import { SessionPanelFrame } from "@/session/session-frame"
@@ -30,7 +31,6 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -45,7 +45,6 @@ export function SessionScreen(props: { session: SessionModel }) {
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const messagesReady = timeline.ready
@@ -136,8 +135,8 @@ export function SessionScreen(props: { session: SessionModel }) {
session,
screen,
timeline,
visible: conversationVisible,
})
useUsageExceededDialogs()
const sessionErrorFallback = (error: unknown, reset: () => void) => {
@@ -178,7 +177,7 @@ export function SessionScreen(props: { session: SessionModel }) {
review.mobile.setTab("changes")
session.layout.view().terminal.close()
}}
backgroundTasks={composer.region.state.background.tasks()}
backgroundTasks={composer.requests.background.tasks()}
/>
)}
</Show>
@@ -200,6 +199,11 @@ export function SessionScreen(props: { session: SessionModel }) {
const sessionPanelContent = () => (
<>
<ComposerDropzone
active={composer.drop.active()}
input={composer.drop.input()}
identity={session.layout.tabKey}
/>
<Show when={!isDesktop() && !!session.identity.params.id}>{mobileTabs()}</Show>
{/* Surface query errors without suspending session metadata while messages load. */}
<Show when={timeline.resource.error}>
@@ -237,7 +241,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<MessageTimeline
hideHeader={!isDesktop()}
session={session}
background={composer.region.state.background}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
@@ -265,10 +269,8 @@ export function SessionScreen(props: { session: SessionModel }) {
</Switch>
</div>
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
)}
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} />}
</Show>
</>
)
@@ -366,7 +368,7 @@ export function SessionScreen(props: { session: SessionModel }) {
setStore("sideReviewPresent", false)
}}
>
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
</div>
</Show>
</div>
+1 -1
View File
@@ -14,7 +14,7 @@ export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
export function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
return (
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
class="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": props.raised,
}}
@@ -242,7 +242,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
)
const title = createMemo(() =>
pending()
? language.t("command.session.new")
? language.t("session.tab.session")
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
)
const project = createMemo(() => {
@@ -1,8 +1,7 @@
import { Component, Show } from "solid-js"
import { Component } from "solid-js"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsList } from "@/settings/list"
import { useSettings } from "@/settings/model"
import { SettingsRow } from "@/settings/row"
@@ -13,7 +12,6 @@ const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical
export const SettingsExperimental: Component = () => {
const language = useLanguage()
const settings = useSettings()
const platform = usePlatform()
return (
<>
@@ -31,22 +29,6 @@ export const SettingsExperimental: Component = () => {
<div class="settings-tab-body">
<div class="settings-section">
<SettingsList>
<Show when={platform.browserPane}>
<SettingsRow
title={language.t("settings.general.row.browserPane.title")}
description={language.t("settings.general.row.browserPane.description")}
>
<div data-action="settings-experimental-browser">
<Switch
checked={settings.general.experimentalBrowser()}
onChange={settings.general.setExperimentalBrowser}
hideLabel
>
{language.t("settings.general.row.browserPane.title")}
</Switch>
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
-7
View File
@@ -67,7 +67,6 @@ describe("settings schema", () => {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
experimentalBrowser: false,
},
appearance: {
fontSize: 14,
@@ -131,12 +130,6 @@ describe("settings schema", () => {
expect(decode(encode(settings))).toEqual(settings)
})
test("browser attachment is opt-in and preserves an explicit choice", () => {
expect(decode({}).general.experimentalBrowser).toBe(false)
expect(decode({ general: { experimentalBrowser: true } }).general.experimentalBrowser).toBe(true)
expect(decode({ general: { experimentalBrowser: false } }).general.experimentalBrowser).toBe(false)
})
test.each([undefined, null, false, 7, "invalid", []].map((invalid) => [invalid]))(
"defaults malformed sections without losing other sections: %j",
(invalid) => {
-9
View File
@@ -95,7 +95,6 @@ const generalSchema = Persistence.struct({
mobileDiffWrap: Schema.Boolean,
terminalPlacement: Schema.Literals(["side", "bottom"]),
followUpBehavior: Schema.Literals(["queue", "steer"]),
experimentalBrowser: Schema.Boolean,
})
const appearanceSchema = Persistence.struct({
@@ -252,7 +251,6 @@ export const defaultSettings: Settings = {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
experimentalBrowser: false,
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
keybinds: {},
@@ -360,13 +358,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowUpBehavior(value: FollowUpBehavior) {
setStore("general", "followUpBehavior", value)
},
experimentalBrowser: withFallback(
() => store.general?.experimentalBrowser,
defaultSettings.general.experimentalBrowser,
),
setExperimentalBrowser(value: boolean) {
setStore("general", "experimentalBrowser", value)
},
},
visibility: {
fileTree: showFileTree,
+2 -1
View File
@@ -647,7 +647,8 @@
.settings-tab-search {
position: relative;
width: 100%;
width: calc(100% - 2px);
margin-inline: 1px;
}
.settings-tab-search [data-component="text-input-v2"] {
@@ -42,7 +42,7 @@ export const SettingsProjects: Component = () => {
const name = () => displayName(props.project)
return (
<div
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
+1 -1
View File
@@ -64,7 +64,7 @@ export default function Layout(props: ParentProps) {
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
@@ -1,8 +1,4 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export const SESSION_BROWSER_TAB = "browser"
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
export const isSessionBrowserTab = (tab: string | undefined) =>
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
export type SessionTabs = {
active?: string
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { render } from "solid-js/web"
import { createComposerAttachments } from "@/composer/attachments/attachments"
test("clears drag state on platform cancellation", async () => {
let cancel = () => {}
let subscribed = false
let dragging: "image" | "@mention" | null = "image"
const dispose = render(() => {
createComposerAttachments({
capture: () => ({ current: () => [], cursor: () => 0, set: () => {} }),
editor: () => undefined,
focusEditor: () => {},
addPart: () => false,
setDraggingType: (type) => (dragging = type),
directory: () => "",
isDialogActive: () => false,
warn: () => {},
duplicate: () => {},
onError: () => {},
onDragCancel: (callback) => {
cancel = callback
subscribed = true
return () => (subscribed = false)
},
})
return null
}, document.createElement("div"))
await Promise.resolve()
cancel()
expect(dragging).toBeNull()
dispose()
expect(subscribed).toBeFalse()
})
+1 -3
View File
@@ -128,9 +128,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
external: ["node-gyp"],
format: "esm",
minify: true,
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
bytecode: false,
bytecode: true,
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
+14
View File
@@ -967,6 +967,20 @@ export type SessionLogOutput =
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly providerContext?:
| {
readonly version: 1
readonly provenance: {
readonly providerID: Provider.ID
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: Schema.Json
}
| undefined
readonly text: string
readonly recent: string
}
+83 -34
View File
@@ -138,6 +138,15 @@ export type SessionMessageCompactionRunning = {
recent: string
}
export type SessionProviderContextProvenance = {
providerID: string
provider: string
modelID: string
route: string
protocol: string
endpoint: string
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -510,19 +519,6 @@ export type SessionMessageAssistantReasoning = {
time?: { created: number; completed?: number }
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
}
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
@@ -537,6 +533,8 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
id: string
sessionID: string
@@ -1343,23 +1341,6 @@ export type SessionToolCalled = {
}
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
text: string
recent: string
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
@@ -1740,10 +1721,37 @@ export type SessionMessageToolStateError = {
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
providerContext?: SessionProviderContext
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
providerContext?: SessionProviderContext
text: string
recent: string
}
}
export type SessionForked = {
id: string
@@ -2082,6 +2090,11 @@ export type SessionMessageAssistantTool = {
time: { created: number; ran?: number; completed?: number }
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
@@ -3077,6 +3090,18 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
@@ -3356,6 +3381,18 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
@@ -3635,6 +3672,18 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
+12
View File
@@ -6,6 +6,7 @@
import type {
AgentInfo,
CommandInfo,
ConfigEntry,
FormCancelInput,
FormInfo,
FormReplyInput,
@@ -84,6 +85,7 @@ type LocationData = {
vcs?: VcsInfo
agent?: AgentInfo[]
command?: CommandInfo[]
config?: ConfigEntry[]
integration?: IntegrationInfo[]
mcpServer?: McpServer[]
mcpResource?: McpResource[]
@@ -1215,6 +1217,11 @@ export function createData(config: CreateDataInput) {
)
break
case "config.updated":
result.location.config.invalidate(location)
if (result.location.config.list(location) !== undefined || sync.has(`location.config:${locationKey(location)}`))
refresh(() => result.location.config.sync(location))
refresh(() => result.location.websearch.refresh(location))
break
case "websearch.updated":
refresh(() => result.location.websearch.refresh(location))
break
@@ -1790,6 +1797,7 @@ export function createData(config: CreateDataInput) {
result.location.vcs.invalidate(location)
result.location.agent.invalidate(location)
result.location.command.invalidate(location)
result.location.config.invalidate(location)
result.location.integration.invalidate(location)
result.location.mcp.server.invalidate(location)
result.location.mcp.resource.invalidate(location)
@@ -1803,6 +1811,10 @@ export function createData(config: CreateDataInput) {
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
agent: locationResource("agent", (location) => api().agent.list({ location })),
command: locationResource("command", (location) => api().command.list({ location })),
config: locationResource("config", async (location) => ({
location: { directory: location.directory, workspaceID: location.workspace },
data: await api().config.get({ location }),
})),
integration: locationResource("integration", (location) => api().integration.list({ location })),
mcp: {
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
@@ -3,6 +3,56 @@ import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("config reads and update refreshes are opt-in", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: string[] = []
const location = { directory: "/project" }
let model = "provider/first"
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/api/location") return Response.json(location)
if (url.pathname === "/api/config") return Response.json([{ type: "document", info: { model } }])
if (url.pathname === "/api/mcp/resource")
return Response.json({ location, data: { resources: [], templates: [] } })
return Response.json({ location, data: [] })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: location.directory,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const event: OpenCodeEvent = { id: "evt_config", created: 1, type: "config.updated", location, data: {} }
try {
await setup.data.location.sync()
listeners.forEach((listener) => listener({ name: event.type, details: event }))
expect(requests).not.toContain("/api/config")
await setup.data.location.config.sync()
expect(requests.filter((path) => path === "/api/config")).toHaveLength(1)
model = "provider/second"
listeners.forEach((listener) => listener({ name: event.type, details: event }))
await setup.data.location.config.sync()
expect(requests.filter((path) => path === "/api/config")).toHaveLength(2)
expect(setup.data.location.config.list()).toEqual([{ type: "document", info: { model } }])
} finally {
setup.dispose()
}
})
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const reported = Promise.withResolvers<unknown>()
@@ -1109,6 +1109,8 @@ export async function handler(
authInfo = authInfo!
const cost = centsToMicroCents(totalCostInCent)
// Keep period bounds and persisted timestamps on one snapshot when a queued write crosses a reset boundary.
const trackedAt = new Date()
// For hot workspaces, batch balance/usage updates through Redis to avoid
// row-level lock contention on BillingTable/UserTable. Returns the amount
@@ -1149,7 +1151,7 @@ export async function handler(
if (billingSource === "subscription") {
const plan = authInfo.billing.subscription!.plan
const black = BlackData.getLimits({ plan })
const week = getWeekBounds(new Date())
const week = getWeekBounds(trackedAt)
const rollingWindowSeconds = black.rollingWindow * 3600
return [
db
@@ -1157,11 +1159,17 @@ export async function handler(
.set({
fixedUsage: sql`
CASE
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.end} THEN ${SubscriptionTable.fixedUsage}
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
ELSE ${cost}
END
`,
timeFixedUpdated: sql`now()`,
timeFixedUpdated: sql`
CASE
WHEN ${SubscriptionTable.timeFixedUpdated} > ${trackedAt} THEN ${SubscriptionTable.timeFixedUpdated}
ELSE ${trackedAt}
END
`,
rollingUsage: sql`
CASE
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
@@ -1185,8 +1193,8 @@ export async function handler(
}
if (billingSource === "lite") {
const lite = LiteData.getLimits()
const week = getWeekBounds(new Date())
const month = getMonthlyBounds(new Date(), authInfo.lite!.timeCreated)
const week = getWeekBounds(trackedAt)
const month = getMonthlyBounds(trackedAt, authInfo.lite!.timeCreated)
const rollingWindowSeconds = lite.rollingWindow * 3600
const quotaCost = Math.round(cost * modelInfo.costMultiplier)
return [
@@ -1195,18 +1203,30 @@ export async function handler(
.set({
monthlyUsage: sql`
CASE
WHEN ${LiteTable.timeMonthlyUpdated} >= ${month.end} THEN ${LiteTable.monthlyUsage}
WHEN ${LiteTable.timeMonthlyUpdated} >= ${month.start} THEN ${LiteTable.monthlyUsage} + ${quotaCost}
ELSE ${quotaCost}
END
`,
timeMonthlyUpdated: sql`now()`,
timeMonthlyUpdated: sql`
CASE
WHEN ${LiteTable.timeMonthlyUpdated} > ${trackedAt} THEN ${LiteTable.timeMonthlyUpdated}
ELSE ${trackedAt}
END
`,
weeklyUsage: sql`
CASE
WHEN ${LiteTable.timeWeeklyUpdated} >= ${week.end} THEN ${LiteTable.weeklyUsage}
WHEN ${LiteTable.timeWeeklyUpdated} >= ${week.start} THEN ${LiteTable.weeklyUsage} + ${quotaCost}
ELSE ${quotaCost}
END
`,
timeWeeklyUpdated: sql`now()`,
timeWeeklyUpdated: sql`
CASE
WHEN ${LiteTable.timeWeeklyUpdated} > ${trackedAt} THEN ${LiteTable.timeWeeklyUpdated}
ELSE ${trackedAt}
END
`,
rollingUsage: sql`
CASE
WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.rollingUsage} + ${quotaCost}
+1 -1
View File
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
ARG BUN_VERSION=1.3.14
ARG BUN_VERSION=1.4.1
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
-1
View File
@@ -122,7 +122,6 @@
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
-34
View File
@@ -41,37 +41,3 @@ const result = await Bun.build({
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
// Bun's Node target eagerly creates its shared require helper, so every split
// entry evaluates import.meta.url even when it never requires a module. Keep
// the helper lazy until Bun stops hoisting it into workerd-reachable chunks.
// https://github.com/oven-sh/bun/issues/12615
const eagerRequire = "var __require = /* @__PURE__ */ createRequire(import.meta.url);"
const lazyRequire = `var __require = (specifier) => createRequire(import.meta.url ?? "file:///worker.js")(specifier);
__require.resolve = (specifier, options) => createRequire(import.meta.url ?? "file:///worker.js").resolve(specifier, options);`
const rewritten = await Promise.all(
result.outputs.map(async (output) => {
if (!output.path.endsWith(".js")) return false
const source = await output.text()
const generatedUses = source
.replace(/import\s*\{[^}]*\b__require\b[^}]*\}\s*from\s*["'][^"']+["'];/g, "")
.replace(/export\s*\{[^}]*\b__require\b[^}]*\};/g, "")
.replace(eagerRequire, "")
if (/\bnew\s+__require\s*\(/.test(generatedUses))
throw new Error(`Unsupported generated require constructor in ${output.path}`)
const unsupported = generatedUses.replace(/\b__require\.resolve\s*\(/g, "").replace(/\b__require\s*\(/g, "")
if (/\b__require\b/.test(unsupported)) throw new Error(`Unsupported generated require usage in ${output.path}`)
if (!source.includes(eagerRequire)) return false
if (source.indexOf(eagerRequire) !== source.lastIndexOf(eagerRequire))
throw new Error(`Multiple eager require helpers in ${output.path}`)
const rewrittenSource = source.replace(eagerRequire, lazyRequire)
if (rewrittenSource.includes(eagerRequire))
throw new Error(`Failed to rewrite eager require helper in ${output.path}`)
await Bun.write(output.path, rewrittenSource)
return true
}),
)
if (rewritten.filter(Boolean).length !== 1)
throw new Error("Expected exactly one eager require helper; Bun may have fixed #12615 and made this shim removable")
+3 -2
View File
@@ -416,8 +416,9 @@ function callOptions(
modelID: ID,
optionKey: string,
): LanguageModelV3CallOptions {
const flattened = ProviderShared.flattenToolRequest(request)
return {
prompt: prompt(request),
prompt: prompt(flattened.request),
maxOutputTokens: request.generation?.maxTokens,
temperature: request.generation?.temperature,
stopSequences: request.generation?.stop === undefined ? undefined : [...request.generation.stop],
@@ -426,7 +427,7 @@ function callOptions(
presencePenalty: request.generation?.presencePenalty,
frequencyPenalty: request.generation?.frequencyPenalty,
seed: request.generation?.seed,
tools: request.tools.map(tool),
tools: flattened.tools.map(tool),
toolChoice: toolChoice(request.toolChoice),
headers: request.http?.headers,
providerOptions: requestProviderOptions(request.providerOptions, packageName, modelID, optionKey),

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