Compare commits

..
Author SHA1 Message Date
Kit Langton f327f66931 fix(core): make tool declines explicit 2026-08-28 21:49:47 -04:00
1074 changed files with 23229 additions and 52871 deletions
+1 -3
View File
@@ -5,7 +5,6 @@ on:
branches:
- dev
- production
- beta
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -16,7 +15,7 @@ permissions:
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
@@ -29,7 +28,6 @@ jobs:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
if: github.ref_name != 'beta'
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
+2 -1
View File
@@ -417,6 +417,7 @@ jobs:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name == 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
@@ -479,7 +480,7 @@ jobs:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
- name: Build
run: bun run build
+1 -2
View File
@@ -3,7 +3,7 @@
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
- The default branch in this repo is `v2`.
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
@@ -46,7 +46,6 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
### General Principles
- Keep things in one function unless composable or reusable
- Validate unknown values once at the boundary that owns them. Pass typed values inward instead of repeating `typeof value === "object"` and property-existence checks. Do not defensively revalidate values already guaranteed by a schema, constructor, or internal type.
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
+64 -97
View File
@@ -54,7 +54,6 @@
"name": "@opencode-ai/app",
"version": "1.18.15",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -184,7 +183,6 @@
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"zod": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
@@ -418,7 +416,7 @@
"electron-window-state": "^5.0.3",
},
"devDependencies": {
"@brendonovich/vite-plugin-opencode": "0.1.3",
"@brendonovich/vite-plugin-opencode": "0.1.1",
"@effect/platform-node": "catalog:",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
@@ -432,7 +430,6 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
@@ -592,8 +589,8 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.10",
"@opentui/solid": ">=0.5.10",
"@opentui/core": ">=0.5.9",
"@opentui/solid": ">=0.5.9",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -730,7 +727,6 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
},
"packages/simulation": {
@@ -970,7 +966,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -980,7 +975,6 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -1050,7 +1044,6 @@
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
@@ -1080,9 +1073,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.10",
"@opentui/keymap": "0.5.10",
"@opentui/solid": "0.5.10",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -1597,7 +1590,7 @@
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-iiIwlNoycOMUiaUzL1ZirLapCG3WY8cg9hSj4KgE/JYIWgqauoHikIlfUt7SdMqqGIkb4iZu6I1MxgDDJaRfBA=="],
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-aPG0ct8ctxAqndbNOx7NW0GhU6QY6sOUfi/DaKqH9c5WdxICSsUop6uSkJwPDHP9WpN9eg0dd2D2qwYpG6UdHw=="],
"@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="],
@@ -1647,10 +1640,6 @@
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -1689,7 +1678,7 @@
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
"@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="],
"@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
@@ -2223,27 +2212,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2785,7 +2774,7 @@
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-Y1XfSefHIOub9762qm3ShafdlEE/Va8h3kLUeMq765fNeWeNLcOP2YUPr86H1SlyGwZTOqQ67RlBZPZ3k9Djgg=="],
@@ -2885,8 +2874,6 @@
"@solid-primitives/media": ["@solid-primitives/media@2.3.6", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/rootless": "^1.5.4", "@solid-primitives/static-store": "^0.1.4", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw=="],
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-VDPrkl9epp0tbby9MvsqphGFCYCtDRC5J8FKzTqHbQiG5hhR8n6xv4MfjhTW231IaBxxPHLxS43EE8c5Q23mSQ=="],
"@solid-primitives/props": ["@solid-primitives/props@3.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A=="],
@@ -3215,6 +3202,8 @@
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -3543,7 +3532,7 @@
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
@@ -3823,7 +3812,7 @@
"dot-prop": ["dot-prop@8.0.2", "", { "dependencies": { "type-fest": "^3.8.0" } }, "sha512-xaBe6ZT4DHPkg0k4Ytbvn5xoxgpG0jOS1dYxSOwAHPuNLjP3/OzN0gH55SrLqpx8cBfSaVt91lXYkApjb+nYdQ=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
@@ -3895,7 +3884,7 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
@@ -4217,7 +4206,7 @@
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
@@ -5311,10 +5300,6 @@
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
@@ -5329,8 +5314,6 @@
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
@@ -5453,6 +5436,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
@@ -5667,6 +5652,8 @@
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -5815,7 +5802,9 @@
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
@@ -5823,7 +5812,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -5847,6 +5836,8 @@
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
@@ -5969,8 +5960,6 @@
"@astrojs/starlight/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=="],
"@astrojs/telemetry/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@astrojs/telemetry/is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
@@ -6125,9 +6114,7 @@
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
@@ -6163,12 +6150,6 @@
"@modelcontextprotocol/sdk/jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="],
"@npmcli/arborist/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
"@npmcli/config/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@npmcli/package-json/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.14", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" } }, "sha512-bgWgiSfFS689/AxQDarU+b3Qu1FYewfVr5vI/jhV84s7GQ3C2OsdRxukGGYKB37MCgwcS50UrfBLlHzhiG13sw=="],
@@ -6257,8 +6238,6 @@
"@opencode-ai/session-ui/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.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-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"@opencode-ai/session-ui/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
"@opencode-ai/stats-app/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.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-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"@opencode-ai/storybook/@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
@@ -6311,16 +6290,12 @@
"@pierre/trees/react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
"@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=="],
"@sentry/bundler-plugins/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
@@ -6393,6 +6368,14 @@
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"app-builder-lib/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=="],
"app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -6403,8 +6386,6 @@
"astro/@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
"astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"astro/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
@@ -6449,8 +6430,6 @@
"conf/dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -6477,16 +6456,14 @@
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
"dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"duplexer2/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=="],
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"electron/@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="],
"electron-builder/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"electron-store/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
@@ -6533,7 +6510,7 @@
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"got/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
@@ -6579,9 +6556,9 @@
"nitro/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"npm-package-arg/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
@@ -6603,6 +6580,8 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -6637,8 +6616,6 @@
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -6665,6 +6642,8 @@
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@@ -6807,8 +6786,6 @@
"@astrojs/starlight/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"@astrojs/starlight/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@astrojs/starlight/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"@astrojs/starlight/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -6931,8 +6908,6 @@
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
@@ -7155,8 +7130,6 @@
"@opencode-ai/web/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"@opencode-ai/web/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@opencode-ai/web/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"@opencode-ai/web/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -7261,6 +7234,14 @@
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
@@ -7279,8 +7260,6 @@
"astro-expressive-code/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"astro-expressive-code/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"astro-expressive-code/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"astro-expressive-code/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -7415,10 +7394,6 @@
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron/@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"electron/@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"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=="],
@@ -7427,8 +7402,6 @@
"gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"js-beautify/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -7477,12 +7450,6 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -7509,8 +7476,6 @@
"toolbeam-docs-theme/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"toolbeam-docs-theme/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"toolbeam-docs-theme/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"toolbeam-docs-theme/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -8189,6 +8154,8 @@
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
+9 -2
View File
@@ -1,5 +1,4 @@
import { domain } from "./stage"
import { createWebApp } from "./webapp"
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
@@ -60,4 +59,12 @@ new sst.cloudflare.x.Astro("Web", {
},
})
createWebApp("app." + domain)
new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
-18
View File
@@ -1,18 +0,0 @@
export function createWebApp(domain: string) {
return new sst.cloudflare.StaticSite("WebApp", {
domain,
path: "packages/app",
environment:
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
}
-5
View File
@@ -87,11 +87,6 @@ stdenv.mkDerivation (finalAttrs: {
cd packages/desktop
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode-ai/", ""))')
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
bun run build
npx electron-builder --dir \
--config electron-builder.config.ts \
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
}
}
+6 -7
View File
@@ -5,10 +5,10 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",
"packageManager": "bun@1.3.14",
"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)\"' --",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -49,9 +49,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.10",
"@opentui/keymap": "0.5.10",
"@opentui/solid": "0.5.10",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
@@ -178,7 +178,6 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
}
}
+15 -36
View File
@@ -831,7 +831,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicUserBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (part.text.trim().length === 0) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -841,7 +840,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
}
if (content.length > 0) messages.push({ role: "user", content })
messages.push({ role: "user", content })
continue
}
@@ -849,7 +848,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (part.text.trim().length === 0) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -893,7 +891,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
)
}
if (content.length > 0) messages.push({ role: "assistant", content })
messages.push({ role: "assistant", content })
continue
}
@@ -1021,11 +1019,10 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const systemParts = request.system.filter((part) => part.text.length > 0)
const system =
systemParts.length === 0
request.system.length === 0
? undefined
: systemParts.map((part) => ({
: request.system.map((part) => ({
type: "text" as const,
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
@@ -1346,28 +1343,20 @@ const onMessageDelta = (
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
const pendingFinish = (() => {
const stopReason = event.delta?.stop_reason
if (stopReason === null || stopReason === undefined) return state.pendingFinish
const stopSequence = event.delta?.stop_sequence
const finishMetadata =
stopSequence === null || stopSequence === undefined
? state.pendingFinish?.providerMetadata
: providerMetadata(state.providerMetadataKey, { stopSequence })
return {
reason: {
normalized: mapFinishReason(stopReason),
raw: stopReason,
},
providerMetadata: finishMetadata,
}
})()
return [
{
...state,
usage,
pendingFinish,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
},
},
NO_EVENTS,
]
@@ -1378,17 +1367,7 @@ const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (st
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
const closed = Object.entries(state.reasoningSignatures).reduce(
(current, [index, signature]) =>
Lifecycle.reasoningEnd(
current,
events,
`reasoning-${index}`,
providerMetadata(state.providerMetadataKey, { signature }),
),
lifecycle,
)
const finished = Lifecycle.finish(closed, events, {
const finished = Lifecycle.finish(lifecycle, events, {
reason: state.pendingFinish?.reason ?? {
normalized: "unknown",
raw: undefined,
+88 -149
View File
@@ -1,4 +1,4 @@
import { Effect, Encoding, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
@@ -262,28 +262,24 @@ const providerMetadata = (key: string, metadata: Record<string, unknown>): Provi
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (part.encrypted !== undefined) return part.encrypted
if (ProviderShared.isRecord(metadata) && typeof metadata.signature === "string") return metadata.signature
return (
part.encrypted ??
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
)
}
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string") return metadata.redactedData
}
const removeEmptyToolInputKeys = (input: unknown): unknown => {
if (Array.isArray(input)) return input.map(removeEmptyToolInputKeys)
if (!ProviderShared.isRecord(input)) return input
return Object.fromEntries(
Object.entries(input).flatMap(([key, value]) => (key === "" ? [] : [[key, removeEmptyToolInputKeys(value)]])),
)
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
? metadata.redactedData
: undefined
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
toolUse: {
toolUseId: part.id,
name: part.name,
input: removeEmptyToolInputKeys(part.input),
input: part.input,
},
})
@@ -418,12 +414,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
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))
return content.length === 0 ? undefined : content
}
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
@@ -431,42 +422,38 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// 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
return {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.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 toolConfig =
request.tools.length > 0
? {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
: undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(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.`,
)
}
const inferenceConfig = (() => {
if (
generation?.maxTokens === undefined &&
generation?.temperature === undefined &&
generation?.topP === undefined &&
(generation?.stop === undefined || generation.stop.length === 0)
)
return undefined
return {
maxTokens: generation?.maxTokens,
temperature: generation?.temperature,
topP: generation?.topP,
stopSequences: generation?.stop,
}
})()
return {
modelId: request.model.id,
messages,
system,
inferenceConfig,
inferenceConfig:
generation?.maxTokens === undefined &&
generation?.temperature === undefined &&
generation?.topP === undefined &&
(generation?.stop === undefined || generation.stop.length === 0)
? undefined
: {
maxTokens: generation?.maxTokens,
temperature: generation?.temperature,
topP: generation?.topP,
stopSequences: generation?.stop,
},
toolConfig,
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
// as a model-specific field, so it goes through additionalModelRequestFields.
@@ -482,6 +469,7 @@ const mapFinishReason = (reason: string): FinishReason => {
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
if (reason === "tool_use") return "tool-calls"
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
return "unknown"
}
@@ -510,23 +498,12 @@ interface ParserState {
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
// one finish after both chunks have had a chance to arrive.
readonly finishReason: FinishReasonDetails | undefined
readonly usage: Usage | undefined
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly reasoningRedactedContent: Readonly<Record<number, ReadonlyArray<Uint8Array>>>
}
const encodeRedactedContent = (chunks: ReadonlyArray<Uint8Array>) => {
const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0))
chunks.reduce((offset, chunk) => {
bytes.set(chunk, offset)
return offset + chunk.length
}, 0)
return Encoding.encodeBase64(bytes)
}
const step = (state: ParserState, event: BedrockEvent) =>
@@ -574,46 +551,23 @@ const step = (state: ParserState, event: BedrockEvent) =>
const index = event.contentBlockDelta.contentBlockIndex
const reasoning = event.contentBlockDelta.delta.reasoningContent
const events: LLMEvent[] = []
const redactedChunks = yield* (() => {
if (reasoning.redactedContent === undefined) return Effect.succeed(undefined)
return Effect.fromResult(Encoding.decodeBase64(reasoning.redactedContent)).pipe(
Effect.map((chunk) => [...(state.reasoningRedactedContent[index] ?? []), chunk]),
Effect.mapError((cause) =>
ProviderShared.eventError(
ADAPTER,
"Bedrock Converse reasoningContent.redactedContent contains invalid base64 data",
undefined,
cause,
),
),
)
})()
const redactedData = redactedChunks === undefined ? reasoning.data : encodeRedactedContent(redactedChunks)
const metadata = (() => {
if (reasoning.signature) return providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
if (redactedData !== undefined) return providerMetadata(state.providerMetadataKey, { redactedData })
})()
const lifecycle = (() => {
if (reasoning.text === undefined && metadata === undefined) return state.lifecycle
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
})()
const reasoningRedactedContent = (() => {
if (redactedChunks !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
if (reasoning.data === undefined) return state.reasoningRedactedContent
return Object.fromEntries(
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
)
})()
const reasoningSignatures = (() => {
if (!reasoning.signature) return state.reasoningSignatures
return { ...state.reasoningSignatures, [index]: reasoning.signature }
})()
const redactedData = reasoning.redactedContent ?? reasoning.data
const metadata = reasoning.signature
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
: redactedData !== undefined
? providerMetadata(state.providerMetadataKey, { redactedData })
: undefined
const lifecycle =
reasoning.text !== undefined || metadata !== undefined
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
: state.lifecycle
return [
{
...state,
lifecycle,
reasoningSignatures,
reasoningRedactedContent,
reasoningSignatures: reasoning.signature
? { ...state.reasoningSignatures, [index]: reasoning.signature }
: state.reasoningSignatures,
},
events,
] as const
@@ -641,24 +595,16 @@ const step = (state: ParserState, event: BedrockEvent) =>
const result = yield* ToolStream.finish(ADAPTER, state.tools, index)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const lifecycle = (() => {
if (resultEvents.length) return Lifecycle.stepStart(state.lifecycle, events)
const metadata = (() => {
const signature = state.reasoningSignatures[index]
if (signature) return providerMetadata(state.providerMetadataKey, { signature })
const redactedContent = state.reasoningRedactedContent[index]
if (redactedContent)
return providerMetadata(state.providerMetadataKey, {
redactedData: encodeRedactedContent(redactedContent),
})
})()
return Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
events,
`reasoning-${index}`,
metadata,
)
})()
const lifecycle = resultEvents.length
? Lifecycle.stepStart(state.lifecycle, events)
: Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
events,
`reasoning-${index}`,
state.reasoningSignatures[index]
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
: undefined,
)
events.push(...resultEvents)
return [
{
@@ -672,30 +618,21 @@ const step = (state: ParserState, event: BedrockEvent) =>
reasoningSignatures: Object.fromEntries(
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
),
reasoningRedactedContent: Object.fromEntries(
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
),
},
events,
] as const
}
if (event.messageStop) {
if (
event.messageStop.stopReason === "malformed_model_output" ||
event.messageStop.stopReason === "malformed_tool_use"
)
return yield* ProviderShared.eventError(
ADAPTER,
`Bedrock Converse stopped with ${event.messageStop.stopReason}`,
ProviderShared.encodeJson(event),
)
return [
{
...state,
finishReason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
},
},
[],
@@ -703,11 +640,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
return [
{
...state,
usage,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
},
[],
] as const
@@ -730,22 +670,23 @@ const step = (state: ParserState, event: BedrockEvent) =>
const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (!state.finishReason) return []
const normalized = (() => {
if (state.finishReason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.finishReason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.finishReason,
normalized,
},
usage: state.usage,
})
return events
}
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.pendingFinish
? (() => {
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.pendingFinish.reason,
normalized:
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
? "tool-calls"
: state.pendingFinish.reason.normalized,
},
usage: state.pendingFinish.usage,
})
return events
})()
: []
// =============================================================================
// Protocol And Bedrock Route
@@ -766,12 +707,10 @@ export const protocol = Protocol.make({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
finishReason: undefined,
usage: undefined,
pendingFinish: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
reasoningSignatures: {},
reasoningRedactedContent: {},
}),
step,
onHalt: (state) => Effect.succeed(onHalt(state)),
@@ -1,7 +1,7 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { Effect, Encoding, Stream } from "effect"
import { AIError, AIErrorReason, InvalidProviderOutputError } from "../schema/index.js"
import { AIError, AIErrorReason } from "../schema/index.js"
import { Framing } from "../route/framing.js"
import { ProviderShared } from "./shared.js"
@@ -22,10 +22,6 @@ interface FrameBufferState {
const initialFrameBuffer: FrameBufferState = { buffer: new Uint8Array(0), offset: 0 }
type FrameInput = { readonly _tag: "Chunk"; readonly bytes: Uint8Array } | { readonly _tag: "End" }
const endOfStream: FrameInput = { _tag: "End" }
const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferState => {
const remaining = state.buffer.length - state.offset
// Compact: drop the consumed prefix and append the new chunk in one alloc.
@@ -37,23 +33,9 @@ const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferSta
return { buffer: next, offset: 0 }
}
const consumeFrames = (route: string) => (state: FrameBufferState, input: FrameInput) =>
const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8Array) =>
Effect.gen(function* () {
if (input._tag === "End") {
const remaining = state.buffer.subarray(state.offset)
if (remaining.length > 0)
return yield* new AIError({
reason: new InvalidProviderOutputError({
route,
classification: "incomplete-stream",
message: `Incomplete Bedrock Converse event-stream frame: ${remaining.length} buffered bytes remain at end of stream`,
body: Encoding.encodeBase64(remaining),
}),
})
return [state, []] as const
}
let cursor = appendChunk(state, input.bytes)
let cursor = appendChunk(state, chunk)
const out: object[] = []
while (cursor.buffer.length - cursor.offset >= 4) {
const view = cursor.buffer.subarray(cursor.offset)
@@ -131,12 +113,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, input: FrameI
export const framing = (route: string): Framing.Definition<object> => ({
id: "aws-event-stream",
body: (frame) => ("rawBody" in frame && typeof frame.rawBody === "string" ? frame.rawBody : undefined),
frame: (bytes) =>
bytes.pipe(
Stream.map((bytes): FrameInput => ({ _tag: "Chunk", bytes })),
Stream.concat(Stream.succeed(endOfStream)),
Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route)),
),
frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
})
export * as BedrockEventStream from "./bedrock-event-stream.js"
+26 -91
View File
@@ -240,10 +240,6 @@ interface ParserState {
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
readonly textSignature?: string
readonly reasoningId?: string
readonly textId?: string
readonly nextReasoningId: number
readonly nextTextId: number
readonly seenCallIds?: ReadonlySet<string>
}
@@ -575,23 +571,19 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
let lifecycle = state.lifecycle
if (state.reasoningId !== undefined)
if (state.reasoningSignature !== undefined)
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
state.reasoningId,
state.reasoningSignature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
"reasoning-0",
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
)
if (state.textId !== undefined)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
state.textId,
state.textSignature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
"text-0",
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
)
Lifecycle.finish(lifecycle, events, {
reason: {
@@ -609,27 +601,18 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
}
const step = (state: ParserState, event: GeminiEvent) => {
if (ProviderShared.isRecord(event.error)) {
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
const body = ProviderShared.encodeJson(event)
return Effect.fail(
new AIError({
reason: classifyProviderFailure({
message:
typeof event.error.message === "string" && event.error.message.length > 0
? event.error.message
: typeof event.error.status === "string" && event.error.status.length > 0
? event.error.status
: "Gemini provider error",
message: event.error.message,
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
}),
)
}
if ("error" in event)
return Effect.fail(
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
@@ -649,10 +632,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
let lifecycle = nextState.lifecycle
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
let reasoningId = nextState.reasoningId
let textId = nextState.textId
let nextReasoningId = nextState.nextReasoningId
let nextTextId = nextState.nextTextId
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
@@ -678,51 +657,27 @@ const step = (state: ParserState, event: GeminiEvent) => {
else if (signature !== undefined && "text" in part) textSignature = signature
if ("text" in part && part.text.length > 0) {
if (part.thought) {
if (textId !== undefined) {
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
textId,
textSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature })
: undefined,
)
textId = undefined
textSignature = undefined
}
if (reasoningId === undefined) {
reasoningId = `reasoning-${nextReasoningId}`
nextReasoningId += 1
}
lifecycle = Lifecycle.reasoningDelta(
lifecycle,
events,
reasoningId,
"reasoning-0",
part.text,
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
)
continue
}
if (reasoningId !== undefined) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
reasoningId,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
reasoningId = undefined
reasoningSignature = undefined
}
if (textId === undefined) {
textId = `text-${nextTextId}`
nextTextId += 1
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
lifecycle = Lifecycle.textDelta(
lifecycle,
events,
textId,
"text-0",
part.text,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
)
@@ -740,28 +695,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
if (supplied !== undefined) seenCallIds.add(supplied)
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
if (reasoningId !== undefined) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
reasoningId,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
reasoningId = undefined
reasoningSignature = undefined
}
if (textId !== undefined) {
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
textId,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
)
textId = undefined
textSignature = undefined
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(
LLMEvent.toolCall({
@@ -784,10 +725,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
reasoningSignature,
textSignature,
reasoningId,
textId,
nextReasoningId,
nextTextId,
seenCallIds,
finishReason: candidate.finishReason ?? nextState.finishReason,
},
@@ -815,8 +752,6 @@ export const protocol = Protocol.make({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
nextReasoningId: 0,
nextTextId: 0,
}),
step,
onHalt: (state) => Effect.succeed(finish(state)),
-1
View File
@@ -1,7 +1,6 @@
export * as AnthropicMessages from "./anthropic-messages.js"
export * as BedrockConverse from "./bedrock-converse.js"
export * as Gemini from "./gemini.js"
export * as MistralChat from "./mistral-chat.js"
export * as OpenAIChat from "./openai-chat.js"
export * as OpenAIImages from "./openai-images.js"
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
-780
View File
@@ -1,780 +0,0 @@
import { Effect, Schema } from "effect"
import { Auth } from "../route/auth.js"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import {
AIError,
InvalidProviderOutputError,
LLMEvent,
Usage,
type FinishReasonDetails,
type LLMRequest,
type MediaPart,
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "mistral-chat"
const DONE = "[DONE]" as const
const TOOL_ID = /^[A-Za-z0-9]{9}$/
export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
export const PATH = "/chat/completions"
const MistralTextContent = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
})
const MistralThinkingUnit = Schema.StructWithRest(
Schema.Struct({
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type MistralThinkingUnit = Schema.Schema.Type<typeof MistralThinkingUnit>
const MistralThinkingContent = Schema.StructWithRest(
Schema.Struct({
type: Schema.Literal("thinking"),
thinking: Schema.Array(MistralThinkingUnit),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type MistralThinkingContent = Schema.Schema.Type<typeof MistralThinkingContent>
const isMistralThinkingContent = Schema.is(MistralThinkingContent)
const MistralUserContent = Schema.Union([
MistralTextContent,
Schema.Struct({ type: Schema.Literal("image_url"), image_url: Schema.String }),
Schema.Struct({ type: Schema.Literal("document_url"), document_url: Schema.String }),
])
type MistralUserContent = Schema.Schema.Type<typeof MistralUserContent>
const MistralAssistantToolCall = Schema.Struct({
id: Schema.String,
type: Schema.Literal("function"),
function: Schema.Struct({ name: Schema.String, arguments: Schema.String }),
})
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
const MistralMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
}),
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.Union([Schema.String, Schema.Array(Schema.Union([MistralTextContent, MistralThinkingContent]))]),
tool_calls: optionalArray(MistralAssistantToolCall),
prefix: Schema.optional(Schema.Literal(true)),
}),
Schema.Struct({
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
name: Schema.String,
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
}),
]).pipe(Schema.toTaggedUnion("role"))
type MistralMessage = Schema.Schema.Type<typeof MistralMessage>
const MistralTool = Schema.Struct({
type: Schema.Literal("function"),
function: Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
strict: Schema.Literal(false),
}),
})
type MistralTool = Schema.Schema.Type<typeof MistralTool>
const MistralOptions = Schema.Struct({
safePrompt: Schema.optional(Schema.Boolean),
documentImageLimit: Schema.optional(Schema.Number),
documentPageLimit: Schema.optional(Schema.Number),
parallelToolCalls: Schema.optional(Schema.Boolean),
reasoningEffort: Schema.optional(Schema.String),
promptMode: Schema.optional(Schema.Literal("reasoning")),
promptCacheKey: Schema.optional(Schema.String),
})
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | (string & {})
export type ProviderOptionsInput = {
readonly safePrompt?: boolean
readonly documentImageLimit?: number
readonly documentPageLimit?: number
readonly parallelToolCalls?: boolean
readonly reasoningEffort?: ReasoningEffort
readonly promptMode?: "reasoning"
readonly promptCacheKey?: string
readonly [key: string]: unknown
}
const MistralBody = Schema.Struct({
model: Schema.String,
messages: Schema.Array(MistralMessage),
tools: optionalArray(MistralTool),
tool_choice: Schema.optional(
Schema.Union([
Schema.Literals(["auto", "none", "any"]),
Schema.Struct({ type: Schema.Literal("function"), function: Schema.Struct({ name: Schema.String }) }),
]),
),
stream: Schema.Literal(true),
max_tokens: Schema.optional(Schema.Number),
random_seed: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
frequency_penalty: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
stop: optionalArray(Schema.String),
prompt_cache_key: Schema.optional(Schema.String),
safe_prompt: Schema.optional(Schema.Boolean),
document_image_limit: Schema.optional(Schema.Number),
document_page_limit: Schema.optional(Schema.Number),
parallel_tool_calls: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(Schema.String),
prompt_mode: Schema.optional(Schema.Literal("reasoning")),
})
export type MistralBody = Schema.Schema.Type<typeof MistralBody>
const MistralUsageDetails = Schema.StructWithRest(Schema.Struct({ cached_tokens: optionalNull(Schema.Number) }), [
Schema.Record(Schema.String, Schema.Unknown),
])
const MistralUsage = Schema.StructWithRest(
Schema.Struct({
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
num_cached_tokens: optionalNull(Schema.Number),
prompt_token_details: optionalNull(MistralUsageDetails),
prompt_tokens_details: optionalNull(MistralUsageDetails),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const MistralOutputContent = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
text: optionalNull(Schema.String),
thinking: optionalNull(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type MistralOutputContent = Schema.Schema.Type<typeof MistralOutputContent>
const MistralToolDelta = Schema.Struct({
index: optionalNull(Schema.Number),
id: optionalNull(Schema.String),
function: optionalNull(
Schema.Struct({
name: optionalNull(Schema.String),
arguments: optionalNull(Schema.Union([Schema.String, JsonObject])),
}),
),
})
type MistralToolDelta = Schema.Schema.Type<typeof MistralToolDelta>
const MistralChoice = Schema.StructWithRest(
Schema.Struct({
delta: optionalNull(
Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.Union([Schema.String, Schema.Array(MistralOutputContent)])),
tool_calls: optionalNull(Schema.Array(MistralToolDelta)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
finish_reason: optionalNull(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const MistralError = Schema.StructWithRest(
Schema.Struct({
message: Schema.String,
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const MistralEvent = Schema.StructWithRest(
Schema.Struct({
choices: optionalNull(Schema.Array(MistralChoice)),
usage: optionalNull(MistralUsage),
error: optionalNull(MistralError),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
const hashID = (value: string) => {
const hash = (seed: number) => {
let result = seed
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
return (result >>> 0).toString(36)
}
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
}
const toolIDNormalizer = (request: LLMRequest) => {
const ids = request.messages.flatMap((message) =>
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
)
const used = new Set(ids.filter((id) => TOOL_ID.test(id)))
const normalized = new Map<string, string>()
return (id: string) => {
if (TOOL_ID.test(id)) return id
const previous = normalized.get(id)
if (previous) return previous
let attempt = 0
let candidate = hashID(id)
while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`)
used.add(candidate)
normalized.set(id, candidate)
return candidate
}
}
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl
if (media.mime.startsWith("image/")) return { type: "image_url" as const, image_url: url }
if (media.mime === "application/pdf") return { type: "document_url" as const, document_url: url }
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.mediaType}`)
})
const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message: LLMRequest["messages"][number]) {
const content: MistralUserContent[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text })
continue
}
if (part.type === "media") {
content.push(yield* lowerMedia(part))
continue
}
return yield* ProviderShared.unsupportedContent("Mistral Chat", "user", ["text", "media"])
}
if (content.every((part) => part.type === "text"))
return { role: "user" as const, content: content.map((part) => part.text).join("") }
return { role: "user" as const, content }
})
const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): MistralAssistantToolCall => ({
id: normalizeID(part.id),
type: "function",
function: { name: part.name, arguments: ProviderShared.encodeJson(part.input) },
})
const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
prefix: boolean,
) {
const structured = message.content.some(
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
)
const content: Array<Schema.Schema.Type<typeof MistralTextContent> | MistralThinkingContent> = []
const text: string[] = []
const toolCalls: MistralAssistantToolCall[] = []
for (const part of message.content) {
if (part.type === "text") {
if (structured) content.push({ type: "text", text: part.text })
else text.push(part.text)
continue
}
if (part.type === "reasoning") {
const native = part.providerMetadata?.mistral?.thinking
if (structured && isMistralThinkingContent(native)) content.push(native)
else if (structured) content.push({ type: "text", text: part.text })
else text.push(part.text)
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part, normalizeID))
continue
}
return yield* ProviderShared.unsupportedContent("Mistral Chat", "assistant", ["text", "reasoning", "tool-call"])
}
return {
role: "assistant" as const,
content: structured ? content : text.join(""),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
...(prefix ? { prefix: true as const } : {}),
}
})
const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
) {
const output: MistralMessage[] = []
for (const part of message.content) {
if (part.type !== "tool-result")
return yield* ProviderShared.unsupportedContent("Mistral Chat", "tool", ["tool-result"])
if (part.result.type !== "content") {
output.push({
role: "tool",
tool_call_id: normalizeID(part.id),
name: part.name,
content: ProviderShared.toolResultText(part),
})
continue
}
const content: MistralUserContent[] = []
for (const item of part.result.value) {
if (item.type === "text") {
content.push({ type: "text", text: item.text })
continue
}
content.push(yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }))
}
output.push({
role: "tool",
tool_call_id: normalizeID(part.id),
name: part.name,
content: content.some((item) => item.type !== "text")
? content
: content.map((item) => (item.type === "text" ? item.text : "")).join(""),
})
}
return output
})
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
const normalizeID = toolIDNormalizer(request)
const messages: MistralMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
for (const message of request.messages) {
if (message.role === "system") {
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
messages.push({
role: "user",
content: update.text,
})
continue
}
if (message.role === "user") {
messages.push(yield* lowerUser(message))
continue
}
if (message.role === "assistant") {
const hasToolCalls = message.content.some((part) => part.type === "tool-call")
const hasNativeThinking = message.content.some(
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
)
const text = message.content
.flatMap((part) => (part.type === "text" || part.type === "reasoning" ? [part.text] : []))
.join("")
if (!hasToolCalls && !hasNativeThinking && text.trim() === "") continue
messages.push(yield* lowerAssistant(message, normalizeID, !hasToolCalls && message === request.messages.at(-1)))
continue
}
messages.push(...(yield* lowerToolResults(message, normalizeID)))
}
return messages
})
const lowerTool = (tool: ToolDefinition): MistralTool => ({
type: "function",
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false },
})
export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MistralOptions))(
request.providerOptions ?? {},
)
const selected = request.toolChoice?.type === "tool" ? request.toolChoice.name : undefined
if (request.toolChoice?.type === "tool" && !selected)
return yield* ProviderShared.invalidRequest("Mistral Chat tool choice requires a tool name")
if (options.reasoningEffort !== undefined && options.promptMode !== undefined)
return yield* ProviderShared.invalidRequest(
"Mistral Chat reasoningEffort and promptMode provider options are mutually exclusive",
)
const toolChoice = request.toolChoice
? yield* ProviderShared.matchToolChoice("Mistral Chat", request.toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "any" as const,
tool: (name) => ({ type: "function" as const, function: { name } }),
})
: undefined
return {
model: request.model.id,
messages: yield* lowerMessages(request),
tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined,
tool_choice: toolChoice,
stream: true as const,
max_tokens: request.generation?.maxTokens,
random_seed: request.generation?.seed,
temperature: request.generation?.temperature,
top_p: request.generation?.topP,
frequency_penalty: request.generation?.frequencyPenalty,
presence_penalty: request.generation?.presencePenalty,
stop: request.generation?.stop,
prompt_cache_key: request.cache === "none" ? undefined : (options.promptCacheKey ?? request.promptCacheKey),
safe_prompt: options.safePrompt,
document_image_limit: options.documentImageLimit,
document_page_limit: options.documentPageLimit,
parallel_tool_calls:
options.parallelToolCalls ?? (request.toolChoice?.disableParallelToolUse === true ? false : undefined),
reasoning_effort: options.reasoningEffort,
prompt_mode: options.promptMode,
}
})
type ToolKey = string | number
interface PendingTool {
readonly id: string
readonly name?: string
readonly input: string
}
interface ActiveContent {
readonly type: "text" | "reasoning"
readonly id: string
readonly thinking?: MistralThinkingContent
}
export interface ParserState {
readonly tools: ToolStream.State<ToolKey>
readonly pendingTools: Partial<Record<ToolKey, PendingTool>>
readonly toolIDs: ReadonlyMap<string, string>
readonly usedToolIDs: ReadonlySet<string>
readonly completedTools: ReadonlyArray<LLMEvent>
readonly latestToolKey?: ToolKey
readonly generatedTools: number
readonly lifecycle: Lifecycle.State
readonly active?: ActiveContent
readonly nextContent: number
readonly usage?: Usage
readonly finishReason?: FinishReasonDetails
}
const mapUsage = (usage: MistralEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const reported =
usage.num_cached_tokens ??
usage.prompt_tokens_details?.cached_tokens ??
usage.prompt_token_details?.cached_tokens ??
undefined
const cached = input === undefined || reported === undefined ? undefined : Math.max(0, Math.min(input, reported))
const output = usage.completion_tokens ?? undefined
return new Usage({
inputTokens: input,
outputTokens: output,
nonCachedInputTokens: ProviderShared.subtractTokens(input, cached),
cacheReadInputTokens: cached,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
providerMetadata: { mistral: usage },
})
}
const mapFinishReason = (reason: string) => {
switch (reason) {
case "stop":
return "stop" as const
case "length":
case "model_length":
return "length" as const
case "tool_calls":
return "tool-calls" as const
case "content_filter":
return "content-filter" as const
case "error":
case "network_error":
return "error" as const
default:
return "unknown" as const
}
}
const thinkingUnits = (value: unknown): ReadonlyArray<MistralThinkingUnit> => {
if (typeof value === "string") return [{ type: "text", text: value }]
if (!Array.isArray(value)) return []
return value.filter(Schema.is(MistralThinkingUnit))
}
const thinkingText = (thinking: ReadonlyArray<MistralThinkingUnit>) =>
thinking.flatMap((unit) => (typeof unit.text === "string" ? [unit.text] : [])).join("")
const thinkingMetadata = (thinking: MistralThinkingContent) => ({ mistral: { thinking } })
const closeActive = (state: ParserState, events: LLMEvent[]) => {
if (!state.active) return state
const lifecycle =
state.active.type === "text"
? Lifecycle.textEnd(state.lifecycle, events, state.active.id)
: Lifecycle.reasoningEnd(
state.lifecycle,
events,
state.active.id,
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
thinkingText(state.active.thinking?.thinking ?? []),
)
return { ...state, lifecycle, active: undefined }
}
const appendText = (state: ParserState, events: LLMEvent[], text: string) => {
if (text.length === 0) return state
const current = state.active?.type === "text" ? state : closeActive(state, events)
const active = current.active ?? { type: "text" as const, id: `text-${current.nextContent}` }
return {
...current,
lifecycle: Lifecycle.textDelta(current.lifecycle, events, active.id, text),
active,
nextContent: current.active ? current.nextContent : current.nextContent + 1,
}
}
const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOutputContent) => {
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
const units = thinkingUnits(part.thinking)
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
const thinking = {
...active.thinking,
...part,
type: "thinking" as const,
thinking: [...(active.thinking?.thinking ?? []), ...units],
}
const text = thinkingText(units)
return {
...current,
lifecycle:
text.length > 0
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
active: { ...active, thinking },
nextContent: current.active ? current.nextContent : current.nextContent + 1,
}
}
const appendContent = (
state: ParserState,
events: LLMEvent[],
content: string | ReadonlyArray<MistralOutputContent>,
) => {
if (typeof content === "string") return appendText(state, events, content)
return content.reduce((current, part) => {
if (part.type === "text") return appendText(current, events, part.text ?? "")
if (part.type === "thinking") return appendThinking(current, events, part)
return closeActive(current, events)
}, state)
}
const normalizeStreamToolID = (state: ParserState, source: string) => {
if (TOOL_ID.test(source))
return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } }
const previous = state.toolIDs.get(source)
if (previous) return { id: previous, state }
let attempt = 0
let id = hashID(source)
while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`)
return {
id,
state: {
...state,
toolIDs: new Map([...state.toolIDs, [source, id]]),
usedToolIDs: new Set([...state.usedToolIDs, id]),
},
}
}
const toolText = (tool: MistralToolDelta) => {
const value = tool.function?.arguments
if (typeof value === "string") return value
return value === null || value === undefined ? "" : ProviderShared.encodeJson(value)
}
const appendTools = Effect.fn("MistralChat.appendTools")(function* (
initial: ParserState,
events: LLMEvent[],
deltas: ReadonlyArray<MistralToolDelta>,
) {
if (deltas.length === 0) return initial
let state = closeActive(initial, events)
for (const [position, delta] of deltas.entries()) {
const wireID = delta.id?.trim() || undefined
const providedID = wireID === "null" ? undefined : wireID
const key =
delta.index ??
(providedID
? `id:${providedID}`
: deltas.length > 1
? `position:${position}`
: (state.latestToolKey ?? `missing:${state.generatedTools}`))
const existing = state.tools[key]
const pending = state.pendingTools[key]
const source = providedID ?? `generated:${String(key)}`
const normalized =
existing || pending ? { id: existing?.id ?? pending?.id ?? "", state } : normalizeStreamToolID(state, source)
state = normalized.state
const name = existing?.name ?? pending?.name ?? (delta.function?.name?.trim() || undefined)
const text = `${pending?.input ?? ""}${toolText(delta)}`
if (!name) {
state = {
...state,
pendingTools: { ...state.pendingTools, [key]: { id: normalized.id, input: text } },
latestToolKey: key,
generatedTools: state.generatedTools + (!providedID && !pending ? 1 : 0),
}
continue
}
const result = ToolStream.appendOrStart(
ADAPTER,
state.tools,
key,
{ id: normalized.id, name, text },
"Mistral Chat tool call delta is missing a name",
)
if (ToolStream.isError(result)) return yield* result
if (result.events.length > 0) state = { ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }
events.push(...result.events)
const pendingTools = { ...state.pendingTools }
delete pendingTools[key]
state = {
...state,
tools: result.tools,
pendingTools,
latestToolKey: key,
generatedTools: state.generatedTools + (!providedID && !existing && !pending ? 1 : 0),
}
}
return state
})
const hasLateContent = (event: MistralEvent) => {
const delta = event.choices?.[0]?.delta
if (typeof delta?.content === "string" && delta.content.length > 0) return true
if (Array.isArray(delta?.content) && delta.content.length > 0) return true
return (delta?.tool_calls ?? []).some(
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || tool.function?.arguments !== undefined,
)
}
const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event: MistralEvent) {
if (event.error) {
const body = ProviderShared.encodeJson(event)
return yield* new AIError({
reason: classifyProviderFailure({
message: event.error.message,
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
})
}
const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
if (state.finishReason) {
if (hasLateContent(event))
return yield* ProviderShared.eventError(
ADAPTER,
"Mistral Chat received content after the finish reason",
ProviderShared.encodeJson(event),
)
return [{ ...state, usage }, events] as const
}
const choice = event.choices?.[0]
const withContent = choice?.delta?.content == null ? state : appendContent(state, events, choice.delta.content)
const withTools = yield* appendTools(withContent, events, choice?.delta?.tool_calls ?? [])
if (!choice?.finish_reason) return [{ ...withTools, usage }, events] as const
const finishReason = {
normalized: mapFinishReason(choice.finish_reason),
raw: choice.finish_reason,
}
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
return yield* ProviderShared.eventError(
ADAPTER,
"Mistral Chat tool call delta is missing a name",
ProviderShared.encodeJson(event),
)
const finished =
!incomplete && Object.keys(withTools.tools).length > 0
? yield* ToolStream.finishAll(ADAPTER, withTools.tools)
: undefined
return [
{
...withTools,
tools: finished?.tools ?? withTools.tools,
completedTools: finished?.events ?? withTools.completedTools,
usage,
finishReason,
},
events,
] as const
})
const finishEvents = Effect.fn("MistralChat.finishEvents")(function* (state: ParserState) {
if (!state.finishReason)
return yield* new AIError({
reason: new InvalidProviderOutputError({
message: "Mistral Chat stream ended without finish_reason",
classification: "incomplete-stream",
route: ADAPTER,
}),
})
const events: LLMEvent[] = []
const closed = closeActive(state, events)
const lifecycle = closed.completedTools.length > 0 ? Lifecycle.stepStart(closed.lifecycle, events) : closed.lifecycle
events.push(...closed.completedTools)
const reason =
state.finishReason.normalized === "stop" && closed.completedTools.some(LLMEvent.is.toolCall)
? { ...state.finishReason, normalized: "tool-calls" as const }
: state.finishReason
Lifecycle.finish(lifecycle, events, { reason, usage: closed.usage })
return events
})
export const protocol = Protocol.make({
id: ADAPTER,
body: { schema: MistralBody, from: fromRequest },
stream: {
event: MistralStreamEvent,
initial: (): ParserState => ({
tools: ToolStream.empty<ToolKey>(),
pendingTools: {},
toolIDs: new Map(),
usedToolIDs: new Set(),
completedTools: [],
generatedTools: 0,
lifecycle: Lifecycle.initial(),
nextContent: 0,
}),
step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)),
terminal: (event) => event === DONE,
onHalt: finishEvents,
},
})
export const framing = Framing.sseWithDone
export const httpTransport = HttpTransport.sseJson.with<MistralBody>().with({ framing })
export const route = Route.make({
id: ADAPTER,
provider: "mistral",
providerMetadataKey: "mistral",
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
transport: httpTransport,
})
export * as MistralChat from "./mistral-chat.js"
+49 -65
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type HostedToolReplayItem = {
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ExtendedHostedToolItem
| {
readonly type: "message"
readonly id?: string
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
export interface ProviderAdapter {
export interface Extension {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface ProviderAdapter {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
}
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
const BASE: Extension = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -397,9 +397,6 @@ export interface ParserState {
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -485,12 +482,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -510,17 +507,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return lowered
})
@@ -529,13 +526,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
"tool-result",
)
})
@@ -543,33 +540,30 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -577,14 +571,16 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
if (content.length > 0) input.push({ role: "user", content })
input.push({
role: "user",
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension)),
})
continue
}
@@ -650,7 +646,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: adapter.restoreHostedToolItem?.(part.result.value)
: extension.lowerHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -664,11 +660,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
continue
}
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -681,11 +679,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, adapter),
output: yield* lowerToolResultOutput(part, request, extension),
})
}
}
@@ -737,28 +735,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
input: yield* lowerMessages(request, extension),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
adapter.name,
extension.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -772,7 +770,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
})
// =============================================================================
@@ -955,16 +953,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -977,7 +971,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1094,12 +1087,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1112,13 +1100,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: message ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1257,12 +1245,9 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
if (
item.type !== "function_call" ||
!item.call_id ||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
)
continue
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (id === undefined) continue
if (item.type !== "function_call" || !current.tools[id]) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1425,9 +1410,9 @@ export const step = (state: ParserState, input: Event) => {
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -1435,7 +1420,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithAdapter(
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
adapter,
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
@@ -1,4 +1,4 @@
import { Effect, Encoding, Schema } from "effect"
import { Effect, Schema } from "effect"
import type { MediaPart } from "../../schema/index.js"
import { ProviderShared } from "../shared.js"
@@ -57,16 +57,6 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
},
})
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
Effect.mapError((cause) =>
ProviderShared.invalidRequest("Bedrock Converse media data must be valid base64", cause),
),
)
return Encoding.encodeBase64(bytes)
})
// Route by MIME. Known image/document formats lower into a typed block; anything
// else fails with a clear error instead of silently degrading to a malformed
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
@@ -76,7 +66,8 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
const mime = part.mediaType.toLowerCase()
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
if (imageFormat) {
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
const media = ProviderShared.normalizeMedia(part)
return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
}
if (mime.startsWith("image/"))
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
@@ -84,7 +75,8 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
if (documentFormat) {
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
const media = ProviderShared.normalizeMedia(part)
return documentBlock(part.filename, documentFormat, media.base64)
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
})
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
-1
View File
@@ -13,7 +13,6 @@ export * as GoogleVertexChat from "./google-vertex-chat.js"
export * as GoogleVertexMessages from "./google-vertex-messages.js"
export * as GoogleVertexResponses from "./google-vertex-responses.js"
export * as Groq from "./groq.js"
export * as Mistral from "./mistral.js"
export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
-51
View File
@@ -1,51 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { MistralChat } from "../protocols/mistral-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("mistral")
export type ProviderOptions = MistralChat.ProviderOptionsInput
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export const route = MistralChat.route
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? MistralChat.DEFAULT_BASE_URL },
auth: AuthOptions.bearer(input, "MISTRAL_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) => configured.model<ProviderOptions>({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Mistral from "./mistral.js"
+7 -12
View File
@@ -7,7 +7,6 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { normalizeToolHistory } from "../tool-history.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
@@ -170,19 +169,17 @@ export interface GenerateMethod {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const resolveRequestOptions = (request: LLMRequest) => {
const messages = normalizeToolHistory(request.messages)
const normalized = messages === request.messages ? request : LLMRequest.update(request, { messages })
const routeDefaults = normalized.model.route.defaults
const modelDefaults = normalized.model.defaults
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, normalized.generation)
return LLMRequest.update(normalized, {
const routeDefaults = request.model.route.defaults
const modelDefaults = request.model.defaults
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
return LLMRequest.update(request, {
generation: generation ?? new GenerationOptions({}),
providerOptions: mergeProviderOptions(
routeDefaults.providerOptions,
modelDefaults?.providerOptions,
normalized.providerOptions,
request.providerOptions,
),
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, normalized.http),
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
})
}
@@ -449,9 +446,7 @@ export function make<Body, Prepared, Frame, Event, State>(
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const original = applyCachePolicy(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 })
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const route = resolved.model.route
const body = yield* route.body
+6 -1
View File
@@ -9,6 +9,7 @@ import {
LanguageModelSchema,
ProviderOptions,
} from "./options.js"
import { isRecord } from "../utils/record.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
@@ -55,6 +56,11 @@ export const MediaPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value
const toolResultValueSchema = Schema.Union([
Schema.Struct({
type: Schema.Literal("json"),
@@ -74,7 +80,6 @@ const toolResultValueSchema = Schema.Union([
}),
]).annotate({ identifier: "LLM.ToolResult" })
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
const isToolResultValue = Schema.is(toolResultValueSchema)
export const ToolResultValue = Object.assign(toolResultValueSchema, {
is: isToolResultValue,
+1 -1
View File
@@ -267,7 +267,7 @@ export const CachePolicyObject = Schema.Struct({
Schema.Union([
Schema.Literal("latest-user-message"),
Schema.Literal("latest-assistant"),
Schema.Struct({ tail: Schema.Natural }),
Schema.Struct({ tail: Schema.Number }),
]),
),
ttlSeconds: Schema.optional(Schema.Number),
-74
View File
@@ -1,74 +0,0 @@
import { Message, ToolResultPart, type ToolCallPart } from "./schema/messages.js"
const EMPTY_TOOL_OUTPUT = "(no tool output)"
const MISSING_TOOL_RESULT = "Tool result missing"
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
const normalized: Message[] = []
const pending = new Map<string, ToolCallPart>()
const appendMissingResults = () => {
if (pending.size === 0) return
normalized.push(missingToolResults(pending.values()))
pending.clear()
}
for (const message of messages) {
if (message.role === "user" || message.role === "assistant") appendMissingResults()
if (message.role === "tool") {
const tool = normalizeToolMessage(message, pending)
if (tool) normalized.push(tool)
continue
}
normalized.push(message)
if (message.role !== "assistant") continue
for (const part of message.content) {
if (part.type === "tool-call" && part.providerExecuted !== true) pending.set(part.id, part)
}
}
return normalized.length === messages.length && normalized.every((message, index) => message === messages[index])
? messages
: normalized
}
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" }),
),
})
}
function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPart>): Message | undefined {
const content = message.content.map((part) => {
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)
})
if (content.length === 0) return undefined
if (content.every((part, index) => part === message.content[index])) return message
return new Message({
id: message.id,
role: message.role,
content,
metadata: message.metadata,
native: message.native,
})
}
function normalizeToolResult(part: ToolResultPart, name: string): ToolResultPart {
const named = part.name === name ? part : { ...part, name }
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 === "")
return { ...named, result: { type: "error", value: EMPTY_TOOL_OUTPUT } }
if (named.result.type !== "content") return named
const value = named.result.value.filter((item) => item.type !== "text" || item.text !== "")
if (value.length === 0) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (value.length === named.result.value.length) return named
return { ...named, result: { type: "content", value } }
}
+1 -50
View File
@@ -1,7 +1,7 @@
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 { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
@@ -77,55 +77,6 @@ describe("request option precedence", () => {
}),
)
it.effect("keeps the last tool definition for duplicate names", () =>
Effect.gen(function* () {
const request = LLM.request({
model: OpenAIChat.route.model({ id: "gpt-4o-mini" }),
prompt: "Use a tool.",
})
const prepared = yield* compileRequest(
LLMRequest.update(request, {
tools: [
ToolDefinition.make({ name: "lookup", description: "old", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "search", description: "search", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "lookup", description: "new", inputSchema: { type: "object" } }),
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "function",
function: { name: "lookup", description: "new", parameters: { type: "object" }, strict: false },
},
{
type: "function",
function: { name: "search", description: "search", parameters: { type: "object" }, strict: false },
},
])
}),
)
it.effect("normalizes tool history before protocol lowering", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAIChat.route.model({ id: "gpt-4o-mini" }),
messages: [
Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })),
Message.user("Continue."),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: "call_1", function: { name: "lookup" } }] },
{ role: "tool", tool_call_id: "call_1", content: "Tool result missing" },
{ role: "user", content: "Continue." },
])
}),
)
it.effect("applies model HTTP defaults before request HTTP overlays", () =>
LLMClient.generate(
LLM.request({
@@ -1,36 +0,0 @@
{
"version": 1,
"metadata": {
"model": "zai-glm-5-2",
"tags": [
"prefix:mistral-chat-glm",
"provider:mistral",
"protocol:mistral-chat",
"hosted-model",
"tool",
"tool-call"
],
"name": "mistral-chat-glm/streams-an-indexed-tool-call",
"recordedAt": "2026-08-30T17:38:02.921Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.mistral.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"zai-glm-5-2\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":256,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-8cc4d8f9f07b298a\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"Paris\\\"}\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"index\":0,\"content\":\"\"},\"finish_reason\":\"stop\",\"logprobs\":null}],\"usage\":{\"prompt_tokens\":171,\"total_tokens\":182,\"completion_tokens\":11,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,47 +0,0 @@
{
"version": 1,
"metadata": {
"model": "mistral-small-latest",
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"],
"name": "mistral-chat/drives-a-tool-loop",
"recordedAt": "2026-08-30T17:18:49.552Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.mistral.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.mistral.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"model": "mistral-small-latest",
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "text", "usage"],
"name": "mistral-chat/streams-text-with-usage",
"recordedAt": "2026-08-30T17:18:45.432Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.mistral.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"max_tokens\":40,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":22,\"total_tokens\":24,\"completion_tokens\":2,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,24 +0,0 @@
import { LLM } from "../../src/index.js"
import { Mistral } from "../../src/providers.js"
const selected = Mistral.provider.model("mistral-small-latest")
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "future-effort" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptMode: "reasoning" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { parallelToolCalls: false } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptCacheKey: "session-1" } })
LLM.request({
model: selected,
prompt: "Hello",
// @ts-expect-error Mistral reasoning effort must be a string.
providerOptions: { reasoningEffort: 1 },
})
LLM.request({
model: selected,
prompt: "Hello",
// @ts-expect-error Mistral prompt mode only supports reasoning.
providerOptions: { promptMode: "standard" },
})
@@ -74,48 +74,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("omits empty system text while preserving whitespace", () =>
Effect.gen(function* () {
const empty = yield* compileRequest(LLMRequest.update(request, { system: [{ type: "text", text: "" }] }))
const whitespace = yield* compileRequest(LLMRequest.update(request, { system: [{ type: "text", text: " " }] }))
expect(empty.body.system).toBeUndefined()
expect(whitespace.body.system).toEqual([{ type: "text", text: " " }])
}),
)
it.effect("filters whitespace-only text and removes empty messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user(" \n\t"),
Message.user([]),
Message.user([
{ type: "text", text: "" },
{ type: "text", text: " Keep this spacing. " },
{ type: "text", text: " \n\t" },
]),
Message.assistant(" \n\t"),
Message.assistant([]),
Message.assistant([{ type: "reasoning", text: "" }]),
Message.assistant([
{ type: "text", text: "" },
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: " Keep this spacing. " }] },
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
])
}),
)
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -945,54 +903,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves a reasoning signature when message_stop closes the block", () =>
Effect.gen(function* () {
const compatible = Route.make({
id: "custom-anthropic-messages",
provider: "custom-anthropic",
protocol: AnthropicMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://compatible.test/v1" }),
auth: Auth.header("x-api-key", "test"),
framing: AnthropicMessages.framing,
}).model({ id: "custom-model" })
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "", signature: "" },
},
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Reasoning." } },
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Think." })).pipe(
Effect.provide(fixedResponse(body)),
)
const reasoningEnds = response.events.filter((event) => event.type === "reasoning-end")
expect(reasoningEnds).toHaveLength(1)
expect(reasoningEnds[0]).toMatchObject({
providerMetadata: { "custom-anthropic": { signature: "sig_1" } },
})
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Reasoning.",
providerMetadata: { "custom-anthropic": { signature: "sig_1" } },
},
])
const prepared = yield* compileRequest(
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "thinking", thinking: "Reasoning.", signature: "sig_1" }] },
])
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -1026,7 +936,6 @@ describe("Anthropic Messages route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
expect(response.message.content).toEqual([
{ type: "text", text: "Hello!" },
@@ -1040,41 +949,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves terminal state across usage-only message deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: "X" },
usage: { output_tokens: 8 },
},
{ type: "message_delta", delta: {}, usage: { output_tokens: 10 } },
{ type: "message_stop" },
),
),
),
)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 10, totalTokens: 15 })
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.events.find((event) => event.type === "step-finish")).toMatchObject({
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
}),
)
it.effect("requires message_stop before completing a streamed message", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -1,12 +1,11 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect, Encoding, Ref, Stream } from "effect"
import { Effect } from "effect"
import {
CacheHint,
GenerationOptions,
LLM,
LLMEvent,
LLMRequest,
Message,
ToolCallPart,
@@ -84,17 +83,6 @@ const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>)
const fixedBytes = (bytes: Uint8Array) =>
fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
fixedResponse(
new ReadableStream<Uint8Array>({
start(controller) {
chunks.forEach((chunk) => controller.enqueue(chunk))
controller.close()
},
}),
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
)
const model = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
@@ -125,50 +113,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("omits empty initial system blocks", () =>
Effect.gen(function* () {
const empty = yield* compileRequest(LLM.request({ model, system: "", prompt: "hello" }))
const cachedEmpty = yield* compileRequest(
LLM.request({
model,
system: [{ type: "text", text: "", cache: new CacheHint({ type: "ephemeral" }) }],
prompt: "hello",
cache: "none",
}),
)
expect(empty.body.system).toBeUndefined()
expect(cachedEmpty.body.system).toBeUndefined()
}),
)
it.effect("omits empty system blocks while preserving order and cache hints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
system: [
{ type: "text", text: "", cache },
{ type: "text", text: "First." },
{ type: "text", text: " " },
{ type: "text", text: "" },
{ type: "text", text: "Second.", cache },
],
prompt: "hello",
cache: "none",
}),
)
expect(prepared.body.system).toEqual([
{ text: "First." },
{ text: " " },
{ text: "Second." },
{ cachePoint: { type: "default" } },
])
}),
)
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -311,79 +255,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
Effect.gen(function* () {
const input = {
path: "file.ts",
edits: [
{ oldText: "a", newText: "b", "": "" },
null,
true,
7,
"text",
["kept", { "": false, nested: { "": null, value: "ok" } }],
],
nested: { "": "drop", empty: {}, onlyEmpty: { "": 1 } },
" ": "preserve whitespace key",
"": "drop",
}
const original = structuredClone(input)
const call = ToolCallPart.make({ id: "tool_1", name: "edit", input })
const prepared = yield* compileRequest(
LLM.request({ model, messages: [Message.assistant([call])], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{
toolUse: {
toolUseId: "tool_1",
name: "edit",
input: {
path: "file.ts",
edits: [{ oldText: "a", newText: "b" }, null, true, 7, "text", ["kept", { nested: { value: "ok" } }]],
nested: { empty: {}, onlyEmpty: {} },
" ": "preserve whitespace key",
},
},
},
],
},
])
expect(input).toEqual(original)
expect(call.input).toBe(input)
}),
)
it.effect("keeps empty tool inputs and empties inputs containing only empty keys", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "tool_empty_key", name: "first", input: { "": { value: true } } }),
ToolCallPart.make({ id: "tool_empty_object", name: "second", input: {} }),
]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ toolUse: { toolUseId: "tool_empty_key", name: "first", input: {} } },
{ toolUse: { toolUseId: "tool_empty_object", name: "second", input: {} } },
],
},
])
}),
)
it.effect("merges parallel tool results into one user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -514,77 +385,19 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects truncated event-stream frames after message stop", () =>
it.effect("maps truncation and malformed output stop reasons", () =>
Effect.gen(function* () {
const partialFrames = [
eventFrame("metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }).subarray(0, 3),
exceptionFrame("modelStreamErrorException", { originalMessage: "Upstream model failed" }).subarray(0, -1),
]
const reasons = [
["model_context_window_exceeded", "length"],
["malformed_model_output", "error"],
["malformed_tool_use", "error"],
] as const
for (const partial of partialFrames) {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(concat([eventFrame("messageStop", { stopReason: "end_turn" }), partial]))),
Effect.flip,
for (const [raw, normalized] of reasons) {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput", classification: "incomplete-stream" },
message: `Incomplete Bedrock Converse event-stream frame: ${partial.length} buffered bytes remain at end of stream`,
})
expect(error.reason.body).toBe(Encoding.encodeBase64(partial))
}
}),
)
it.effect("decodes frames split across transport chunks through exact-boundary EOF", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedByteChunks(body.subarray(0, 2), body.subarray(2, 17), body.subarray(17))),
)
expect(response.text).toBe("Hello")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("maps model context window exhaustion to length", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "model_context_window_exceeded" }]))),
)
expect(response.finishReason).toEqual({
normalized: "length",
raw: "model_context_window_exceeded",
})
}),
)
it.effect("fails malformed output stop reasons", () =>
Effect.gen(function* () {
for (const reason of ["malformed_model_output", "malformed_tool_use"] as const) {
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
const error = yield* LLMClient.stream(baseRequest).pipe(
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
Stream.runDrain,
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: reason }]))),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
message: `Bedrock Converse stopped with ${reason}`,
})
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
headers: { ":event-type": { value: "messageStop" } },
body: JSON.stringify({ stopReason: reason }),
})
expect((yield* Ref.get(events)).some((event) => event.type === "finish")).toBeFalse()
expect(response.finishReason).toEqual({ normalized, raw })
}
}),
)
@@ -631,45 +444,10 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("retains metadata usage that arrives before messageStop", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("rejects metadata-only streams as incomplete with HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(eventStreamBody(["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }])),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
http: {
status: 200,
headers: { "content-type": "application/vnd.amazon.eventstream" },
},
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -1075,7 +853,7 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () {
// Bedrock represents redactedContent blobs as base64 strings on its JSON
// wire. The provider owns the payload and requires byte-exact replay.
const redactedData = "AQID"
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const response = yield* LLMClient.generate(
LLMRequest.update(baseRequest, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
@@ -1085,8 +863,10 @@ describe("Bedrock Converse route", () => {
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AgM=" } } }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: redactedData } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
[
"contentBlockStart",
@@ -1102,17 +882,12 @@ describe("Bedrock Converse route", () => {
),
),
)
expect(response.events.filter((event) => event.type === "reasoning-delta" && event.text === "").at(-1)).toEqual({
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
type: "reasoning-delta",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { redactedData } },
})
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "reasoning-0",
providerMetadata: { bedrock: { redactedData } },
})
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -1143,73 +918,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("keeps redacted reasoning accumulation separate by content block index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 2, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
["contentBlockDelta", { contentBlockIndex: 2, delta: { reasoningContent: { redactedContent: "Ag==" } } }],
["contentBlockStop", { contentBlockIndex: 2 }],
["contentBlockDelta", { contentBlockIndex: 7, delta: { reasoningContent: { redactedContent: "Aw==" } } }],
["contentBlockDelta", { contentBlockIndex: 7, delta: { reasoningContent: { redactedContent: "BA==" } } }],
["contentBlockStop", { contentBlockIndex: 7 }],
["messageStop", { stopReason: "end_turn" }],
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AQI=" } } },
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AwQ=" } } },
])
}),
)
it.effect("preserves split redacted reasoning when contentBlockStop is missing", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AgM=" } } }],
["messageStop", { stopReason: "end_turn" }],
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AQID" } } },
])
}),
)
it.effect("rejects invalid redacted reasoning base64 with the triggering event", () =>
Effect.gen(function* () {
const payload = { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "%%==" } } }
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(eventStreamBody(["contentBlockDelta", payload]))),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
message: "Bedrock Converse reasoningContent.redactedContent contains invalid base64 data",
})
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
headers: { ":event-type": { value: "contentBlockDelta" } },
body: JSON.stringify(payload),
})
expect(error.reason.cause).toBeInstanceOf(Error)
}),
)
it.effect("ignores unknown normal stream events", () =>
Effect.gen(function* () {
const body = concat([
@@ -1464,20 +1172,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects image media that is not valid base64", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "image/png", data: "https://example.test/image.png" })],
}),
).pipe(Effect.flip)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
}),
)
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1601,37 +1295,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects remote media URLs in tool results", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{
type: "file",
uri: "https://example.test/report.pdf",
mime: "application/pdf",
name: "report.pdf",
},
],
},
}),
],
}),
).pipe(Effect.flip)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
}),
)
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -62,62 +62,6 @@ describe("provider error retention", () => {
)
}
it.effect("classifies a message-less Gemini 429 and retains its event and HTTP context", () =>
Effect.gen(function* () {
const body = JSON.stringify({
error: { code: 429, status: "RESOURCE_EXHAUSTED", details: { opaque: [1, 2] } },
trace: { opaque: "outer" },
})
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(
fixedResponse(sseEvents(body), {
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
}),
),
Effect.flip,
)
expect(error.message).toBe("RESOURCE_EXHAUSTED")
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("rejects a malformed non-record Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Invalid google/gemini stream event")
expect(error.reason.body).toBe(body)
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("rejects and retains an explicit null Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: null, trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-null" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("retains malformed provider frames and the original decode cause", () =>
Effect.gen(function* () {
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
-138
View File
@@ -906,94 +906,6 @@ describe("Gemini route", () => {
}),
)
it.effect("assigns unique ids to separated reasoning blocks", () =>
Effect.gen(function* () {
const body = sseEvents(
{
candidates: [
{
content: {
role: "model",
parts: [{ text: "A", thought: true, thoughtSignature: "reasoning_sig_a" }],
},
},
],
},
{
candidates: [
{
content: { role: "model", parts: [{ text: "X", thoughtSignature: "text_sig_x" }] },
},
],
},
{
candidates: [
{
content: {
role: "model",
parts: [{ text: "B", thought: true, thoughtSignature: "reasoning_sig_b" }],
},
},
],
},
{
candidates: [
{
content: { role: "model", parts: [{ text: "Y", thoughtSignature: "text_sig_y" }] },
finishReason: "STOP",
},
],
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const starts = response.events.filter((event) => event.type === "reasoning-start")
const deltas = response.events.filter((event) => event.type === "reasoning-delta")
const ends = response.events.filter((event) => event.type === "reasoning-end")
expect(starts.map((event) => event.id)).toEqual(["reasoning-0", "reasoning-1"])
expect(starts[0]?.id).not.toBe(starts[1]?.id)
expect(deltas.map((event) => ({ id: event.id, text: event.text }))).toEqual([
{ id: "reasoning-0", text: "A" },
{ id: "reasoning-1", text: "B" },
])
expect(ends.map((event) => event.id)).toEqual(["reasoning-0", "reasoning-1"])
expect(response.events.filter((event) => event.type === "text-start").map((event) => event.id)).toEqual([
"text-0",
"text-1",
])
expect(response.events.filter((event) => event.type === "text-delta").map((event) => event.id)).toEqual([
"text-0",
"text-1",
])
expect(response.events.filter((event) => event.type === "text-end").map((event) => event.id)).toEqual([
"text-0",
"text-1",
])
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "A",
providerMetadata: { google: { thoughtSignature: "reasoning_sig_a" } },
},
{
type: "text",
text: "X",
providerMetadata: { google: { thoughtSignature: "text_sig_x" } },
},
{
type: "reasoning",
text: "B",
providerMetadata: { google: { thoughtSignature: "reasoning_sig_b" } },
},
{
type: "text",
text: "Y",
providerMetadata: { google: { thoughtSignature: "text_sig_y" } },
},
])
}),
)
it.effect("ignores unknown response parts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1368,56 +1280,6 @@ describe("Gemini route", () => {
}),
)
it.effect("separates text blocks around streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: "before" },
{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } },
{ text: "after" },
],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.slice(1, 8)).toEqual([
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "before" },
{ type: "text-end", id: "text-0" },
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
{ type: "text-start", id: "text-1" },
{ type: "text-delta", id: "text-1", text: "after" },
{ type: "text-end", id: "text-1" },
])
const textStarts = response.events.filter((event) => event.type === "text-start")
expect(textStarts[0]?.id).not.toBe(textStarts[1]?.id)
expect(response.message.content).toEqual([
{ type: "text", text: "before" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
{ type: "text", text: "after" },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "STOP" })
}),
)
it.effect("defaults omitted function call args to an empty object", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -1,694 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { Mistral } from "../../src/providers/index.js"
import { MistralChat } from "../../src/protocols/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = Mistral.configure({ apiKey: "fixture" }).model("mistral-large-latest")
const request = LLM.request({ model, prompt: "Hello" })
const chunk = (delta: object, finishReason: string | null = null, usage?: object) => ({
choices: [{ delta, finish_reason: finishReason }],
usage,
})
describe("Mistral Chat", () => {
test("exposes native provider and protocol identities", async () => {
const entrypoint = await import("@opencode-ai/ai/providers/mistral")
expect(Mistral.id).toBe("mistral")
expect(MistralChat.protocol.id).toBe("mistral-chat")
expect(Mistral.route).toMatchObject({
id: "mistral-chat",
provider: "mistral",
providerMetadataKey: "mistral",
protocol: "mistral-chat",
})
expect(Mistral.route.endpoint).toMatchObject({
baseURL: "https://api.mistral.ai/v1",
path: "/chat/completions",
})
expect(entrypoint.model).toBeFunction()
})
it.effect("lowers native messages, media, tool choice, options, and replay IDs", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
system: "Initial",
messages: [
Message.system("Updated"),
Message.user([
{ type: "text", text: "Inspect" },
{ type: "media", mediaType: "image/png", data: "aW1hZ2U=" },
{ type: "media", mediaType: "application/pdf", data: "cGRm" },
]),
Message.assistant([
{ type: "reasoning", text: "Think" },
{ type: "text", text: "Calling" },
{ type: "tool-call", id: "call.same-prefix-1", name: "lookup", input: { city: "Paris" } },
{ type: "tool-call", id: "call.same-prefix-2", name: "other", input: {} },
]),
Message.tool({ id: "call.same-prefix-1", name: "lookup", result: { ok: true } }),
],
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up a city", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "other", description: "Other operation", inputSchema: { type: "object" } }),
],
toolChoice: "lookup",
promptCacheKey: "session-1",
generation: {
maxTokens: 64,
seed: 7,
temperature: 0.2,
topP: 0.8,
frequencyPenalty: 0.1,
presencePenalty: 0.3,
stop: ["done"],
},
providerOptions: {
safePrompt: true,
documentImageLimit: 3,
documentPageLimit: 8,
parallelToolCalls: false,
reasoningEffort: "high",
},
}),
)
expect(prepared.body).toMatchObject({
model: "mistral-large-latest",
tools: [{ function: { name: "lookup", strict: false } }, { function: { name: "other", strict: false } }],
tool_choice: { type: "function", function: { name: "lookup" } },
stream: true,
max_tokens: 64,
random_seed: 7,
temperature: 0.2,
top_p: 0.8,
frequency_penalty: 0.1,
presence_penalty: 0.3,
stop: ["done"],
prompt_cache_key: "session-1",
safe_prompt: true,
document_image_limit: 3,
document_page_limit: 8,
parallel_tool_calls: false,
reasoning_effort: "high",
})
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
{ role: "system", content: "Initial" },
{ role: "user", content: "<system-update>\nUpdated\n</system-update>" },
{
role: "user",
content: [
{ type: "text", text: "Inspect" },
{ type: "image_url", image_url: "data:image/png;base64,aW1hZ2U=" },
{ type: "document_url", document_url: "data:application/pdf;base64,cGRm" },
],
},
{
role: "assistant",
content: "ThinkCalling",
},
])
const assistant = prepared.body.messages[3]
const toolResult = prepared.body.messages[4]
expect(assistant?.role).toBe("assistant")
expect(toolResult?.role).toBe("tool")
if (assistant?.role !== "assistant" || toolResult?.role !== "tool") return
const ids = assistant.tool_calls?.map((tool) => tool.id) ?? []
expect(ids).toHaveLength(2)
expect(ids[0]).toMatch(/^[A-Za-z0-9]{9}$/)
expect(ids[1]).toMatch(/^[A-Za-z0-9]{9}$/)
expect(ids[0]).not.toBe(ids[1])
expect(toolResult.tool_call_id).toBe(ids[0])
expect(toolResult.name).toBe("lookup")
}),
)
it.effect("preserves valid replay IDs", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }),
Message.tool({ id: "Ab12Cd34E", name: "lookup", result: "ok" }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ tool_calls: [{ id: "Ab12Cd34E" }] },
{ tool_call_id: "Ab12Cd34E" },
])
}),
)
it.effect("applies trailing prefix, cache, and reasoning options without changing earlier assistants", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
promptCacheKey: "common-key",
messages: [Message.assistant("Earlier"), Message.user("Continue"), Message.assistant("Prefix")],
providerOptions: { promptCacheKey: "native-key", promptMode: "reasoning" },
}),
)
expect(prepared.body.prompt_cache_key).toBe("native-key")
expect(prepared.body.prompt_mode).toBe("reasoning")
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Earlier" },
{ role: "user", content: "Continue" },
{ role: "assistant", content: "Prefix", prefix: true },
])
const uncached = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
promptCacheKey: "common-key",
cache: "none",
providerOptions: { promptCacheKey: "native-key" },
}),
)
expect(uncached.body.prompt_cache_key).toBeUndefined()
const longKey = "cache-key-".repeat(10)
const unbounded = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
promptCacheKey: longKey,
}),
)
expect(unbounded.body.prompt_cache_key).toBe(longKey)
const conflict = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
providerOptions: { reasoningEffort: "high", promptMode: "reasoning" },
}),
).pipe(Effect.flip)
expect(conflict.message).toContain("mutually exclusive")
}),
)
it.effect("omits empty assistant history unless it carries a tool call", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant(" \n "),
Message.assistant({ type: "reasoning", text: "\t" }),
Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: "",
tool_calls: [{ id: "Ab12Cd34E", type: "function", function: { name: "lookup", arguments: "{}" } }],
},
])
}),
)
it.effect("preserves remote media URLs and structured tool-result media", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user({
type: "media",
mediaType: "image/png",
data: "https://assets.example.test/input.png",
}),
Message.tool({
id: "Ab12Cd34E",
name: "inspect",
resultType: "content",
result: [
{ type: "text", text: "Result" },
{ type: "file", mime: "image/jpeg", uri: "https://assets.example.test/output.jpg" },
{ type: "file", mime: "application/pdf", uri: "cGRm" },
],
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ type: "image_url", image_url: "https://assets.example.test/input.png" }],
},
{
role: "tool",
tool_call_id: "Ab12Cd34E",
name: "inspect",
content: [
{ type: "text", text: "Result" },
{ type: "image_url", image_url: "https://assets.example.test/output.jpg" },
{ type: "document_url", document_url: "data:application/pdf;base64,cGRm" },
],
},
])
}),
)
it.effect("concatenates text-only user and tool content without separators", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "text", text: "first" },
{ type: "text", text: "second" },
]),
Message.tool({
id: "Ab12Cd34E",
name: "lookup",
resultType: "content",
result: [
{ type: "text", text: "third" },
{ type: "text", text: "fourth" },
],
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "firstsecond" },
{ role: "tool", tool_call_id: "Ab12Cd34E", name: "lookup", content: "thirdfourth" },
])
}),
)
it.effect("streams ordered thinking and text and replays native thinking metadata", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
chunk({ content: [{ type: "text", text: "Answer" }] }),
chunk({}, "stop"),
),
),
),
)
expect(response.reasoning).toBe("Consider")
expect(response.text).toBe("Answer")
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Consider",
providerMetadata: {
mistral: {
thinking: {
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
marker: "empty",
},
},
},
},
{ type: "text", text: "Answer" },
])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: [
{
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
marker: "empty",
},
{ type: "text", text: "Answer" },
],
prefix: true,
},
])
}),
)
it.effect("replays metadata-only native thinking", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(chunk({ content: [{ type: "thinking", thinking: [], marker: "opaque" }] }), chunk({}, "stop")),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "",
providerMetadata: {
mistral: { thinking: { type: "thinking", thinking: [], marker: "opaque" } },
},
},
])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: [], marker: "opaque" }],
prefix: true,
},
])
}),
)
it.effect("merges indexed argument fragments with missing continuation identity", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }],
}),
chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: '"Paris"}' } }] }),
chunk({}, "tool_calls"),
),
),
),
)
expect(response.message.content).toContainEqual({
type: "tool-call",
id: "Ab12Cd34E",
name: "lookup",
input: { city: "Paris" },
})
expect(
response.events.filter(
(event) =>
LLMEvent.is.toolInputStart(event) ||
LLMEvent.is.toolInputDelta(event) ||
LLMEvent.is.toolInputEnd(event) ||
LLMEvent.is.toolCall(event),
),
).toEqual([
{ type: "tool-input-start", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
{
type: "tool-input-delta",
id: "Ab12Cd34E",
name: "lookup",
text: '{"city":',
input: {},
},
{
type: "tool-input-delta",
id: "Ab12Cd34E",
name: "lookup",
text: '"Paris"}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
id: "Ab12Cd34E",
name: "lookup",
input: { city: "Paris" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("normalizes stop to tool calls when a hosted model emits indexed tool fragments", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [
{
index: 0,
id: "chatcmpl-tool-8cc4d8f9f07b298a",
function: { name: "lookup", arguments: '{"city":"' },
},
],
}),
chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: 'Paris"}' } }] }),
chunk({}, "stop"),
),
),
),
)
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "stop" })
expect(response.toolCalls).toMatchObject([{ name: "lookup", input: { city: "Paris" } }])
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("generates a stable ID when the first indexed fragment has null identity", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [{ index: 0, id: null, function: { name: "lookup", arguments: { city: "Paris" } } }],
}),
chunk({}, "tool_calls"),
),
),
),
)
const call = response.message.content.find((part) => part.type === "tool-call")
expect(call?.id).toMatch(/^[A-Za-z0-9]{9}$/)
expect(call).toMatchObject({ name: "lookup", input: { city: "Paris" } })
}),
)
it.effect("generates distinct IDs for parallel null and literal-null identities", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [
{ index: 0, id: null, function: { name: "first", arguments: {} } },
{ index: 1, id: "null", function: { name: "second", arguments: {} } },
],
}),
chunk({}, "tool_calls"),
),
),
),
)
const calls = response.message.content.filter((part) => part.type === "tool-call")
expect(calls).toHaveLength(2)
expect(calls[0]?.id).toMatch(/^[A-Za-z0-9]{9}$/)
expect(calls[1]?.id).toMatch(/^[A-Za-z0-9]{9}$/)
expect(calls[0]?.id).not.toBe(calls[1]?.id)
}),
)
it.effect("keeps parallel indexed calls independent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [
{ index: 0, id: "Ab12Cd34E", function: { name: "first", arguments: '{"n":' } },
{ index: 1, id: "Fg56Hi78J", function: { name: "second", arguments: '{"n":' } },
],
}),
chunk({
tool_calls: [
{ index: 0, function: { arguments: "1}" } },
{ index: 1, function: { arguments: "2}" } },
],
}),
chunk({}, "tool_calls"),
),
),
),
)
expect(response.message.content.filter((part) => part.type === "tool-call")).toEqual([
{ type: "tool-call", id: "Ab12Cd34E", name: "first", input: { n: 1 } },
{ type: "tool-call", id: "Fg56Hi78J", name: "second", input: { n: 2 } },
])
}),
)
it.effect("correlates parallel identity-less fragments by batch position", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [
{ function: { name: "first", arguments: '{"n":' } },
{ function: { name: "second", arguments: '{"n":' } },
],
}),
chunk({
tool_calls: [{ function: { arguments: "1}" } }, { function: { arguments: "2}" } }],
}),
chunk({}, "tool_calls"),
),
),
),
)
expect(response.message.content.filter((part) => part.type === "tool-call")).toMatchObject([
{ name: "first", input: { n: 1 } },
{ name: "second", input: { n: 2 } },
])
}),
)
it.effect("maps usage variants and clamps cache reads", () =>
Effect.gen(function* () {
for (const usage of [
{ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7, num_cached_tokens: 9 },
{ prompt_tokens: 5, completion_tokens: 2, prompt_token_details: { cached_tokens: 2 } },
{ prompt_tokens: 5, completion_tokens: 2, prompt_tokens_details: { cached_tokens: 3 } },
]) {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(chunk({}, "stop", usage)))),
)
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
totalTokens: 7,
})
expect(response.usage?.cacheReadInputTokens).toBe(
Math.min(
5,
usage.num_cached_tokens ??
usage.prompt_token_details?.cached_tokens ??
usage.prompt_tokens_details?.cached_tokens ??
0,
),
)
}
}),
)
it.effect("maps finish reasons and does not finalize truncated tool calls", () =>
Effect.gen(function* () {
for (const [raw, normalized] of [
["stop", "stop"],
["model_length", "length"],
["tool_calls", "tool-calls"],
["error", "error"],
["future_reason", "unknown"],
] as const) {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(chunk({}, raw)))),
)
expect(response.finishReason).toEqual({ normalized, raw })
}
const truncated = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }],
}),
chunk({}, "length"),
),
),
),
)
expect(truncated.finishReason).toEqual({ normalized: "length", raw: "length" })
expect(truncated.events.some(LLMEvent.is.toolCall)).toBe(false)
expect(truncated.events.some(LLMEvent.is.toolInputEnd)).toBe(false)
}),
)
it.effect("ignores non-text output parts and rejects invalid stream endings", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({ content: null }),
chunk({
content: [
{ type: "reference", reference_ids: [1] },
{ type: "image_url", image_url: "https://example.test/image.png" },
{ type: "text", text: "Answer" },
],
}),
chunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("Answer")
const missingFinish = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(chunk({ content: "partial" })))),
Effect.flip,
)
expect(missingFinish.message).toContain("without finish_reason")
const lateContent = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents(chunk({}, "stop"), chunk({ content: [{ type: "text", text: "late" }] }))),
),
Effect.flip,
)
expect(lateContent.message).toContain("content after the finish reason")
}),
)
it.effect("uses environment bearer auth and custom package settings", () =>
LLMClient.generate(
LLM.request({
model: Mistral.model("fixture-model", {
baseURL: "https://mistral.test/v1",
headers: { "x-app": "test" },
body: { service_tier: "priority" },
providerOptions: { safePrompt: true },
}),
prompt: "Hello",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://mistral.test/v1/chat/completions")
expect(web.headers.get("authorization")).toBe("Bearer secret")
expect(web.headers.get("x-app")).toBe("test")
expect(input.text).toContain('"service_tier":"priority"')
return input.respond(sseEvents(chunk({}, "stop")), { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { MISTRAL_API_KEY: "secret" } }))),
),
)
})
@@ -1,159 +0,0 @@
import { configure } from "@opencode-ai/ai/providers/mistral"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.MISTRAL_API_KEY ?? "fixture"
const recorded = recordedTests({
prefix: "mistral-chat",
provider: "mistral",
protocol: "mistral-chat",
requires: ["MISTRAL_API_KEY"],
})
const glmRecorded = recordedTests({
prefix: "mistral-chat-glm",
provider: "mistral",
protocol: "mistral-chat",
requires: ["MISTRAL_API_KEY"],
})
const weather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
additionalProperties: false,
},
})
describe("Mistral recorded", () => {
recorded.effect.with(
"streams text with usage",
{ tags: ["text", "usage"], metadata: { model: "mistral-small-latest" } },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest"),
prompt: "Reply with exactly one word: hello",
generation: { maxTokens: 40, temperature: 0 },
}),
)
expect(response.text.trim()).toMatch(/^(?:hello|hi)[!.]?$/i)
expect(response.finishReason.normalized).toBe("stop")
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
60_000,
)
recorded.effect.with(
"replays native reasoning",
{ tags: ["reasoning", "replay", "usage"], metadata: { model: "mistral-small-latest" } },
() =>
Effect.gen(function* () {
const model = configure({ apiKey, providerOptions: { reasoningEffort: "high" } }).model("mistral-small-latest")
const firstRequest = LLM.request({
model,
prompt: "Calculate 17 multiplied by 23. Think briefly, then reply with only the integer.",
generation: { maxTokens: 512, temperature: 0 },
})
const first = yield* LLMClient.generate(firstRequest)
expect(first.text.trim()).toBe("391")
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
const followUp = LLMRequest.update(firstRequest, {
messages: [...firstRequest.messages, first.message, Message.user("Reply with exactly: Done.")],
generation: { maxTokens: 256, temperature: 0 },
})
const replay = yield* compileRequest(followUp)
expect(replay.body.messages).toContainEqual(
expect.objectContaining({
role: "assistant",
content: expect.arrayContaining([expect.objectContaining({ type: "thinking" })]),
}),
)
const second = yield* LLMClient.generate(followUp)
expect(second.text.trim()).toMatch(/Done\.?$/)
expect(second.finishReason.normalized).toBe("stop")
}),
60_000,
)
recorded.effect.with(
"drives a tool loop",
{ tags: ["tool", "tool-loop", "usage"], metadata: { model: "mistral-small-latest" } },
() =>
Effect.gen(function* () {
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
const firstRequest = LLM.request({
model,
system: "Call lookup_weather exactly once with Paris.",
prompt: "What is the weather?",
tools: [weather],
toolChoice: weather,
generation: { maxTokens: 160, temperature: 0 },
})
const first = yield* LLMClient.generate(firstRequest)
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.toolCalls).toMatchObject([{ name: "lookup_weather", input: { city: "Paris" } }])
expect(first.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
const call = first.toolCalls[0]
if (!call) throw new Error("Mistral did not return a tool call")
const followUp = LLMRequest.update(firstRequest, {
toolChoice: ToolChoice.make("none"),
messages: [
...firstRequest.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
],
generation: { maxTokens: 160, temperature: 0 },
})
const second = yield* LLMClient.generate(followUp)
expect(second.finishReason.normalized).toBe("stop")
expect(second.toolCalls).toHaveLength(0)
expect(second.text.toLowerCase()).toContain("sunny")
}),
60_000,
)
})
describe("Mistral hosted GLM recorded", () => {
glmRecorded.effect.with(
"streams an indexed tool call",
{ tags: ["hosted-model", "tool", "tool-call"], metadata: { model: "zai-glm-5-2" } },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: configure({ apiKey }).model("zai-glm-5-2"),
system: "Call lookup_weather exactly once with Paris.",
prompt: "What is the weather?",
tools: [weather],
toolChoice: weather,
generation: { maxTokens: 256, temperature: 0 },
}),
)
expect(response.finishReason.normalized).toBe("tool-calls")
expect(response.toolCalls).toMatchObject([{ name: "lookup_weather", input: { city: "Paris" } }])
expect(response.events.filter(LLMEvent.is.toolInputStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolInputDelta).length).toBeGreaterThan(0)
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
60_000,
)
})
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -216,63 +216,7 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
id: "msg_text",
content: [{ type: "output_text", text: "Done-only text." }],
}
const refusal = {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_text",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-end",
id: "msg_text",
text: "Done-only text.",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-start",
id: "msg_refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
{
type: "text-end",
id: "msg_refusal",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
])
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -289,44 +233,9 @@ describe("Open Responses basic-item lifecycles", () => {
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
@@ -96,27 +96,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("omits user messages with no content", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.user([]), Message.user("After.")],
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
}),
)
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
Effect.gen(function* () {
const model = configure({
@@ -4017,7 +4017,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,6 +4032,7 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
-32
View File
@@ -21,7 +21,6 @@ import {
QuotaExceededError,
RateLimitError,
RouteID,
ToolResultValue,
TransportError,
UnknownProviderError,
Usage,
@@ -84,37 +83,6 @@ describe("llm schema", () => {
})
})
describe("ToolResultValue", () => {
test("uses the canonical schema guard", () => {
const cases: ReadonlyArray<{ readonly value: unknown; readonly expected: boolean }> = [
{ value: { type: "json", value: { ok: true } }, expected: true },
{ value: { type: "text", value: "done" }, expected: true },
{ value: { type: "error", value: "failed" }, expected: true },
{ value: { type: "content", value: [{ type: "text", text: "done" }] }, expected: true },
{ value: { type: "content", value: [{ type: "text" }] }, expected: false },
{ value: { type: "content", value: "done" }, expected: false },
{ value: { type: "json" }, expected: false },
{ value: { type: "unknown", value: "done" }, expected: false },
]
for (const item of cases) {
expect(Schema.is(ToolResultValue)(item.value)).toBe(item.expected)
expect(ToolResultValue.is(item.value)).toBe(item.expected)
}
})
test("accepts canonical results with extra fields", () => {
expect(ToolResultValue.is({ type: "json", value: { ok: true }, metadata: { source: "tool" } })).toBe(true)
expect(
ToolResultValue.is({
type: "content",
value: [{ type: "file", uri: "https://example.test/result.txt", mime: "text/plain", checksum: "abc" }],
metadata: { source: "tool" },
}),
).toBe(true)
})
})
describe("AI.Usage", () => {
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
// Defense against a provider reporting cached_tokens > prompt_tokens or
-77
View File
@@ -1,77 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolCallPart, ToolResultPart } from "../src/schema/messages.js"
import { normalizeToolHistory } from "../src/tool-history.js"
const toolCall = (id: string, name = id) => ToolCallPart.make({ id, name, input: {} })
const toolResult = (id: string, value: unknown, name = id, resultType?: "text" | "content" | "error") =>
Message.tool(ToolResultPart.make({ id, name, result: value, resultType }))
describe("tool history normalization", () => {
test("fills missing local results before the next step", () => {
const normalized = normalizeToolHistory([
Message.assistant([toolCall("first"), toolCall("second")]),
toolResult("first", "done", "wrong", "text"),
Message.user("Continue."),
Message.assistant(toolCall("trailing")),
])
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" } },
])
expect(normalized[4]?.content).toEqual([toolCall("trailing")])
})
test("normalizes empty results without changing whitespace or media", () => {
const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" }
const normalized = normalizeToolHistory([
Message.assistant([
toolCall("text"),
toolCall("content"),
toolCall("error"),
toolCall("mixed"),
toolCall("whitespace"),
]),
toolResult("text", "", "text", "text"),
toolResult("content", [], "content", "content"),
toolResult("error", "", "error", "error"),
toolResult("mixed", [{ type: "text", text: "" }, media], "mixed", "content"),
toolResult("whitespace", " ", "whitespace", "text"),
])
expect(normalized.slice(1).map((message) => message.content[0])).toEqual([
{ type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } },
{ type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } },
{ type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } },
{ type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } },
{ type: "tool-result", id: "whitespace", name: "whitespace", result: { type: "text", value: " " } },
])
})
test("leaves unmatched and provider-executed history unchanged", () => {
const hostedCall = ToolCallPart.make({
id: "hosted",
name: "web_search",
input: {},
providerExecuted: true,
})
const hostedResult = ToolResultPart.make({
id: "hosted",
name: "web_search",
result: "",
resultType: "text",
providerExecuted: true,
})
const hosted = Message.assistant([hostedCall, hostedResult])
const orphan = toolResult("orphan", "ignored", "orphan", "text")
expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted])
})
})
-27
View File
@@ -442,33 +442,6 @@ describe("LLMClient tools", () => {
}),
)
it.effect("projects malformed tagged dynamic output as opaque JSON", () =>
Effect.gen(function* () {
const malformed = { type: "content", value: [{ type: "text" }] }
const dynamic = Tool.make({
description: "Return caller-defined JSON.",
jsonSchema: { type: "object", properties: {} },
execute: () => Effect.succeed(malformed),
})
const dispatched = yield* ToolRuntime.dispatch(
{ dynamic },
LLMEvent.toolCall({ id: "call_1", name: "dynamic", input: {} }),
)
expect(dispatched.result).toEqual({ type: "json", value: malformed })
expect(dispatched.output).toEqual({ structured: malformed, content: [] })
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "dynamic",
result: { type: "json", value: malformed },
output: { structured: malformed, content: [] },
}),
])
}),
)
it.effect("executes tool calls for one step without looping by default", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
-1
View File
@@ -1,6 +1,5 @@
src/assets/theme.css
e2e/test-results
e2e/performance/results/
e2e/playwright-report
component-tests/test-results
component-tests/playwright-report
+1 -22
View File
@@ -71,25 +71,4 @@ Environment options:
## Deployment
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
| Branch | Site |
| ------------ | --------------------- |
| `dev` | `app.dev.opencode.ai` |
| `production` | `app.opencode.ai` |
| `beta` | `beta.opencode.ai` |
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
owns `opencode.ai`. The beta GitHub environment must allow deployments from the `beta` branch; it does not
need AWS credentials.
SST manages the beta site's custom domain. The first deployment creates its DNS record and TLS certificate.
Do not create a CNAME for `beta.opencode.ai` first, because it would conflict with the Workers custom domain.
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
@@ -1,93 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
await component.locator('[data-action="composer-attach"]').click()
const shortcuts = page.locator('[role="menu"] [data-slot="menu-v2-item-shortcut"]')
await expect(shortcuts).toHaveCount(4)
const boxes = await shortcuts.evaluateAll((items) =>
items.map((item) => {
const box = item.getBoundingClientRect()
return { width: box.width, center: box.left + box.width / 2 }
}),
)
expect(new Set(boxes.map((box) => box.width)).size).toBe(1)
expect(new Set(boxes.map((box) => box.center)).size).toBe(1)
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const text = await input.textContent()
for (let count = 0; count < 2; count++) {
await input.press("ControlOrMeta+a")
expect(
await input.evaluate((editor) => {
const selection = window.getSelection()
return {
text: selection?.toString(),
inside: editor.contains(selection?.anchorNode ?? null) && editor.contains(selection?.focusNode ?? null),
}
}),
).toEqual({ text, inside: true })
}
await page.keyboard.type("Replacement draft")
await expect(input).toHaveText("Replacement draft")
await expect(component.getByRole("status")).toHaveText("Ready")
if (draft === "mixed-attachments") {
await expect(component.getByAltText("layout.png")).toBeVisible()
await expect(component.getByText("Keep the normal flow flat", { exact: true })).toBeVisible()
}
})
}
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
await page.addInitScript(() => {
const replace = Element.prototype.replaceChildren
Element.prototype.replaceChildren = function (this: Element, ...nodes) {
// The ref can run before data-component is assigned, so count on every target.
this.setAttribute("data-test-replacements", String(Number(this.getAttribute("data-test-replacements")) + 1))
return replace.apply(this, nodes)
}
})
const component = await mount("opencode-composer-flow--failed-submission-restoration")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
await expect(input).toHaveText("Preserve this draft on failure")
await expect(input).toHaveAttribute("data-test-replacements", "1")
await input.press("Home")
await input.press("Shift+ArrowRight")
await input.pressSequentially("XY")
await expect(input).toHaveText("XYreserve this draft on failure")
await expect(input).toHaveAttribute("data-test-replacements", "1")
// Closing the model picker restores the controller's saved caret through its editor ref.
await component.locator('[data-action="composer-model"]').click()
await page.getByRole("menu").getByRole("textbox").press("Escape")
await expect(input).toBeFocused()
await input.pressSequentially("!")
await expect(input).toHaveText("XY!reserve this draft on failure")
await component.getByRole("button", { name: "Send", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Submission failed; draft restored")
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 }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
@@ -1,13 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
story("keeps the comment options button pressed while its menu is open", async ({ mount, page }) => {
const component = await mount("ui-line-comment--display")
const trigger = component.locator('[data-slot="line-comment-v2-overflow"]')
const rest = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.click()
await expect(page.getByRole("menu")).toBeVisible()
await expect(trigger).toHaveAttribute("data-expanded", "")
await expect(trigger).not.toHaveCSS("background-color", rest)
})
@@ -1,135 +0,0 @@
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"
export function mountTimelineVirtualizer(input: { count: number; rowHeight: number; immediate?: boolean }) {
const host = document.createElement("main")
host.dataset.testid = "timeline-virtualizer-fixture"
host.dataset.scrolls = "0"
host.dataset.viewportResizes = "0"
host.style.cssText = "position:fixed;top:24px;right:24px;width:400px;z-index:1000"
document.body.appendChild(host)
function Fixture() {
const [state, setState] = createStore({ pinned: true, ready: false })
const rows = Array.from(
{ length: input.count },
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
)
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
let viewport!: HTMLDivElement
let content!: HTMLDivElement
let container!: HTMLDivElement
const timeline = createTimelineVirtualizer({
sessionKey: () => "cold-reveal-fixture",
projection: {
rows: () => rows,
rowByKey: () => rowByKey,
activeMessageID: () => undefined,
messageRowIndex: () => indexes,
messageLastRowIndex: () => indexes,
},
showHeader: () => false,
pinned: () => state.pinned,
scroll: () => ({ overflow: false, jump: false }),
setScrollRef: (element) => {
if (!element) return
viewport = element
resize.observe(element, { box: "border-box" })
},
setContentRef: (element) => {
content = element
reveal.observe(element, { attributes: true, attributeFilter: ["style"] })
},
onPin: () => setState("pinned", true),
onUnpin: () => setState("pinned", false),
onScheduleScrollState: (element) => {
host.dataset.scrolls = String(Number(host.dataset.scrolls) + 1)
host.dataset.lastScrollTop = String(element.scrollTop)
},
onResumeScroll: () => {},
onSelectionInteraction: () => {},
onUserScroll: () => {},
onHistoryScroll: () => {},
canRenderImmediately: () => input.immediate ?? false,
})
const resize = new ResizeObserver((entries) => {
host.dataset.observedHeight = String(entries[0].borderBoxSize[0].blockSize)
host.dataset.viewportResizes = String(Number(host.dataset.viewportResizes) + 1)
})
const reveal = new MutationObserver(() => {
if (content.style.visibility === "hidden" || host.dataset.firstReveal) return
// Capture the first reveal, not a later frame after geometry has recovered.
const mounted = [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")]
host.dataset.firstReveal = JSON.stringify({
rows: mounted.map((element) => Number(element.firstElementChild!.getAttribute("data-index"))),
pendingMarkdown: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
viewportHeight: viewport.clientHeight,
scrollTop: viewport.scrollTop,
clipped: mounted
.filter((element) => element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1)
.map((element) => element.dataset.timelineKey),
})
})
onCleanup(() => {
resize.disconnect()
reveal.disconnect()
})
return (
<div data-testid="timeline-controls" data-pinned={state.pinned}>
<button type="button" onClick={() => setState("ready", true)}>
Complete Markdown
</button>
<button type="button" onClick={() => (container.style.display = "none")}>
Hide viewport
</button>
<button
type="button"
onClick={() => {
const parent = viewport.parentElement!
host.dataset.scrolls = "0"
// Keep the same scroller and complete Markdown while it has no layout box.
viewport.remove()
viewport.scrollTop = 0
setState("ready", true)
parent.prepend(viewport)
container.style.removeProperty("display")
}}
>
Reconnect ready rows
</button>
<div ref={container} style={{ height: "180px", width: "400px" }}>
<timeline.View
header={null}
workspaceSession={() => false}
deferred={() => false}
renderRow={(row) => (
<div
data-component="markdown"
data-markdown-ready={state.ready ? "" : undefined}
style={{ height: `${input.rowHeight}px` }}
>
{row().userMessageID}
</div>
)}
/>
</div>
</div>
)
}
render(
() => (
<LanguageProvider locale="en">
<Fixture />
</LanguageProvider>
),
host,
)
}
@@ -1,103 +0,0 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./timeline-virtualizer.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story.beforeEach(async ({ mount }) => {
const component = await mount("opencode-composer-flow--mixed-attachments")
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
})
story("spaces the first mobile message without changing desktop spacing", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer({ count: 1, rowHeight: 60, immediate: true })
}, fixture)
const root = page.getByTestId("timeline-virtualizer-fixture")
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
const content = root.locator("[data-timeline-virtual-content]")
await expect(content).toHaveCSS("visibility", "visible")
const gap = () =>
root.locator('[data-timeline-key="user-message:message-0"]').evaluate((element) => {
const viewport = element.closest("[data-scrollable]")!
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
})
await expect.poll(gap).toBe(16)
await root.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect.poll(gap).toBe(16)
await page.setViewportSize({ width: 1280, height: 900 })
await expect.poll(gap).toBe(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(gap).toBe(16)
})
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer({ count: 100, rowHeight: 60, immediate: true })
}, fixture)
const root = page.getByTestId("timeline-virtualizer-fixture")
const content = root.locator("[data-timeline-virtual-content]")
await expect(root).toHaveAttribute("data-observed-height", "180")
await expect(content).toHaveCSS("visibility", "hidden")
await expect(content.locator("[data-timeline-key]")).toHaveCount(4)
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
await expect(content).toHaveCSS("visibility", "visible")
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
rows: [96, 97, 98, 99],
pendingMarkdown: 0,
clipped: [],
viewportHeight: 180,
})
})
for (const input of [
{ name: "offset-only", count: 1, rowHeight: 600 },
{ name: "zero-height", count: 4, rowHeight: 60 },
]) {
story(`reveals ready measured rows after an ${input.name} reconnect`, async ({ page }) => {
await page.evaluate(
async ({ fixture, input }) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer(input)
},
{ fixture, input },
)
const root = page.getByTestId("timeline-virtualizer-fixture")
const content = root.locator("[data-timeline-virtual-content]")
await expect(root).toHaveAttribute("data-observed-height", "180")
await expect(content).toHaveCSS("visibility", "hidden")
await expect(content.locator("[data-timeline-key]")).toHaveCount(1)
if (input.name === "offset-only") {
await expect(root).toHaveAttribute("data-last-scroll-top", "484")
await root.locator("[data-scrollable]").dispatchEvent("wheel", { deltaY: -1 })
await expect(root.getByTestId("timeline-controls")).toHaveAttribute("data-pinned", "false")
}
if (input.name === "zero-height") {
await root.getByRole("button", { name: "Hide viewport", exact: true }).click()
// Wait for ResizeObserver to clear the actual range, not just for display:none.
await expect(root).toHaveAttribute("data-observed-height", "0")
await expect(content.locator("[data-timeline-key]")).toHaveCount(0)
}
await expect(root).not.toHaveAttribute("data-first-reveal")
const resizes = await root.getAttribute("data-viewport-resizes")
await root.getByRole("button", { name: "Reconnect ready rows", exact: true }).click()
await expect(content).toHaveCSS("visibility", "visible")
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
rows: input.count === 1 ? [0] : [0, 1, 2, 3],
pendingMarkdown: 0,
clipped: [],
viewportHeight: 180,
...(input.name === "offset-only" ? { scrollTop: 0 } : {}),
})
if (input.name === "offset-only") {
// This repair must not depend on another native scroll or resize delivery.
await expect(root).toHaveAttribute("data-scrolls", "0")
await expect(root).toHaveAttribute("data-viewport-resizes", resizes!)
}
})
}
+4 -53
View File
@@ -65,7 +65,7 @@ The fixture requires every benchmark to call `report()`, automatically names and
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
```
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, optional trace path, and trace scope before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide; the default window is page lifetime. Tab-switch traces begin after scenario setup and include explicit interaction markers. Scenario metrics use their own narrower observation windows.
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
@@ -81,62 +81,13 @@ Committed smoke and regression tests continue to own correctness coverage for pa
Tab-switch timing starts at `mousedown`, when mouse-selected tabs actually navigate, with a `click` fallback for keyboard activation. The probe excludes hidden/transparent content and intersects answers with their virtual-row clip and viewport. The tab workload requires the destination's final answer to be visible with Markdown ready. These results are not directly comparable to older click-start, geometry-only measurements. `stableObservedMs` includes confirmation across three correct samples; `firstCorrectObservedMs` is the first sample meeting all content and geometry checks. Neither is a compositor presentation timestamp.
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=20` for a baseline distribution. Warm scenarios prepare the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
The tab-switch workload uses two equally long sessions: 200 user/assistant exchanges (400 messages) per tab. Every answer includes headings, emphasis, links, a blockquote, task and nested lists, an eight-row table, and four highlighted code fences (TSX, JSON, SQL, Bash), alongside the stress fixture's reasoning and tools. The mock API deliberately returns all 400 messages in one response so every scenario measures a long loaded history, not a short paginated tail. The viewport is fixed at 1440 x 900. Results include the fixture version, Markdown and serialized-message byte counts, and message-request count. These numbers are not directly comparable to the earlier 12-exchange source / 72-exchange destination fixture.
Cold means the destination transcript has never loaded or rendered in that fresh browser context. Its measured switch includes one fixture message fetch. Warm means its complex answer was rendered and ready before switching away and back, and asserts no message fetch during the measured switch. Neither includes app startup or the source session's Markdown engine initialization. These cold results are not comparable to older prefetched cold-render results. Setup waits for mounted Markdown to finish and for the review-pane width transition to complete. Service workers are blocked to exclude the web build's background asset precache from this renderer benchmark. Screenshots are attached after measurement for the first repetition; Playwright video and trace recording are disabled for this workload, while opt-in Chrome profiling remains available. For a baseline distribution, use `--repeat-each=20 --retries=0`, keep profiling disabled, and report the median and p95 of `firstCorrectObservedMs` separately from the three-observation `stableObservedMs`.
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=5` for repeated measurements. Cached scenarios warm the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --retries=0
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
```
**The tab-switch fixture returns full history, not normal pagination.** Measure cold API navigation, Home-row opening, and restored-but-unvisited tabs separately with normal pagination. Do not combine these entry paths or compare different transports and machine-load periods as one experiment.
`inactive-tab-prefetch-benchmark.spec.ts` restores eight tabs with normal 20-message pages (44 parts and 139,257 response bytes per page). It gates heavy responses independently until every tab's attention callback has run, then measures selection with ready answer Markdown and bottom anchoring. A separate case closes an inactive tab before releasing the responses. The fixture reports speculative transcript/inbox reads, request concurrency, response bytes, and activation latency. Set `OPENCODE_PERFORMANCE_MEMORY=1` only in separate retention runs; those force GC before selection and must not be mixed into clean timing results. The scope is the production browser renderer, not total desktop memory. Live background events and eviction of previously visited transcripts are separate workloads.
Keep one-off reports, recorded results, and traces outside git, in the ignored `e2e/performance/results/` directory or an external artifact directory. Preserve raw observations locally and publish anonymized summaries and charts in the PR description, not as committed experiment files.
For a repeatable tab-switch summary, run from `packages/app`:
```sh
bun run bench:tabs
```
This runs only the tab-switch benchmark against the production build with 20 serial repetitions and no retries. It prints the median (mean of the two middle values for even sample counts) and nearest-rank p95 for `firstCorrectObservedMs` and `stableObservedMs` per scenario. Only records whose benchmark and Playwright statuses are passed and whose two metrics are finite enter the summary. Test and record statuses, missing records, and excluded samples are reported separately.
For fresh entry paths, run `bun run bench:entry` from `packages/app`. It uses the same production, serial-repetition, and reporting defaults. The cases open an empty draft from the actual Home button, create a draft with the titlebar plus from an active session, and open a cold paginated session from Home. Draft readiness requires a focused editable composer, the expected model, project control, and new tab; typing and absence of backend mutations are checked afterward. Session readiness requires the latest group, ready answer Markdown, and bottom anchoring. These cases are separate from cached tab remounts.
For milestone charts, rerun frozen builds with one workload and counterbalanced serial order. Do not connect historical medians from different transports, preparation, or machine-load periods. Show samples or ranges, name the checkpoints accurately, and distinguish experimental build snapshots from Git commits.
Complete original `BENCHMARK` JSON records, including samples, context, and failed records, are saved as `tab-switch-benchmark.jsonl` in Playwright's configured output directory (default: `e2e/test-results/performance`). Standard Playwright flags can override defaults when appended:
```sh
bun run bench:tabs --repeat-each=3 --output=e2e/test-results/tabs-smoke
```
Set `OPENCODE_PERFORMANCE_MEMORY=1` for an opt-in renderer-main-isolate heap and DOM sample after mounted content is ready and an explicit GC completes. Probe DOM references are released before collection. This is not total desktop memory; do not mix these diagnostic runs with unprofiled latency samples. Set `OPENCODE_PERFORMANCE_TRACE_DIR` for a separate Chrome trace of each tab interaction, starting after preparation, with `session-switch:start`, `session-switch:ready`, and `session-switch:stable` markers.
### Cache-Enabled HTTP Fixture
The default tab harness uses Playwright routing for API responses. Playwright routing disables the browser HTTP cache, including for unrelated SVG assets. To measure with HTTP caching enabled, the same API handlers and tab data can run on a real loopback HTTP endpoint:
```sh
bun run build
bun e2e/performance/tab-switch-server.ts --port 4639 --dist dist
```
With that fixture running, run the benchmark in a separate terminal from `packages/app`:
```powershell
$env:PLAYWRIGHT_BASE_URL = "http://127.0.0.1:4639"
$env:OPENCODE_PERFORMANCE_HTTP_FIXTURE = "1"
bun run bench:tabs
```
Use `--dist` to select a frozen production bundle when comparing revisions. An explicit `PLAYWRIGHT_BASE_URL` means the benchmark does not rebuild or start another preview. The fixture gives hashed assets immutable cache headers; it serves the deterministic read workload, not the live OpenCode service. Each test still gets a fresh browser context, and source-session setup still occurs before the measured switch. API responses use `no-store`, service workers remain blocked, and no destination Markdown is rendered before a cold switch. Records identify the transport as `http` or `playwright-route`; keep these series separate. Unset `OPENCODE_PERFORMANCE_HTTP_FIXTURE` when returning to the default routed harness.
## Retained renderer memory
Run the catalog workload against the production app bundle:
@@ -160,7 +111,7 @@ bunx playwright test --config e2e/performance/playwright.config.ts \
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
Trace capture follows [Puppeteer's tracing lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss. V8 CPU sample stacks support attribution through the frozen build's source maps. Set `OPENCODE_PERFORMANCE_STACK_TRACE=1` only when per-event timeline stacks are needed; they add substantial overhead. Keep profiled runs separate from latency distributions, including when comparing the stack-capture modes.
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
```sh
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
+7 -17
View File
@@ -5,20 +5,16 @@ type BenchmarkFixtures = {
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
benchmarkResult: void
traceScope: "page" | "interaction"
}
export type PerformancePageDiagnostics = {
navigations: string[]
traceScope: "page" | "interaction"
startTrace: () => Promise<void>
stop: () => Promise<string | undefined>
}
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
export const benchmark = base.extend<BenchmarkFixtures>({
traceScope: ["page", { option: true }],
reportState: async ({}, use) => use({}),
report: async ({ reportState }, use) => {
await use((metrics, context = {}) => {
@@ -53,9 +49,9 @@ export const benchmark = base.extend<BenchmarkFixtures>({
},
{ auto: true },
],
page: async ({ page, traceScope }, use, testInfo) => {
page: async ({ page }, use, testInfo) => {
const name = benchmarkName(testInfo)
const diagnostics = await observePerformancePage(page, name, traceScope)
const diagnostics = await observePerformancePage(page, name)
try {
await use(page)
} finally {
@@ -79,30 +75,25 @@ function benchmarkName(testInfo: TestInfo) {
export { expect }
async function observePerformancePage(page: Page, name: string, traceScope: "page" | "interaction" = "page") {
async function observePerformancePage(page: Page, name: string) {
const navigations: string[] = []
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
if (frame === page.mainFrame()) navigations.push(frame.url())
}
page.on("framenavigated", onNavigation)
let stopTrace: Awaited<ReturnType<typeof startChromeTrace>>
const stopTrace = await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
let stopping: Promise<string | undefined> | undefined
const diagnostics: PerformancePageDiagnostics = {
navigations,
traceScope,
async startTrace() {
stopTrace ??= await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
},
stop() {
page.off("framenavigated", onNavigation)
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
},
}
pages.set(page, diagnostics)
if (traceScope === "page") await diagnostics.startTrace()
return diagnostics
}
@@ -139,7 +130,6 @@ async function reportPerformancePage(name: string, diagnostics: PerformancePageD
context: {
platform: process.platform,
trace,
traceScope: diagnostics.traceScope,
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
},
navigations: diagnostics.navigations,
+1 -3
View File
@@ -14,6 +14,7 @@ const categories = [
"blink.console",
"blink.user_timing",
"latencyInfo",
"disabled-by-default-devtools.timeline.stack",
"disabled-by-default-v8.cpu_profiler",
]
@@ -33,9 +34,6 @@ export async function startChromeTrace(page: Page, name: string): Promise<undefi
.map((category) => category.slice(1)),
includedCategories: [
...categories.filter((category) => !category.startsWith("-")),
...(process.env.OPENCODE_PERFORMANCE_STACK_TRACE === "1"
? ["disabled-by-default-devtools.timeline.stack"]
: []),
...(selectors
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
: []),
@@ -1,37 +0,0 @@
# Composer History Hydration
Manual benchmark for an empty destination composer. Runs the production
`ComposerEditor`, `createComposerEditor`, `createComposerHistory`, persistence
codec, and browser IndexedDB draft store. It does not run the surrounding app
shell or native desktop IPC.
Workload: 100 normal prompts with realistic review instructions/code and 100
shell commands. Separate cases have no images, 50 unique screenshots, or 50
references to 5 screenshots. The fixture generates valid 1440 x 900 PNG code
screenshots before timing and reports their exact byte sizes. Each isolated
browser context measures a cold URL-cache mount followed by a warm remount.
The database was just seeded; this does not simulate a cold disk cache.
`historyReadyMs` measures the mount action until both production history stores
are populated. This is history availability, not time to first editable input
(input can be usable before history finishes). The benchmark then verifies
ArrowUp recall and a decoded screenshot in the real editor. `recallObservedMs`
includes Playwright action/assertion overhead and is reported separately.
`mountRecallObservedMs` includes the mount, readiness checks, keyboard action,
and correct text/image completion; it also includes Playwright overhead.
IndexedDB reads and blob sizes are mechanism metrics, not desktop IPC bytes or
process memory. No timing threshold is enforced.
From `packages/app`, set `OPENCODE_HISTORY_BUILD` and
`OPENCODE_HISTORY_OUTPUT` to artifact directories outside Git, then run:
```sh
bun x vite build --config e2e/performance/composer-history/vite.config.ts
bun x playwright test --config e2e/performance/composer-history/playwright.config.ts --repeat-each=20
```
The preview server owns port 4783 and is stopped by Playwright. Preserve each
build and its revision/hash for comparisons. `BENCHMARK` JSON lines contain all
raw samples. Optional Chrome traces use the existing
`OPENCODE_PERFORMANCE_TRACE_DIR` setting; keep trace runs separate from clean
timing. Screenshots are captured after timing on the first repeat only.
@@ -1,48 +0,0 @@
import { benchmark, expect } from "../benchmark"
benchmark.use({ traceScope: "page" })
for (const shape of ["text", "unique", "repeated"]) {
benchmark(`composer global history: ${shape}, cold and warm mounts`, async ({ page, report }, testInfo) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
await page.goto(`/?shape=${shape}`)
const button = page.getByRole("button", { name: "Mount empty composer", exact: true })
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
const samples = []
for (const cache of ["cold", "warm"]) {
await expect(button).toBeEnabled()
const mountStarted = performance.now()
await button.click()
await expect(page.getByTestId("history-ready")).toHaveText("ready")
await expect(input).toBeEditable()
await expect(input).toBeEmpty()
const result = JSON.parse((await page.getByTestId("history-result").textContent())!)
expect(result.documents).toBe(2)
expect(result.historyReadyMs).toBeGreaterThan(0)
const start = performance.now()
await input.press("ArrowUp")
await expect(input).toContainText("Review the retry policy in src/network/request-0.ts.")
const images = page.getByRole("img", { name: "request-0.png", exact: true })
await expect(images).toHaveCount(shape === "text" ? 0 : 1)
if (shape !== "text")
await expect
.poll(() => images.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth === 1440))
.toBe(true)
samples.push({
cache,
...result,
recallObservedMs: performance.now() - start,
mountRecallObservedMs: performance.now() - mountStarted,
})
}
expect(errors).toEqual([])
report(
{ samples },
{
browser: page.context().browser()!.version(),
scope: "production composer editor/history, browser IndexedDB; no native IPC",
},
)
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath(`${shape}.png`) })
})
}
@@ -1,174 +0,0 @@
/// <reference types="vite/client" />
import { createEffect, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { PlatformProvider, type Platform } from "@/runtime/platform/platform"
import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
import { createComposerHistory } from "@/composer/history/store"
import { ComposerEditor } from "@/composer/editor/editor"
import { createComposerEditor } from "@/composer/editor/interaction"
import type { ComposerPersistedState } from "@/composer/types"
import "@/index.css"
const shape = new URLSearchParams(location.search).get("shape") ?? "text"
const normal = Array.from({ length: 100 }, (_, index) => {
const content =
`Review the retry policy in src/network/request-${index}.ts. Preserve cancellation and the existing error messages.\n\n` +
`The request should stop after three attempts. Add coverage for a 429 response, a connection reset, and a successful retry. Verify that only idempotent requests are retried.\n\n` +
`Report ${index}:\n\`\`\`ts\nexport async function request(input: Request) {\n const response = await fetch(input)\n if (!response.ok) throw new Error(response.statusText)\n return response.json()\n}\n\`\`\``
return {
prompt: [
{ type: "text", content, start: 0, end: content.length },
...(shape !== "text" && index % 2 === 0
? [
{
type: "image",
id: `attachment-${index}`,
filename: `request-${index}.png`,
mime: "image/png",
blob: { id: `screenshot-${shape === "repeated" ? index % 10 : index}` },
},
]
: []),
],
comments: [],
}
})
const shell = Array.from({ length: 100 }, (_, index) => {
const content = `bun test src/network/request-${index}.test.ts --timeout 30000`
return { prompt: [{ type: "text", content, start: 0, end: content.length }], comments: [] }
})
// Seed only this Playwright context, before opening the production draft store.
const request = indexedDB.open("opencode-drafts", 1)
request.onupgradeneeded = () => {
request.result.createObjectStore("documents")
request.result.createObjectStore("blobs")
}
const db = await new Promise<IDBDatabase>((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
const ids = [...new Set(normal.flatMap((entry) => entry.prompt.flatMap((part) => (part.blob ? [part.blob.id] : []))))]
const screenshots: { id: string; blob: Blob }[] = []
for (const id of ids) {
const canvas = document.createElement("canvas")
canvas.width = 1440
canvas.height = 900
const context = canvas.getContext("2d")!
context.fillStyle = "#15191f"
context.fillRect(0, 0, canvas.width, canvas.height)
context.font = "16px monospace"
context.fillStyle = "#b8c8d8"
context.fillText(`request.ts - ${id}`, 30, 35)
for (let line = 0; line < 38; line++) {
context.fillStyle = line % 3 ? "#a8c7ba" : "#d4a882"
context.fillText(
`${String(line + 1).padStart(3)} const response${line} = await fetch('/api/request/${id}/${line}', { signal, headers });`,
30,
70 + line * 20,
)
}
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob!), "image/png"))
screenshots.push({ id, blob })
}
const transaction = db.transaction(["documents", "blobs"], "readwrite")
transaction.objectStore("documents").put(JSON.stringify({ entries: normal }), "opencode.global.dat:prompt-history")
transaction.objectStore("documents").put(JSON.stringify({ entries: shell }), "opencode.global.dat:prompt-history-shell")
screenshots.forEach(({ id, blob }) => transaction.objectStore("blobs").put(blob, id))
await new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
db.close()
const metrics = { reads: 0, blobBytes: 0, documents: 0 }
const originalGet = IDBObjectStore.prototype.get
IDBObjectStore.prototype.get = function (key) {
const request = originalGet.call(this, key)
if (this.name === "documents") metrics.documents++
if (this.name === "blobs") {
metrics.reads++
request.addEventListener("success", () => {
metrics.blobBytes += request.result?.size ?? 0
})
}
return request
}
const platform: Platform = {
platform: "web",
draftStore: createBrowserDraftStore(),
openExternal() {},
restart: async () => {},
notify: async () => {},
}
const [state, setState] = createStore({ mount: 0, ready: false, result: "" })
const workload = {
shape,
normalEntries: normal.length,
shellEntries: shell.length,
imageReferences: shape === "text" ? 0 : 50,
uniqueImages: ids.length,
storedImageBytes: screenshots.reduce((sum, item) => sum + item.blob.size, 0),
documentBytes: [normal, shell].reduce(
(sum, entries) => sum + new TextEncoder().encode(JSON.stringify({ entries })).length,
0,
),
screenshotDimensions: [1440, 900],
}
let started = 0
function mount() {
metrics.reads = 0
metrics.blobBytes = 0
metrics.documents = 0
setState({ ready: false, result: "" })
started = performance.now()
setState("mount", state.mount + 1)
}
function Destination() {
// Same history creation and editor mapping as createComposerModel. Destination draft is empty.
const history = createComposerHistory()
const store = createStore<ComposerPersistedState>({
prompt: [{ type: "text", content: "", start: 0, end: 0 }],
cursor: 0,
context: { items: [] },
})
const controller = createComposerEditor({
store,
commands: () => [],
context: () => [],
searchContextFiles: () => [],
history: {
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
add: (prompt, mode) => history.add(prompt, mode, []),
},
view: {
placeholder: () => "Empty destination composer",
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
},
})
createEffect(() => {
if (history.entries("normal").length !== 100 || history.entries("shell").length !== 100) return
setState({
ready: true,
result: JSON.stringify({ historyReadyMs: performance.now() - started, ...metrics, ...workload }),
})
})
return <ComposerEditor controller={controller} />
}
render(
() => (
<PlatformProvider value={platform}>
<main style={{ padding: "40px", width: "900px" }}>
<h1>Composer global history: {shape}</h1>
<button onClick={mount}>Mount empty composer</button>
<output data-testid="history-ready">{state.ready ? "ready" : "idle"}</output>
<pre data-testid="history-result">{state.result}</pre>
<Show when={state.mount} keyed>
{(_mount) => <Destination />}
</Show>
</main>
</PlatformProvider>
),
document.getElementById("root")!,
)
@@ -1,11 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Composer history benchmark</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./fixture.tsx"></script>
</body>
</html>
@@ -1,20 +0,0 @@
import { defineConfig } from "@playwright/test"
import { fileURLToPath } from "node:url"
export default defineConfig({
testDir: ".",
testMatch: "composer-history.bench.ts",
workers: 1,
retries: 0,
timeout: 60_000,
reporter: "line",
outputDir: process.env.OPENCODE_HISTORY_OUTPUT,
use: { baseURL: "http://127.0.0.1:4783", viewport: { width: 1440, height: 900 }, trace: "off", video: "off" },
webServer: {
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
command:
"bun x vite preview --config e2e/performance/composer-history/vite.config.ts --host 127.0.0.1 --port 4783 --strictPort",
url: "http://127.0.0.1:4783",
reuseExistingServer: false,
},
})
@@ -1,10 +0,0 @@
import { defineConfig } from "vite"
import { fileURLToPath } from "node:url"
import app from "../../../vite"
export default defineConfig({
root: fileURLToPath(new URL(".", import.meta.url)),
publicDir: fileURLToPath(new URL("../../../public", import.meta.url)),
plugins: [app],
build: { target: "esnext", outDir: process.env.OPENCODE_HISTORY_BUILD, emptyOutDir: true },
})
@@ -1,44 +0,0 @@
# Timeline Preload Lifetime
This manual benchmark uses the production app, restored session tabs, the real
`MessageTimeline` preload, and the real Markdown worker. Only API data and result
delivery timing are fixture-owned. It does not connect to a running OpenCode
service or send prompts.
From `packages/app`, set absolute `MARKDOWN_APP_BUILD_DIR` and
`MARKDOWN_RESULTS_DIR` artifact paths, then run:
```sh
bun run build
# Copy dist into MARKDOWN_APP_BUILD_DIR before editing production source.
bun --bun x playwright test --config e2e/performance/markdown/playwright.config.ts --repeat-each 20
```
Each isolated sample restores two sessions with one user message and one completed
assistant text part each. The cold target has a realistic recovery review with
either two TypeScript fences (typical) or 36 fences (large). The source has a short
completed answer. Target data is prefetched before selection, but its Markdown is
not parsed until the target is selected.
The app's service-worker generator reads `dist`, so use the normal build output
and freeze a copy, rather than overriding Vite's build output directory.
The real worker result is held after admission. The test selects the original
session again and releases the held result only after the abandoned timeline row
detaches and the selected answer reports production Markdown readiness. This
exercises both the timeline preload and the nested Markdown consumer, including
the case where either one would otherwise keep a shared parse alive.
Destination readiness and post-disposal result settlement are separate metrics.
The latter is a MessageChannel task after the result's promise microtasks drain.
The DOMParser probe counts actual DOMPurify input containing the abandoned answer
after disposal, in characters. CDP reports renderer task/script time and JS heap.
These are not worker CPU, Electron process RAM, or ungated tab-switch measurements.
In particular, this gate releases the result after destination readiness and must
not be used to claim a destination-readiness gain from skipping sanitization.
`MARKDOWN_ASSERT_DISPOSAL=1` enables the no-obsolete-sanitization assertion. Use
`MARKDOWN_RETAINED=1` only in separate post-GC runs. The repository trace collector
is available through `OPENCODE_PERFORMANCE_TRACE_DIR`, and `MARKDOWN_SCREENSHOT`
captures final output after timing. Run serially, preserve frozen builds, and keep
all results outside Git.
@@ -1,125 +0,0 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { benchmark, expect } from "../benchmark"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture } from "../timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "../timeline/timeline-test-helpers"
import { completedAnswer } from "../../../../session-ui/performance/markdown-lifetime/answer"
import { installMarkdownGate } from "./probe"
for (const size of ["typical", "large"]) {
benchmark(`timeline preload disposal: ${size}`, async ({ page, report }) => {
const answer = completedAnswer(size === "typical" ? 2 : 36)
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((id) => [
id,
[
{
id: `msg_1_${id}_user`,
type: "user",
time: { created: 1700000000000 },
text: "Review the recovery boundary.",
},
{
id: `msg_2_${id}_assistant`,
type: "assistant",
time: { created: 1700000001000, completed: 1700000008000 },
model: { id: "claude-opus-4-6", providerID: "opencode" },
agent: "build",
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
content: [
{
type: "text",
text:
id === fixture.targetID
? answer
: "## Current destination\n\nThe selected session is ready.\n\n```typescript\nconst current = { ready: true }\n```",
},
],
},
] satisfies SessionMessageInfo[],
]),
)
await mockOpenCodeServer(page, {
sessions: fixture.sessions.filter((session) => session.id !== fixture.childID),
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (id) => ({ items: messages[id] ?? [] }),
})
await installTimelineSettings(page)
await installStressSessionTabs(page)
const targetPart = `msg_2_${fixture.targetID}_assistant:text:0`
const sourcePart = `msg_2_${fixture.sourceID}_assistant:text:0`
await installMarkdownGate(page, { answer, targetPart, sourcePart, href: stressSessionHref(fixture.sourceID) })
const prefetched = page.waitForResponse((response) =>
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
)
await page.goto(stressSessionHref(fixture.sourceID))
await prefetched
const source = page.locator(`[data-timeline-part-id="${sourcePart}"] [data-component="markdown"]`)
await expect(source).toHaveAttribute("data-markdown-ready", "")
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
await page.waitForFunction(() => Reflect.get(window, "markdownGate").held)
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toBeAttached()
const cdp = await page.context().newCDPSession(page)
await cdp.send("Performance.enable")
const before = await cdp.send("Performance.getMetrics")
await page.evaluate(() => Reflect.get(window, "markdownGate").arm())
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(source).toHaveAttribute("data-markdown-ready", "")
await expect(source.getByRole("heading", { name: "Current destination" })).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toHaveCount(0)
await page.waitForFunction(() => Reflect.get(window, "markdownGate").settled > 0)
const after = await cdp.send("Performance.getMetrics")
const stats = await page.evaluate(() => {
const value = Reflect.get(window, "markdownGate")
return {
admitted: value.admitted,
responses: value.responses,
started: value.started,
ready: value.ready,
released: value.released,
settled: value.settled,
sanitizeCalls: value.sanitizeCalls,
sanitizeChars: value.sanitizeChars,
}
})
expect(stats.admitted).toBe(1)
expect(stats.responses).toBe(1)
expect(stats.ready).toBeGreaterThan(stats.started)
expect(stats.settled).toBeGreaterThan(stats.released)
expect(errors).toEqual([])
if (process.env.MARKDOWN_ASSERT_DISPOSAL === "1") expect(stats.sanitizeCalls).toBe(0)
const value = (data: typeof after, name: string) => data.metrics.find((item) => item.name === name)!.value
const retained = process.env.MARKDOWN_RETAINED === "1"
if (retained) await cdp.send("HeapProfiler.collectGarbage")
report(
{
...stats,
destinationReadyMs: stats.ready - stats.started,
releasedSettledMs: stats.settled - stats.released,
taskMs: (value(after, "TaskDuration") - value(before, "TaskDuration")) * 1000,
scriptMs: (value(after, "ScriptDuration") - value(before, "ScriptDuration")) * 1000,
usedHeapBytes: (await cdp.send("Runtime.getHeapUsage")).usedSize,
},
{
size,
retained,
answerBytes: Buffer.byteLength(answer),
messagesPerSession: 2,
partsPerAnswer: 1,
fences: size === "typical" ? 2 : 36,
browser: page.context().browser()!.version(),
transport: "playwright-route",
build: process.env.MARKDOWN_APP_BUILD_DIR,
},
)
if (process.env.MARKDOWN_SCREENSHOT)
await page.screenshot({ path: `${process.env.MARKDOWN_SCREENSHOT}/timeline-${size}.png` })
await cdp.detach()
})
}
@@ -1,24 +0,0 @@
import { defineConfig } from "@playwright/test"
import { fileURLToPath } from "node:url"
process.env.PLAYWRIGHT_PORT = "6199"
process.env.PLAYWRIGHT_SERVER_PORT = "6199"
process.env.PLAYWRIGHT_SERVER_HOST = "127.0.0.1"
export default defineConfig({
testDir: ".",
testMatch: "*.bench.ts",
outputDir: process.env.MARKDOWN_RESULTS_DIR,
workers: 1,
retries: 0,
timeout: 60_000,
expect: { timeout: 15_000 },
reporter: [["line"]],
use: { baseURL: "http://127.0.0.1:6199", viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
webServer: {
cwd: fileURLToPath(new URL("../../..", import.meta.url)),
command: `bun run serve -- --host 127.0.0.1 --port 6199 --strictPort --outDir "${process.env.MARKDOWN_APP_BUILD_DIR}"`,
url: "http://127.0.0.1:6199",
reuseExistingServer: false,
},
})
@@ -1,93 +0,0 @@
import type { Page } from "@playwright/test"
import type {
MarkdownWorkerRequest,
MarkdownWorkerResponse,
} from "../../../../session-ui/src/components/markdown-worker-protocol"
export async function installMarkdownGate(
page: Page,
input: { answer: string; sourcePart: string; targetPart: string; href: string },
) {
await page.addInitScript(({ answer, sourcePart, targetPart, href }) => {
const stats = {
admitted: 0,
responses: 0,
held: false,
started: 0,
ready: 0,
released: 0,
settled: 0,
sanitizeCalls: 0,
sanitizeChars: 0,
arm: () => {
armed = true
},
}
let armed = false
let id: number | undefined
let release: (() => void) | undefined
const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, "onmessage")!
const post = Worker.prototype.postMessage
Object.defineProperty(Worker.prototype, "onmessage", {
configurable: true,
get: descriptor.get,
set(callback: (event: MessageEvent<MarkdownWorkerResponse>) => void) {
descriptor.set!.call(this, (event: MessageEvent<MarkdownWorkerResponse>) => {
if (event.data.type === "parse" && event.data.id === id) {
stats.responses++
stats.held = true
release = () => callback.call(this, event)
return
}
callback.call(this, event)
})
},
})
Worker.prototype.postMessage = function (request: MarkdownWorkerRequest) {
if (request.type === "parse" && request.text === answer) {
id = request.id
stats.admitted++
}
post.call(this, request)
}
const parse = DOMParser.prototype.parseFromString
DOMParser.prototype.parseFromString = function (text, type) {
if (stats.released && String(text).includes("Recovery implementation review")) {
stats.sanitizeCalls++
stats.sanitizeChars += String(text).length
}
return parse.call(this, text, type)
}
document.addEventListener(
"mousedown",
(event) => {
if (!armed || stats.started) return
const target = event.target instanceof Element ? event.target.closest("a") : undefined
if (target?.getAttribute("href") !== href) return
stats.started = performance.now()
},
true,
)
// The app can retain the outgoing view until the destination is ready. Release
// only after its actual row detaches, rather than assuming click means dispose.
new MutationObserver(() => {
if (!stats.started || stats.released) return
const current = document.querySelector(`[data-timeline-part-id="${sourcePart}"] [data-markdown-ready]`)
if (!current) return
stats.ready ||= performance.now()
if (document.querySelector(`[data-timeline-part-id="${targetPart}"]`)) return
stats.released = performance.now()
performance.mark("markdown-timeline-disposed")
release!()
release = undefined
const channel = new MessageChannel()
channel.port1.onmessage = () => {
stats.settled = performance.now()
channel.port1.close()
channel.port2.close()
}
channel.port2.postMessage(null)
}).observe(document, { childList: true, subtree: true, attributes: true })
Object.defineProperty(window, "markdownGate", { value: stats })
}, input)
}
@@ -1,99 +0,0 @@
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from "@playwright/test/reporter"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
type BenchmarkRecord = {
status?: string
metrics?: { firstCorrectObservedMs?: unknown; stableObservedMs?: unknown } | null
}
export default class TabSwitchReporter implements Reporter {
private output = ""
private tests: TestCase[] = []
private results: { test: TestCase; status: TestResult["status"]; records: string[] }[] = []
onBegin(config: FullConfig, suite: Suite) {
this.output = config.projects[0].outputDir
this.tests = suite.allTests()
}
onTestEnd(test: TestCase, result: TestResult) {
this.results.push({
test,
status: result.status,
records: Buffer.concat(result.stdout.map((chunk) => (typeof chunk === "string" ? Buffer.from(chunk) : chunk)))
.toString("utf8")
.split(/\r?\n/)
.filter((line) => line.startsWith("BENCHMARK "))
.map((line) => line.slice("BENCHMARK ".length)),
})
}
async onEnd(result: FullResult) {
const file = path.join(this.output, "tab-switch-benchmark.jsonl")
try {
await mkdir(this.output, { recursive: true })
await writeFile(file, this.results.flatMap((entry) => entry.records.map((raw) => `${raw}\n`)).join(""), "utf8")
} catch (error) {
console.error("Could not save tab-switch benchmark records:", error)
return { status: "failed" as const }
}
console.log(`\nTab-switch benchmark: ${result.status}`)
Array.from(new Set(this.tests.map((test) => test.title))).forEach((name) => {
const results = this.results.filter((entry) => entry.test.title === name)
const unrun = this.tests.filter(
(test) => test.title === name && !results.some((entry) => entry.test.id === test.id),
).length
const records = results.flatMap((entry) =>
entry.records.map((raw) => {
try {
return { status: entry.status, record: JSON.parse(raw) as BenchmarkRecord | null }
} catch {
return { status: entry.status, record: { status: "invalid JSON", metrics: null } }
}
}),
)
const passed = records.filter((entry) => entry.status === "passed" && entry.record?.status === "passed")
const valid = passed
.map((entry) => ({
firstCorrectObservedMs: entry.record?.metrics?.firstCorrectObservedMs,
stableObservedMs: entry.record?.metrics?.stableObservedMs,
}))
.filter(
(metrics): metrics is { firstCorrectObservedMs: number; stableObservedMs: number } =>
typeof metrics.firstCorrectObservedMs === "number" &&
Number.isFinite(metrics.firstCorrectObservedMs) &&
typeof metrics.stableObservedMs === "number" &&
Number.isFinite(metrics.stableObservedMs),
)
console.log(`\n${name}`)
console.log(` Tests: ${counts(results.map((entry) => entry.status))}; unrun=${unrun}`)
console.log(
` Records: ${counts(records.map((entry) => entry.record?.status ?? "missing status"))}; ` +
`missing=${results.filter((entry) => entry.records.length === 0).length + unrun}; ` +
`excluded=${records.length - valid.length}; invalid metrics=${passed.length - valid.length}`,
)
;(["firstCorrectObservedMs", "stableObservedMs"] as const).forEach((metric) => {
const values = valid.map((entry) => entry[metric]).sort((a, b) => a - b)
if (values.length === 0) {
console.log(` ${metric}: n=0, median=n/a, p95=n/a`)
return
}
const median = (values[Math.floor((values.length - 1) / 2)] + values[Math.floor(values.length / 2)]) / 2
const p95 = values[Math.ceil(values.length * 0.95) - 1]
console.log(` ${metric}: n=${values.length}, median=${median.toFixed(2)} ms, p95=${p95.toFixed(2)} ms`)
})
})
console.log(`\nRaw BENCHMARK records: ${file}`)
}
}
function counts(statuses: string[]) {
return (
Array.from(new Set(statuses))
.map((status) => `${status}=${statuses.filter((value) => value === status).length}`)
.join(", ") || "none"
)
}
@@ -1,61 +0,0 @@
import path from "node:path"
import { parseArgs } from "node:util"
import { createMockServerHandler } from "../utils/mock-server"
import { fixture } from "./timeline/session-timeline-stress.fixture"
import { messages } from "./timeline/session-tab-switch.fixture"
import { createReviewDiffs } from "./timeline/timeline-test-helpers"
const args = parseArgs({
args: Bun.argv.slice(2),
options: { port: { type: "string", default: "4639" }, dist: { type: "string", default: "dist" } },
})
const directory = path.resolve(args.values.dist)
const api = createMockServerHandler({
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
vcsDiff: createReviewDiffs(),
})
const server = Bun.serve({
hostname: "127.0.0.1",
port: Number(args.values.port),
idleTimeout: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('data: {"id":"evt_fixture_connected","type":"server.connected","data":{}}\n\n'),
)
},
}),
{ headers: { "content-type": "text/event-stream", "cache-control": "no-store" } },
)
}
if (url.pathname.startsWith("/api/")) {
const response = await api.handler(request)
response.headers.set("cache-control", "no-store")
return response
}
const file = Bun.file(path.join(directory, url.pathname))
if (!url.pathname.endsWith("/") && (await file.exists())) {
return new Response(file, {
headers: {
"cache-control": url.pathname.startsWith("/_assets/") ? "public, max-age=31536000, immutable" : "no-cache",
},
})
}
return new Response(Bun.file(path.join(directory, "index.html")), { headers: { "cache-control": "no-cache" } })
},
})
console.log(`Tab fixture: ${server.url} (${directory})`)
const close = async () => {
await server.stop(true)
await api.dispose()
}
process.once("SIGINT", close)
process.once("SIGTERM", close)
@@ -1,44 +0,0 @@
# Native Terminal Benchmark
Manual Windows benchmark. Run only in an isolated development worktree. It does
not connect to an OpenCode service, user profile, or database.
Build from `packages/app` with
`bun x vite build --config e2e/performance/terminals/vite.config.ts`, then freeze
`dist` outside the repository. Set `PLAYWRIGHT_BUILD=1`, `PLAYWRIGHT_BASE_URL` to
an unused loopback URL, `TERMINAL_BUILD` to the frozen build,
`TERMINAL_ARTIFACTS` to an existing external directory, and `TERMINAL_RESULTS`
to an external result directory. Run:
```sh
bun x playwright test --config e2e/performance/terminals/playwright.config.ts --repeat-each=20
```
The runner owns its preview server and each test owns a PowerShell ConPTY process.
Session metadata is deterministic. Native output is forwarded through Playwright's
WebSocket fixture into the real production `Terminal`, writer, Ghostty WASM/canvas,
and serializer. No output is dropped, paused, or delayed. This is native terminal
plus production renderer evidence, not the production PTY backend or Electron IPC.
The workload is 12,000 colored build/test log lines with file paths, durations, and
result descriptions. Cases separate visible output, the same output while hidden,
and closing the session tab after filling the configured scrollback. Ghostty
converts the app's 10,000-line setting to bytes at its initial 80-column width;
resizing reduces the effective row capacity. The report records actual retained
rows and the first retained fixture record rather than assuming 10,000 rows. Completion
requires the final marker in Ghostty and completion of its write callbacks, not
just WebSocket delivery. Teardown requires Home readiness and the final serialized
snapshot. Input, focus, resizing, and native process survival are checked.
`probe.ts` is included only by this benchmark build. It observes actual writes,
renderer calls, and serialization. Chrome `TaskDuration` measures renderer task
time, not total process CPU or RAM. For attribution, set
`OPENCODE_PERFORMANCE_TRACE_DIR`; keep traced runs separate from clean timing.
`TERMINAL_DRAW_PROBE=1` separately counts actual canvas draws to verify hidden
rendering; do not mix these instrumented samples with clean timing.
Use `TERMINAL_REVISION` and `TERMINAL_BUNDLE` to identify frozen artifacts.
`TERMINAL_SCREENSHOTS` captures the visible result after timing.
The benchmark has no machine-dependent performance thresholds. Keep raw logs,
snapshots, traces, and screenshots outside Git. Run heavy work through the
coordinator's exclusive gate when participating in a shared performance wave.
@@ -1,20 +0,0 @@
import { defineConfig } from "@playwright/test"
import config from "../../../playwright.config"
export default defineConfig({
...config,
testDir: ".",
testIgnore: [],
testMatch: "terminal-benchmark.spec.ts",
workers: 1,
retries: 0,
timeout: 120_000,
outputDir: process.env.TERMINAL_RESULTS,
reporter: [["line"]],
webServer: {
command: `bun x vite preview --host 127.0.0.1 --port ${new URL(process.env.PLAYWRIGHT_BASE_URL!).port} --strictPort --outDir ${process.env.TERMINAL_BUILD}`,
url: process.env.PLAYWRIGHT_BASE_URL,
reuseExistingServer: false,
},
use: { ...config.use, viewport: { width: 1440, height: 900 }, trace: "off", video: "off", serviceWorkers: "block" },
})
@@ -1,74 +0,0 @@
import { Terminal } from "ghostty-web"
import { SerializeAddon } from "../../../src/session/terminal/serialize"
export type TerminalProbe = {
term?: Terminal
writes: number
pending: number
bytes: number
renders: number
hiddenRenders: number
draws: number
hiddenDraws: number
serialized: { ms: number; bytes: number; value: string }[]
}
declare global {
interface Window {
terminalProbe: TerminalProbe
}
}
const probe: TerminalProbe = {
writes: 0,
pending: 0,
bytes: 0,
renders: 0,
hiddenRenders: 0,
draws: 0,
hiddenDraws: 0,
serialized: [],
}
window.terminalProbe = probe
const open = Terminal.prototype.open
Terminal.prototype.open = function (element) {
probe.term = this
open.call(this, element)
// Ghostty does not expose render events. This benchmark-only wrapper observes its
// actual renderer; it does not alter scheduling, parsing, or drawing.
const renderer = (this as unknown as { renderer: { render: (...args: unknown[]) => void } }).renderer
const render = renderer.render
let hidden = false
renderer.render = function (...args) {
probe.renders++
hidden = !element.checkVisibility()
if (hidden) probe.hiddenRenders++
return render.apply(this, args)
}
if (new URL(location.href).searchParams.has("terminalDrawProbe")) {
const context = element.querySelector("canvas")!.getContext("2d")!
const draw = context.drawImage
context.drawImage = function (...args: unknown[]) {
probe.draws++
if (hidden) probe.hiddenDraws++
Reflect.apply(draw, this, args)
}
}
}
const write = Terminal.prototype.write
Terminal.prototype.write = function (data, done) {
probe.writes++
probe.pending++
probe.bytes += typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength
return write.call(this, data, () => {
probe.pending--
done?.()
})
}
const serialize = SerializeAddon.prototype.serialize
SerializeAddon.prototype.serialize = function (options) {
const start = performance.now()
const value = serialize.call(this, options)
probe.serialized.push({ ms: performance.now() - start, bytes: new TextEncoder().encode(value).byteLength, value })
return value
}
@@ -1,13 +0,0 @@
param([Parameter(Mandatory = $true)][string]$Fixture)
$ErrorActionPreference = 'Stop'
[Console]::WriteLine('TERMINAL_FIXTURE_READY')
while ($null -ne ($command = [Console]::ReadLine())) {
if ($command -eq 'exit') { exit 0 }
if ($command -eq 'run') {
foreach ($line in [System.IO.File]::ReadLines($Fixture)) {
[Console]::WriteLine($line)
}
[Console]::WriteLine('TERMINAL_WORKLOAD_DONE')
}
if ($command -eq 'ping') { [Console]::WriteLine('TERMINAL_PROCESS_ALIVE') }
}
@@ -1,352 +0,0 @@
import { createRequire } from "node:module"
import { mkdtemp, writeFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import type { Page } from "@playwright/test"
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import type {} from "./probe"
// Use the same installed native PTY package as Core, with a fixture-owned process.
const native = createRequire(new URL("../../../../core/package.json", import.meta.url))("@lydell/node-pty") as {
spawn: (
file: string,
args: string[],
options: { cols: number; rows: number; cwd: string },
) => {
pid: number
write: (data: string) => void
resize: (cols: number, rows: number) => void
kill: () => void
onData: (handler: (data: string) => void) => { dispose: () => void }
onExit: (handler: () => void) => { dispose: () => void }
}
}
const sessionID = "ses_terminal_benchmark"
const ptyID = "pty_terminal_benchmark"
const title = "Terminal build output"
const server = process.env.PLAYWRIGHT_BASE_URL!
const href = `/server/${Buffer.from(server).toString("base64url")}/session/${sessionID}`
const lines = Array.from({ length: 12_000 }, (_, i) => {
const unit = ["session/history", "session/runner", "project/discovery", "tool/shell", "provider/stream"][i % 5]
return `\x1b[32mPASS\x1b[0m packages/core/test/${unit}-${String(i).padStart(5, "0")}.test.ts \x1b[2m[${10 + (i % 237)}ms]\x1b[0m validates ordered output and durable recovery`
}).join("\r\n")
benchmark.use({ traceScope: "interaction", viewport: { width: 1440, height: 900 } })
for (const scenario of ["visible-output", "hidden-output", "full-scrollback-teardown"] as const) {
benchmark(scenario, async ({ page, report }, info) => {
const dir = await mkdtemp(path.join(process.env.TERMINAL_ARTIFACTS ?? tmpdir(), "terminal-fixture-"))
await writeFile(path.join(dir, "build.log"), lines)
const pty = native.spawn(
"pwsh.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-File",
fileURLToPath(new URL("./shell.ps1", import.meta.url)),
"-Fixture",
path.join(dir, "build.log"),
],
{ cols: 120, rows: 24, cwd: dir },
)
const exited = new Promise<void>((resolve) => pty.onExit(resolve))
let output = ""
let connected = 0
let closed = 0
let send: ((data: string) => void) | undefined
const listener = pty.onData((data) => {
output += data
send?.(data)
})
const sizes: { cols: number; rows: number }[] = []
const removals: string[] = []
try {
if (process.env.TERMINAL_DRAW_PROBE) {
await page.addInitScript(() => {
const fill = CanvasRenderingContext2D.prototype.fillText
CanvasRenderingContext2D.prototype.fillText = function (...args: Parameters<typeof fill>) {
if (this.canvas instanceof HTMLCanvasElement && this.canvas.closest('[data-component="terminal"]')) {
window.terminalProbe.draws++
if (!this.canvas.checkVisibility()) window.terminalProbe.hiddenDraws++
}
Reflect.apply(fill, this, args)
}
})
}
const location = { directory: dir, project: { id: "proj_terminal_benchmark", directory: dir } }
const data = {
id: ptyID,
title: "Terminal 1",
command: "pwsh.exe",
args: [],
cwd: dir,
status: "running",
pid: pty.pid,
}
await mockOpenCodeServer(page, {
directory: dir,
project: {
id: location.project.id,
worktree: dir,
vcs: "git",
name: "terminal-benchmark",
time: { created: 1, updated: 1 },
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,
slug: sessionID,
projectID: location.project.id,
directory: dir,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/pty**", async (route) => {
if (route.request().method() === "DELETE") removals.push(route.request().url())
const body = route.request().postDataJSON()
if (body?.size) {
sizes.push(body.size)
pty.resize(body.size.cols, body.size.rows)
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location,
data: route.request().url().includes("connect-token") ? { ticket: "fixture", expires_in: 60 } : data,
}),
})
})
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (socket) => {
connected++
send = (data) => socket.send(data)
socket.send(output.slice(Number(new URL(socket.url()).searchParams.get("cursor") ?? 0)))
socket.onMessage((data) => pty.write(String(data)))
socket.onClose(() => {
closed++
send = undefined
})
})
await page.addInitScript(
({ server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID },
)
await page.goto(
`${href}${process.env.TERMINAL_DRAW_PROBE && scenario !== "full-scrollback-teardown" ? "?terminalDrawProbe" : ""}`,
)
await expectSessionTitle(page, title)
await page.keyboard.press("Control+Backquote")
await waitForText(page, "TERMINAL_FIXTURE_READY")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
await page.evaluate(() => document.fonts.ready.then(() => undefined))
await expect
.poll(async () => {
const size = await page.evaluate(() => ({
cols: window.terminalProbe.term!.cols,
rows: window.terminalProbe.term!.rows,
}))
return sizes.at(-1)?.cols === size.cols && sizes.at(-1)?.rows === size.rows
})
.toBe(true)
if (scenario === "hidden-output") {
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeHidden()
}
const cdp = await page.context().newCDPSession(page)
await cdp.send("Performance.enable")
const before = await cdp.send("Performance.getMetrics")
const start = await page.evaluate(() => {
window.terminalProbe.renders = 0
window.terminalProbe.hiddenRenders = 0
window.terminalProbe.draws = 0
window.terminalProbe.hiddenDraws = 0
return performance.now()
})
await benchmarkDiagnostics(page).startTrace()
// The producer is not throttled. The visible and hidden cases receive the same bytes.
pty.write("run\r")
await waitForText(page, "TERMINAL_WORKLOAD_DONE")
const produced = await page.evaluate(
(start) => ({
ms: performance.now() - start,
renders: window.terminalProbe.renders,
hiddenRenders: window.terminalProbe.hiddenRenders,
draws: window.terminalProbe.draws,
hiddenDraws: window.terminalProbe.hiddenDraws,
bytes: window.terminalProbe.bytes,
scrollback: window.terminalProbe.term!.getScrollbackLength(),
cols: window.terminalProbe.term!.cols,
rows: window.terminalProbe.term!.rows,
firstRecord: Number(
window.terminalProbe
.term!.buffer.normal.getLine(0)
?.translateToString(true)
.match(/-(\d{5})\.test\.ts/)?.[1],
),
}),
start,
)
const after = await cdp.send("Performance.getMetrics")
const cpuMs =
(after.metrics.find((x) => x.name === "TaskDuration")!.value -
before.metrics.find((x) => x.name === "TaskDuration")!.value) *
1000
let interaction: Record<string, unknown> = {}
if (scenario === "hidden-output") {
const start = await page.evaluate(() => performance.now())
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await waitForText(page, "TERMINAL_WORKLOAD_DONE")
interaction = { returnMs: await page.evaluate((start) => performance.now() - start, start) }
}
if (scenario === "full-scrollback-teardown") {
// Ghostty converts the configured line limit to bytes at the initial
// 80-column size. Resizing changes the effective retained row count.
expect(produced.scrollback).toBeGreaterThan(0)
expect(produced.firstRecord).toBeGreaterThan(0)
expect(produced.firstRecord).toBeLessThan(11_999)
const close = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"]) [data-component="icon-button-v2"]`)
await expect(close).toBeVisible()
const cpuBefore = await cdp.send("Performance.getMetrics")
const start = await page.evaluate(() => performance.now())
await close.click()
await expect(page).toHaveURL("/")
await expect(page.locator('[data-component="home-session-search"]')).toBeVisible()
await expect(page.locator('[data-component="home-session-search"] input')).toBeEditable()
await expect.poll(() => page.evaluate(() => window.terminalProbe.serialized.length)).toBe(1)
interaction = await page.evaluate(
(start) => ({
homeReadyMs: performance.now() - start,
serializeMs: window.terminalProbe.serialized[0].ms,
serializedBytes: window.terminalProbe.serialized[0].bytes,
}),
start,
)
const cpuAfter = await cdp.send("Performance.getMetrics")
interaction.teardownCpuMs =
(cpuAfter.metrics.find((x) => x.name === "TaskDuration")!.value -
cpuBefore.metrics.find((x) => x.name === "TaskDuration")!.value) *
1000
const snapshot = await page.evaluate(() => window.terminalProbe.serialized[0].value)
expect(Array.from(snapshot.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))).toEqual(
Array.from({ length: 12_000 - produced.firstRecord }, (_, index) => produced.firstRecord + index),
)
expect(snapshot).toContain("TERMINAL_WORKLOAD_DONE")
await writeFile(
path.join(
process.env.TERMINAL_ARTIFACTS ?? tmpdir(),
`${process.env.TERMINAL_BUNDLE}-${info.repeatEachIndex}.ansi`,
),
snapshot,
)
await expect(terminal).toHaveCount(0)
expect(closed).toBe(1)
// UI teardown must not terminate the native process.
pty.write("ping\r")
await expect.poll(() => output.includes("TERMINAL_PROCESS_ALIVE")).toBe(true)
}
await benchmarkDiagnostics(page).stop()
expect(connected).toBe(1)
expect(removals).toEqual([])
expect(sizes.length).toBeGreaterThan(0)
report(
{ ...produced, cpuMs, ...interaction },
{
revision: process.env.TERMINAL_REVISION,
bundle: process.env.TERMINAL_BUNDLE,
fixtureBytes: Buffer.byteLength(lines),
fixtureLines: 12_000,
transport: "Windows ConPTY -> Playwright WebSocket fixture -> production Terminal/writer/Ghostty",
scope: "Chromium renderer; not Electron total RAM or production backend IPC",
},
)
if (scenario !== "full-scrollback-teardown") {
// Validate input, focus, and resize after both visible and hidden output.
await terminal.click()
await expect(terminal.locator("textarea")).toBeFocused()
await page.keyboard.type("ping")
await page.keyboard.press("Enter")
await waitForText(page, "TERMINAL_PROCESS_ALIVE")
const columns = await page.evaluate(() => window.terminalProbe.term!.cols)
await page.setViewportSize({ width: 1100, height: 800 })
await expect.poll(() => page.evaluate(() => window.terminalProbe.term!.cols)).not.toBe(columns)
await expect
.poll(async () => sizes.at(-1)?.cols === (await page.evaluate(() => window.terminalProbe.term!.cols)))
.toBe(true)
expect(closed).toBe(0)
}
if (process.env.TERMINAL_SCREENSHOTS && scenario !== "full-scrollback-teardown") {
await page.screenshot({
path: path.join(process.env.TERMINAL_SCREENSHOTS, `${scenario}-${info.repeatEachIndex}.png`),
})
}
} finally {
listener.dispose()
try {
await benchmarkDiagnostics(page).stop()
// Stop fixture request handlers before killing their native resource. The
// app debounces PTY resize requests independently of the canvas resize.
await page.unrouteAll({ behavior: "wait" })
await page.close()
} finally {
pty.kill()
await exited
await writeFile(
path.join(
process.env.TERMINAL_ARTIFACTS ?? tmpdir(),
`${process.env.TERMINAL_BUNDLE}-${scenario}-${info.repeatEachIndex}.native.log`,
),
output,
)
await rm(dir, { recursive: true, force: true })
}
}
})
}
async function waitForText(page: Page, text: string) {
await expect
.poll(() =>
page.evaluate((text) => {
const probe = window.terminalProbe
const term = probe?.term
if (!term || probe.pending !== 0) return false
const buffer = term.buffer.active
return Array.from(
{ length: term.rows },
(_, i) => buffer.getLine(buffer.length - term.rows + i)?.translateToString(true) ?? "",
)
.join("\n")
.includes(text)
}, text),
)
.toBe(true)
}
@@ -1,17 +0,0 @@
import { mergeConfig } from "vite"
import config from "../../../vite.config"
// The probe is included only in this manual benchmark build, never in the app build.
export default mergeConfig(config, {
plugins: [
{
name: "terminal-benchmark-probe",
transformIndexHtml: {
order: "pre",
handler: () => [
{ tag: "script", attrs: { type: "module", src: "/e2e/performance/terminals/probe.ts" }, injectTo: "head" },
],
},
},
],
})
@@ -1,197 +0,0 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { fixture } from "./session-timeline-stress.fixture"
const sessionID = "ses_composer_write_batch"
const title = "Composer persistence workload"
const addition = " Keep the existing error handling and add coverage."
const text =
Array.from(
{ length: 180 },
(_, index) =>
`Review requirement ${index + 1}: preserve request ordering in src/queue/worker-${index % 12}.ts. ` +
`A failed request must retain its payload, report its cause, and remain safe to retry.\n` +
`Expected: await queue.flush(); expect(await repository.read(id)).toEqual(accepted);\n`,
).join("") + "Implementation notes:"
const items = Array.from({ length: 8 }, (_, index) => ({
type: "file",
path: `src/queue/worker-${index}.ts`,
selection: { startLine: 10, startChar: 0, endLine: 24, endChar: 0 },
commentID: `composer-write-batch-${index}`,
comment: `Check retry path ${index}: keep the original request identity and error cause.`,
preview: Array.from(
{ length: 24 },
(_, line) => ` const request${line} = await repository.loadPending("queue-${index}");`,
).join("\n"),
}))
const document = {
prompt: [{ type: "text", content: text, start: 0, end: text.length }],
cursor: text.length,
mode: "normal",
context: { items },
}
type Probe = { active: boolean; encodes: number; bytes: number; inputs: number; keyups: number }
type ProbeWindow = typeof window & { composerWriteBatch: Probe }
benchmark.use({
viewport: { width: 1440, height: 900 },
video: "off",
trace: "off",
serviceWorkers: "block",
traceScope: "interaction",
})
for (const scenario of ["typing", "cursor-movement", "cursor-noop", "submit-cleanup"] as const) {
benchmark(`composer-write-batch: ${scenario}`, async ({ page, report }, testInfo) => {
const submitted: Record<string, unknown>[] = []
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: [{ ...fixture.sessions[0], id: sessionID, title }],
pageMessages: () => ({ items: [] }),
onPrompt: (input) => submitted.push(input.body),
})
await page.addInitScript(
({ key, value, counts }) => {
localStorage.setItem(key, JSON.stringify(value))
const probe: Probe = { active: false, encodes: 0, bytes: 0, inputs: 0, keyups: 0 }
;(window as ProbeWindow).composerWriteBatch = probe
// The draft adapter parses each schema-encoded composer document once before
// its asynchronous blob walk. Count at this boundary, not at the IDB write
// (which already discards superseded writes). This fixture is ASCII only.
if (counts) {
const parse = JSON.parse
JSON.parse = (value, reviver) => {
if (probe.active && typeof value === "string" && value.startsWith('{"prompt":[')) {
probe.encodes++
probe.bytes += value.length
}
return parse(value, reviver)
}
}
window.addEventListener("input", (event) => {
if (
probe.active &&
event.target instanceof Element &&
event.target.matches('[data-component="composer-editor"]')
)
probe.inputs++
})
window.addEventListener("keyup", (event) => {
if (
probe.active &&
event.target instanceof Element &&
event.target.matches('[data-component="composer-editor"]')
)
probe.keyups++
})
},
{
key: `${base64Encode(fixture.directory)}/prompt/${sessionID}.v2`,
value: document,
counts: process.env.OPENCODE_PERSISTENCE_COUNTS === "1",
},
)
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(editor).toBeEditable()
await expect(editor).toHaveText(text)
await editor.focus()
await editor.press("ControlOrMeta+End")
await page.evaluate(() => window.document.fonts.ready)
const stored = async () =>
page.evaluate(async (sessionID) => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("opencode-drafts", 1)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
try {
const transaction = db.transaction("documents")
const keys = transaction.objectStore("documents").getAllKeys()
const values = transaction.objectStore("documents").getAll()
await new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
const index = keys.result.findIndex((key) => String(key).endsWith(`session:${sessionID}:prompt`))
// Parse after disabling the count so the observation is not part of it.
const probe = (window as ProbeWindow).composerWriteBatch
const active = probe.active
probe.active = false
const value = index < 0 ? undefined : JSON.parse(values.result[index])
probe.active = active
return value as { prompt: { content: string }[]; cursor: number; context: { items: unknown[] } } | undefined
} finally {
db.close()
}
}, sessionID)
await expect.poll(async () => (await stored())?.cursor).toBe(text.length)
expect((await stored())?.context.items).toHaveLength(items.length)
const cdp = await page.context().newCDPSession(page)
await cdp.send("Performance.enable")
await benchmarkDiagnostics(page).startTrace()
const before = await cdp.send("Performance.getMetrics")
await page.evaluate(() => {
;(window as ProbeWindow).composerWriteBatch.active = true
performance.mark("composer-write-batch-start")
})
const start = performance.now()
if (scenario === "typing") await editor.pressSequentially(addition)
if (scenario === "cursor-movement") await editor.press("ArrowLeft")
if (scenario === "cursor-noop") await editor.press("ArrowRight")
if (scenario === "submit-cleanup") await editor.press("Enter")
const expectedText = scenario === "typing" ? text + addition : scenario === "submit-cleanup" ? "" : text
const expectedCursor =
scenario === "typing"
? text.length + addition.length
: scenario === "submit-cleanup"
? 0
: text.length - Number(scenario === "cursor-movement")
await expect(editor).toHaveText(expectedText)
await expect.poll(async () => (await stored())?.cursor).toBe(expectedCursor)
const elapsedMs = performance.now() - start
const after = await cdp.send("Performance.getMetrics")
const probe = await page.evaluate(() => {
performance.mark("composer-write-batch-end")
const probe = (window as ProbeWindow).composerWriteBatch
probe.active = false
return probe
})
expect((await stored())?.prompt.map((part) => part.content).join("")).toBe(expectedText)
if (scenario === "submit-cleanup") {
await expect.poll(() => submitted.length).toBe(1)
expect(submitted[0].text).toContain(text)
expect((await stored())?.context.items).toHaveLength(0)
}
expect(probe.keyups).toBe(scenario === "typing" ? addition.length : 1)
expect(probe.inputs).toBe(scenario === "typing" ? addition.length : 0)
const metric = (name: string) =>
1000 *
((after.metrics.find((x) => x.name === name)?.value ?? 0) -
(before.metrics.find((x) => x.name === name)?.value ?? 0))
report(
{ elapsedMs, taskMs: metric("TaskDuration"), scriptMs: metric("ScriptDuration"), ...probe },
{
scenario,
promptBytes: Buffer.byteLength(text),
contextItems: items.length,
persistedBytes: Buffer.byteLength(JSON.stringify(document)),
typedCharacters: scenario === "typing" ? addition.length : 0,
counts: process.env.OPENCODE_PERSISTENCE_COUNTS === "1",
browser: page.context().browser()!.version(),
build: process.env.OPENCODE_PERSISTENCE_BUILD,
transport: "playwright-route",
completion: "editor text and committed IDB cursor",
},
)
await benchmarkDiagnostics(page).stop()
await cdp.detach()
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath(`${scenario}.png`) })
})
}
@@ -1,166 +0,0 @@
import { benchmark, expect } from "../benchmark"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { fixture } from "./session-timeline-stress.fixture"
import { messages } from "./session-tab-switch.fixture"
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
const sessions = Array.from({ length: 8 }, (_, index) => ({
...fixture.sessions[0],
id: `ses_prefetch_${index}`,
title: `Renderer review ${index}`,
}))
// A normal first page, not the full-history response used by the tab-switch benchmark.
const pages = Object.fromEntries(
sessions.map((session) => [
session.id,
messages[fixture.targetID].slice(-20).map((message) => ({ ...message, id: `${message.id}_${session.id}` })),
]),
)
const workload = {
sessions: sessions.length,
messagesPerPage: 20,
payloadBytes: Object.fromEntries(
sessions.map((session) => [
session.id,
Buffer.byteLength(JSON.stringify({ data: pages[session.id].toReversed(), cursor: {} })),
]),
),
partsPerPage: pages[sessions[0].id].reduce(
(count, message) => count + (message.type === "assistant" ? message.content.length : 1),
0,
),
events: 0,
}
type ProbeWindow = Window & { __prefetchBodies?: Record<string, number> }
benchmark.use({ viewport: { width: 1440, height: 900 }, video: "off", trace: "off", serviceWorkers: "block" })
for (const close of [false, true]) {
benchmark(
`inactive tab prefetch: ${close ? "close before response" : "activate after restore"}`,
async ({ page, report }, testInfo) => {
const gate = Promise.withResolvers<void>()
const reads: string[] = []
const inboxReads: string[] = []
const pending = new Set<string>()
const concurrency = { peak: 0 }
const mutations: string[] = []
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
page.on("response", (response) => {
if (new URL(response.url()).pathname.startsWith("/api/") && !response.ok())
errors.push(`HTTP ${response.status()}: ${response.url()}`)
})
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "DELETE" || /\/(interrupt|prompt)$/.test(path)) mutations.push(request.url())
const inbox = path.match(/^\/api\/session\/([^/]+)\/inbox$/)
if (request.method() === "GET" && inbox) inboxReads.push(inbox[1])
})
await page.addInitScript(() => {
const host = window as ProbeWindow
host.__prefetchBodies = {}
const text = Response.prototype.text
Response.prototype.text = async function () {
const body = await text.call(this)
if (this.url) {
const path = new URL(this.url).pathname
host.__prefetchBodies![path] = (host.__prefetchBodies![path] ?? 0) + 1
}
return body
}
})
await mockOpenCodeServer(page, {
...fixture,
sessions,
pageMessages: (id) => ({ items: pages[id] ?? [] }),
beforeMessagesResponse: ({ sessionID }) => (sessionID === sessions[0].id ? Promise.resolve() : gate.promise),
onMessages: ({ sessionID, phase }) => {
if (phase === "end") return void pending.delete(sessionID)
reads.push(sessionID)
pending.add(sessionID)
concurrency.peak = Math.max(concurrency.peak, pending.size)
},
})
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
const cdp = await page.context().newCDPSession(page)
await cdp.send("Performance.enable")
await page.goto(stressSessionHref(sessions[0].id))
await expectSessionTitle(page, sessions[0].title)
await waitForStableTimeline(page, pages[sessions[0].id].at(-2)!.id)
// Every inactive tab's scheduled attention request must finish. This gates on the
// same production callback as prefetch, without a sleep or waiting for a removed read.
await page.waitForFunction(
(ids) => ids.every((id) => (window as ProbeWindow).__prefetchBodies![`/api/session/${id}/form`] > 0),
sessions.slice(1).map((session) => session.id),
)
const speculativeReads = reads.filter((id) => id !== sessions[0].id)
const speculativeInboxReads = inboxReads.filter((id) => id !== sessions[0].id).length
const closed = sessions.at(-1)!
if (close) {
const tab = page
.locator("[data-titlebar-tab-slot]")
.filter({ has: page.locator(`a[href="${stressSessionHref(closed.id)}"]`) })
await tab.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(tab).toHaveCount(0)
}
gate.resolve()
await page.waitForFunction(
(ids) => ids.every((id) => (window as ProbeWindow).__prefetchBodies![`/api/session/${id}/message`] > 0),
reads,
)
await expectSessionTitle(page, sessions[0].title)
const heap =
process.env.OPENCODE_PERFORMANCE_MEMORY === "1"
? await cdp.send("HeapProfiler.collectGarbage").then(() => cdp.send("Runtime.getHeapUsage"))
: undefined
const task =
(await cdp.send("Performance.getMetrics")).metrics.find((metric) => metric.name === "TaskDuration")!.value *
1000
const before = reads.length
const target = sessions[1]
const result = await measureSessionSwitch(page, {
destinationIDs: pages[target.id].map((message) => message.id),
sourceIDs: pages[sessions[0].id].map((message) => message.id),
lastID: pages[target.id].at(-2)!.id,
requiredPartID: `${pages[target.id].at(-1)!.id}:text:0`,
href: stressSessionHref(target.id),
switch: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(target.id)}"]`).click()
await expectSessionTitle(page, target.title)
},
})
await expect(
page.locator(`[data-timeline-part-id="${pages[target.id].at(-1)!.id}:text:0"] [data-component="markdown"]`),
).toHaveAttribute("data-markdown-ready", "")
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(mutations).toEqual([])
expect(errors).toEqual([])
report(
{
speculativeReads: speculativeReads.length,
speculativeInboxReads,
speculativePayloadBytes: speculativeReads.reduce((bytes, id) => bytes + workload.payloadBytes[id], 0),
peakMessageRequests: concurrency.peak,
closedSessionReads: close ? reads.filter((id) => id === closed.id).length : undefined,
activationReads: reads.length - before,
startupTaskMs: task,
retainedHeap: heap,
...result,
},
{
...workload,
close,
gc: heap ? "forced retention; timing diagnostic only" : "natural; clean timing",
transport: "playwright-route",
browser: page.context().browser()!.version(),
scope: "production app renderer; not total desktop RAM",
},
)
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath("destination.png") })
await cdp.detach()
},
)
}
@@ -1,33 +0,0 @@
import type { NavigationMilestoneSample } from "./navigation-milestones"
import { measureNavigationMilestones } from "./navigation-milestones"
import { benchmark, expect } from "../benchmark"
benchmark(
"navigation milestones start at mousedown and wait for the expected ready controls",
async ({ page, report }) => {
await page.setContent('<button id="open">Open</button><input id="editor" disabled><span id="model">Loading</span>')
const result = await measureNavigationMilestones(page, {
triggerSelector: "#open",
milestones: { editor: { selector: "#editor:enabled:focus" }, model: { selector: "#model", text: "Ready model" } },
navigate: async () => {
await page.getByRole("button", { name: "Open", exact: true }).dispatchEvent("mousedown", { button: 0 })
await page.locator("#editor").evaluate((element: HTMLInputElement) => {
element.disabled = false
element.focus()
})
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: { samples: NavigationMilestoneSample[] } })
.__navigationMilestones?.samples
return samples?.some((sample) => sample.milestones.editor && !sample.milestones.model)
})
await page.locator("#model").evaluate((element) => {
element.textContent = "Ready model"
})
},
})
expect(result.summary.all.firstObservedMs).not.toBeNull()
expect(result.summary.all.firstObservedMs).toBeGreaterThan(result.summary.milestones.editor.firstObservedMs!)
expect(await page.evaluate(() => "__navigationMilestones" in window)).toBe(false)
report(result)
},
)
@@ -36,7 +36,7 @@ export async function measureNavigationMilestones(
page: Page,
input: {
triggerSelector: string
milestones: Record<string, { selector: string; visible?: boolean; text?: string }>
milestones: Record<string, { selector: string; visible?: boolean }>
navigate: () => Promise<void>
},
) {
@@ -47,19 +47,11 @@ export async function measureNavigationMilestones(
const marked = new Set<string>()
let started: number | undefined
let running = true
const visible = (selector: string, text?: string) =>
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
if (text !== undefined && element.textContent?.replace(/\s+/g, " ").trim() !== text) return false
const rect = element.getBoundingClientRect()
return (
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.top < innerHeight &&
rect.right > 0 &&
rect.left < innerWidth
)
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
@@ -69,9 +61,7 @@ export async function measureNavigationMilestones(
const current = Object.fromEntries(
Object.entries(milestones).map(([name, milestone]) => [
name,
milestone.visible === false
? !document.querySelector(milestone.selector)
: visible(milestone.selector, milestone.text),
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
]),
)
samples.push({
@@ -103,46 +93,36 @@ export async function measureNavigationMilestones(
}, 0)
})
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.start")
sample()
}
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
document.addEventListener(
"click",
(event) => {
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.click")
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
},
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
)
try {
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
return (
samples &&
samples.length >= 3 &&
samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
)
})
const samples = await page.evaluate(
() => (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!.samples,
)
return { summary: summarizeNavigationMilestones(samples), samples }
} finally {
await page.evaluate(() => {
const host = window as Window & { __navigationMilestones?: NavigationMilestoneProbe }
host.__navigationMilestones?.stop()
delete host.__navigationMilestones
})
}
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
if (!samples || samples.length < 3) return false
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
probe.stop()
return probe.samples
})
return { summary: summarizeNavigationMilestones(samples), samples }
}
@@ -1,91 +0,0 @@
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import { measureNavigationMilestones } from "./navigation-milestones"
import { fixture } from "./session-timeline-stress.fixture"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
benchmark.use({
viewport: { width: 1440, height: 900 },
serviceWorkers: "block",
traceScope: "interaction",
trace: "off",
video: "off",
})
for (const entry of ["home", "session"] as const) {
benchmark(`entry: new session from ${entry}`, async ({ page, report }) => {
await mockStressTimeline(page)
await installStressSessionTabs(page, { sessionIDs: entry === "home" ? [] : [fixture.sourceID] })
await page.goto(entry === "home" ? "/" : stressSessionHref(fixture.sourceID))
if (entry === "session") await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const trigger = entry === "home" ? '[data-action="home-new-session"]' : 'button[aria-label="New session"]'
await expect(page.locator(trigger)).toBeVisible()
await expect(page.locator('[data-component="new-session"]')).toHaveCount(0)
const writes: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/") && !["GET", "HEAD", "OPTIONS"].includes(request.method()))
writes.push(request.method())
})
await benchmarkDiagnostics(page).startTrace()
const result = await measureNavigationMilestones(page, {
triggerSelector: trigger,
milestones: {
editor: {
selector: '[data-component="new-session"] [data-component="composer-editor"][contenteditable="true"]:focus',
},
model: { selector: '[data-component="new-session"] [data-action="composer-model"]', text: "Claude Opus 4.6" },
project: { selector: '[data-component="new-session"] [data-action="prompt-project"]' },
tab: { selector: '[data-titlebar-tab-link][href^="/new-session?draftId="]' },
},
navigate: () => page.locator(trigger).click(),
})
await benchmarkDiagnostics(page).stop()
const editor = page.locator('[data-component="new-session"] [data-component="composer-editor"]')
await expect(editor).toHaveText("")
await page.keyboard.type("Draft input")
await expect(editor).toHaveText("Draft input")
expect(writes).toEqual([])
report(
{
firstCorrectObservedMs: result.summary.all.firstObservedMs,
stableObservedMs: result.summary.all.stableObservedMs,
...result,
},
{ entry, data: "fixture", inputEvent: "mousedown" },
)
})
}
benchmark("entry: cold session from Home", async ({ page, report }) => {
const requests: string[] = []
await mockStressTimeline(page, {
onMessages: (request) => {
if (request.phase === "start") requests.push(request.sessionID)
},
})
await installStressSessionTabs(page, { sessionIDs: [] })
await page.goto("/")
const selector = `[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"] [data-component="home-session-row"]`
await expect(page.locator(selector)).toBeVisible()
expect(requests).not.toContain(fixture.targetID)
const href = stressSessionHref(fixture.targetID)
await benchmarkDiagnostics(page).startTrace()
const result = await measureSessionSwitch(page, {
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
sourceIDs: [],
lastID: fixture.expected.targetMessageIDs.at(-1)!,
requiredPartID: fixture.expected.targetPartIDs.at(-1)!,
href,
triggerSelector: selector,
switch: async () => {
await page.locator(selector).click()
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
},
})
await benchmarkDiagnostics(page).stop()
await expect(
page.locator(`[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-link][href="${href}"]`),
).toHaveCount(1)
expect(requests).toContain(fixture.targetID)
report(result, { entry: "home", data: "cold paginated fixture", inputEvent: "mousedown" })
})
@@ -48,27 +48,20 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
expect(result.samples.length).toBeGreaterThan(0)
})
benchmark("loads only the selected restored tab's transcript", async ({ page, report }) => {
const loaded = new Set<string>()
benchmark("prefetches every open session tab", async ({ page, report }) => {
const prefetched = new Set<string>()
await mockStressTimeline(page, {
onMessages: (input) => {
if (!input.before && input.phase === "start") loaded.add(input.sessionID)
if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
},
})
await installStressSessionTabs(page, {
sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
})
await installTimelineSettings(page)
const attention = Promise.all(
[fixture.targetID, fixture.childID].map((id) =>
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${id}/form`),
),
)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await attention
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
expect([...loaded]).toEqual([fixture.sourceID])
report({ loaded: [...loaded] })
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
report({ prefetched: [...prefetched] })
})
@@ -1,124 +1,71 @@
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { expected, messages, workload } from "./session-tab-switch.fixture"
import {
createReviewDiffs,
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
const scenarios = [
{ cache: "cold", review: "closed" },
{ cache: "cold", review: "open" },
{ cache: "warm", review: "closed" },
{ cache: "warm", review: "open" },
{ cache: "warm", review: "resized" },
] as const
const viewport = { width: 1440, height: 900 }
const reviewDiffs = createReviewDiffs()
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
{ cached: false, review: false, resized: false },
{ cached: false, review: true, resized: false },
{ cached: true, review: false, resized: false },
{ cached: true, review: true, resized: false },
{ cached: true, review: true, resized: true },
]
scenarios.forEach((scenario) => {
benchmark(`tab switch: ${scenario.cache}, review ${scenario.review}`, async ({ page, report }, testInfo) => {
const requests = await prepareSessionTabs(page)
if (scenario.review === "open") await openReviewPane(page)
if (scenario.cache === "warm") {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
await expectReadyTimeline(page, fixture.targetID)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
}
if (scenario.review === "resized") await openReviewPane(page)
await expectReadyTimeline(page, fixture.sourceID)
await benchmarkDiagnostics(page).startTrace()
const requestsBefore = requests.length
const name = `tab switch: ${scenario.cached ? "cached" : "unmounted"}, review ${scenario.review ? "open" : "closed"}${scenario.resized ? ", resized" : ""}`
benchmark(name, async ({ browser, report }, testInfo) => {
const result = await withBenchmarkPage(
browser,
name,
async (page) => {
await mockStressTimeline(page, { vcsDiff: createReviewDiffs() })
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (scenario.review && !scenario.resized) await openReviewPane(page)
if (scenario.cached) {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
const answer = page.locator(`[data-timeline-part-id="${fixture.expected.targetPartIDs.at(-1)}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect
.poll(() =>
answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })),
)
.toBe(true)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
}
if (scenario.resized) await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const result = await measureSessionSwitch(page, {
destinationIDs: messages[fixture.targetID].map((message) => message.id),
sourceIDs: messages[fixture.sourceID].map((message) => message.id),
lastID: expected[fixture.targetID].lastID,
requiredPartID: expected[fixture.targetID].answerID,
href: stressSessionHref(fixture.targetID),
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(requests).toHaveLength(requestsBefore + (scenario.cache === "cold" ? 1 : 0))
await expectReadyTimeline(page, fixture.targetID)
report(
{
...result,
messageRequestsDuringSwitch: requests.length - requestsBefore,
rendererMemory:
process.env.OPENCODE_PERFORMANCE_MEMORY === "1" ? await retainedRendererMemory(page) : undefined,
},
{
...scenario,
...workload,
viewport,
browserVersion: page.context().browser()!.version(),
serviceWorkers: "blocked",
reviewFiles: scenario.review === "closed" ? 0 : reviewDiffs.length,
data: scenario.cache === "cold" ? "on-demand" : "cached",
transport: process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE === "1" ? "http" : "playwright-route",
inputEvent: "mousedown",
requireReadyAnswer: true,
return measureSessionSwitch(page, {
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
lastID: fixture.expected.targetMessageIDs.at(-1)!,
requiredPartID: fixture.expected.targetPartIDs.at(-1),
href: stressSessionHref(fixture.targetID),
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
},
testInfo,
)
if (testInfo.repeatEachIndex === 0) {
await page.screenshot({ path: testInfo.outputPath("destination.png") })
await testInfo.attach("destination", { path: testInfo.outputPath("destination.png"), contentType: "image/png" })
}
expect(result.unknownSamples).toBe(0)
expect(result.wrongDestinationSamples).toBe(0)
if (scenario.cached) expect(result.blankSamples).toBe(0)
report(result, { ...scenario, inputEvent: "mousedown", requireReadyAnswer: true })
})
})
async function prepareSessionTabs(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "GET") return
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
if (match) requests.push(decodeURIComponent(match[1]))
})
if (process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE !== "1")
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
// Return the full history so every scenario exercises a long loaded timeline.
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
vcsDiff: reviewDiffs,
})
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expectReadyTimeline(page, fixture.sourceID)
await expect(page.locator(`[data-timeline-part-id="${expected[fixture.targetID].answerID}"]`)).toHaveCount(0)
expect(requests).toEqual([fixture.sourceID])
return requests
}
async function expectReadyTimeline(page: Page, sessionID: string) {
const answer = page.locator(`[data-timeline-part-id="${expected[sessionID].answerID}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect(answer.getByRole("table")).toHaveCount(1)
await expect(answer.locator("pre")).toHaveCount(4)
await expect
.poll(() => answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })))
.toBe(true)
await waitForStableTimeline(page, expected[sessionID].lastID)
await expect(page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(
0,
)
}
async function switchSession(page: Page, sessionID: string, title: string) {
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sessionID)}"]`)
await expect(tab).toHaveCount(1)
@@ -133,20 +80,4 @@ async function openReviewPane(page: Page) {
const text = document.querySelector("#review-panel")?.textContent ?? ""
return text.includes("generated-000.ts") && text.includes("+3")
})
await page.locator('[data-slot="session-chat-panel"]').evaluate(async (panel) => {
await Promise.all(panel.getAnimations().map((animation) => animation.finished))
})
}
async function retainedRendererMemory(page: Page) {
const cdp = await page.context().newCDPSession(page)
try {
await cdp.send("HeapProfiler.collectGarbage")
return {
heap: await cdp.send("Runtime.getHeapUsage"),
dom: await cdp.send("Memory.getDOMCounters"),
}
} finally {
await cdp.detach()
}
}
@@ -2,77 +2,66 @@ import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch } from "./session-tab-switch-probe"
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
for (const tag of ["a", "button"] as const) {
benchmark(
`starts at ${tag} mousedown and excludes hidden or unfinished destination content`,
async ({ page, report }) => {
await page.setContent(`
<${tag} id="destination" ${tag === "a" ? 'href="/session/destination"' : 'type="button"'}>Destination</${tag}>
benchmark("starts at mousedown and excludes hidden or unfinished destination content", async ({ page, report }) => {
await page.setContent(`
<a href="/session/destination">Destination</a>
<div class="scroll-view__viewport" style="height:200px;overflow:auto">
<div data-timeline-row="message" data-timeline-key="row" data-message-id="source">
<div data-timeline-part-id="answer"><div data-component="markdown">Destination answer</div></div>
</div>
</div>
`)
await page.evaluate(() => {
document.querySelector("#destination")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
await page.evaluate(() => {
document.querySelector("a")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
})
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page.getByRole("link", { name: "Destination" }).dispatchEvent("mousedown", { button: 0 })
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
})
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
triggerSelector: tag === "button" ? "#destination" : undefined,
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page
.getByRole(tag === "a" ? "link" : "button", { name: "Destination", exact: true })
.dispatchEvent("mousedown", { button: 0 })
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
})
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some(
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
)
})
const beforeClip = await page.evaluate(() => {
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
row.style.cssText = "height:10px;position:relative;overflow:clip"
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
answer.style.cssText = "position:absolute;top:30px;width:150px"
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
return (
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
?.samples.length ?? 0
)
})
await page.waitForFunction((count) => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples
.slice(count)
.some((sample) => sample.requiredPartVisible === false)
}, beforeClip)
await page.locator("[data-timeline-key]").evaluate((row) => {
row.style.height = "100px"
})
},
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some(
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
)
})
const beforeClip = await page.evaluate(() => {
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
row.style.cssText = "height:10px;position:relative;overflow:clip"
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
answer.style.cssText = "position:absolute;top:30px;width:150px"
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
return (
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
?.samples.length ?? 0
)
})
await page.waitForFunction((count) => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.slice(count).some((sample) => sample.requiredPartVisible === false)
}, beforeClip)
await page.locator("[data-timeline-key]").evaluate((row) => {
row.style.height = "100px"
})
expect(result.blankSamples).toBeGreaterThan(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
expect(await page.evaluate(() => "__sessionSwitchProbe" in window)).toBe(false)
report(result)
},
)
}
})
expect(result.blankSamples).toBeGreaterThan(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
report(result)
})
@@ -14,132 +14,124 @@ async function installSessionSwitchProbe(
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
triggerSelector?: string
href: string
},
) {
await page.evaluate(
({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, triggerSelector, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
file: '#review-panel [data-component="file"][data-mode="diff"]',
}
const initialReviewNodes: Record<string, Element | null> = {}
const sample = () => {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
file: '#review-panel [data-component="file"][data-mode="diff"]',
}
const initialReviewNodes: Record<string, Element | null> = {}
const sample = () => {
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
const initial = initialReviewNodes[name]
if (!initial) return []
const current = document.querySelector(selector)
return current && current !== initial ? [name] : []
})
const review = reviewPanel
? {
fileHost: !!reviewFile,
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
header:
reviewPanel
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
?.textContent?.trim() ?? "",
replacedLevels,
}
: undefined
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const inViewport = (element: HTMLElement) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
const rect = element.getBoundingClientRect()
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
return (
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
)
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
const initial = initialReviewNodes[name]
if (!initial) return []
const current = document.querySelector(selector)
return current && current !== initial ? [name] : []
})
const review = reviewPanel
? {
fileHost: !!reviewFile,
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
header:
reviewPanel
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
?.textContent?.trim() ?? "",
replacedLevels,
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
if (!element.textContent?.trim()) return false
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
return inViewport(element)
})
: undefined
const spacer = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({
observedAtMs: performance.now() - started,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
requiredPartVisible,
bottomAnchorRequired: requireBottomAnchor !== false,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
review,
})
} else {
samples.push({
observedAtMs: performance.now() - started,
destination: [],
source: [],
hasVisibleRows: false,
last: false,
requiredPartVisible: requiredPartID ? false : undefined,
bottomAnchorRequired: requireBottomAnchor !== false,
review,
})
: undefined
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const inViewport = (element: HTMLElement) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
const rect = element.getBoundingClientRect()
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
return (
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
)
}
requestAnimationFrame(sample)
}, 0)
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const trigger = event.target instanceof Element ? event.target.closest(triggerSelector ?? "a") : undefined
if (!trigger || (!triggerSelector && trigger.getAttribute("href") !== href)) return
started = performance.now()
performance.mark("session-switch:start", { startTime: started })
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
if (!element.textContent?.trim()) return false
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
return inViewport(element)
})
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs: performance.now() - started,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
requiredPartVisible,
bottomAnchorRequired: requireBottomAnchor !== false,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
review,
})
} else {
samples.push({
observedAtMs: performance.now() - started,
destination: [],
source: [],
hasVisibleRows: false,
last: false,
requiredPartVisible: requiredPartID ? false : undefined,
bottomAnchorRequired: requireBottomAnchor !== false,
review,
})
}
requestAnimationFrame(sample)
}, 0)
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
Object.keys(initialReviewNodes).forEach((key) => (initialReviewNodes[key] = null))
},
}
},
input,
)
requestAnimationFrame(sample)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
}, input)
}
async function waitForStableSessionSwitch(page: Page) {
@@ -167,17 +159,9 @@ async function collectSessionSwitchResult(page: Page) {
const samples = await page.evaluate(() => {
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
probe.stop()
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
return probe.samples
})
const result = classifySessionSwitch(samples)
await page.evaluate(({ firstCorrectObservedMs, stableObservedMs }) => {
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
if (firstCorrectObservedMs !== null)
performance.mark("session-switch:ready", { startTime: start + firstCorrectObservedMs })
if (stableObservedMs !== null) performance.mark("session-switch:stable", { startTime: start + stableObservedMs })
}, result)
return result
return classifySessionSwitch(samples)
}
export async function measureSessionSwitch(
@@ -188,7 +172,6 @@ export async function measureSessionSwitch(
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
triggerSelector?: string
href: string
switch: () => Promise<void>
},
@@ -202,7 +185,6 @@ export async function measureSessionSwitch(
} finally {
await page.evaluate(() => {
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
})
}
}
@@ -1,143 +0,0 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { fixture } from "./session-timeline-stress.fixture"
export const exchanges = 200
export const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((sessionID) => [
sessionID,
Array.from({ length: exchanges }, (_, index) => {
const seed = fixture.messages[fixture.targetID]
const user = seed[(index % (seed.length / 2)) * 2]!
const assistant = seed[(index % (seed.length / 2)) * 2 + 1]!
if (user.type !== "user" || assistant.type !== "assistant") throw new Error("Expected a user/assistant pair")
const suffix = `${sessionID}_${String(index).padStart(4, "0")}`
return [
{
...user,
id: `msg_user_${suffix}`,
time: { created: 1700000000000 + index * 10_000 },
},
{
...assistant,
id: `msg_assistant_${suffix}`,
time: { created: 1700000001000 + index * 10_000, completed: 1700000008000 + index * 10_000 },
content: [
...assistant.content
.filter((part) => part.type !== "text")
.map((part) => (part.type === "tool" ? { ...part, id: `${part.id}_${suffix}` } : part)),
{ type: "text", text: complexMarkdown(sessionID, index) },
],
},
] satisfies SessionMessageInfo[]
}).flat(),
]),
)
export const expected = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((sessionID) => [
sessionID,
{
lastID: messages[sessionID].at(-2)!.id,
answerID: `${messages[sessionID].at(-1)!.id}:text:0`,
},
]),
)
export const workload = {
fixture: "long-complex-markdown-v1",
exchangesPerSession: exchanges,
messagesPerSession: exchanges * 2,
history: "full fixture history in one response",
sessions: Object.fromEntries(
Object.entries(messages).map(([sessionID, items]) => [
sessionID,
{
payloadBytes: Buffer.byteLength(JSON.stringify(items)),
markdownBytes: items.reduce(
(total, message) =>
total +
(message.type === "assistant"
? message.content.reduce(
(size, part) => size + (part.type === "text" ? Buffer.byteLength(part.text) : 0),
0,
)
: 0),
0,
),
},
]),
),
}
function complexMarkdown(sessionID: string, index: number) {
return `## Renderer review ${sessionID} / ${index}
Preserve **semantic identity**, *measured geometry*, and ~~obsolete estimates~~ when switching sessions. The \`measureElement(node)\` result must agree with the [rendering contract](https://example.com/rendering/${sessionID}/${index}).
> A completed answer contains formatted prose, highlighted source, and structured results.
> Keep the previous view until the destination is ready, rather than exposing partially formatted content.
### Readiness checklist
- [x] Resolve the destination session and its messages.
- [x] Parse Markdown and highlight fenced code.
- [ ] Verify a different panel width.
- Preserve the bottom anchor.
- Reuse the measured rows when their width matches.
| Stage | Input | Expected result | Verification |
| :--- | ---: | :--- | :--- |
${Array.from({ length: 8 }, (_, row) => `| stage-${index}-${row} | ${index * 8 + row} | **ready** with \`row[${row}]\` | stable geometry and visible content |`).join("\n")}
### Implementation
\`\`\`tsx
import { For, Show, createMemo } from "solid-js"
type Row = { id: string; title: string; ready: boolean; height: number }
export function SessionRows${index}(props: { rows: Row[]; selected: string }) {
const visible = createMemo(() => props.rows.filter((row) => row.ready))
return (
<section aria-label="${sessionID}-${index}">
<For each={visible()}>{(row) => (
<article data-selected={row.id === props.selected}>
<h3>{row.title}</h3>
<Show when={row.height > 0} fallback={<span>Measuring</span>}>
<output>{row.height.toFixed(2)} pixels</output>
</Show>
</article>
)}</For>
</section>
)
}
\`\`\`
\`\`\`json
${JSON.stringify({ session: sessionID, exchange: index, stages: ["hydrate", "parse", "highlight", "measure"], viewport: { width: 1440, height: 900 }, cache: { markdown: true, geometry: true } }, null, 2)}
\`\`\`
\`\`\`sql
SELECT session_id, COUNT(*) AS messages, MAX(created_at) AS latest
FROM session_message
WHERE session_id = '${sessionID}' AND ordinal >= ${index}
GROUP BY session_id
ORDER BY latest DESC;
\`\`\`
### Verification
1. Open the long source session and wait for its final answer.
2. Select the destination tab, without changing the viewport.
3. Confirm that **all Markdown is ready** and the bottom anchor is correct.
\`\`\`bash
bun typecheck
bunx playwright test --config e2e/performance/playwright.config.ts
git diff --check # ${sessionID}-${index}
\`\`\`
**Review complete: ${sessionID} / ${index}.**
`
}
@@ -1,114 +0,0 @@
import { expect, spyOn, test } from "bun:test"
import type { FullConfig, Suite, TestCase, TestResult } from "@playwright/test/reporter"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import TabSwitchReporter from "../tab-switch-reporter"
test("summarizes each scenario and saves complete records in the configured output directory", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
const output = path.join(root, "configured-output")
const log = spyOn(console, "log").mockImplementation(() => {})
try {
const reporter = new TabSwitchReporter()
const cases = Array.from(
{ length: 23 },
(_, index) => ({ id: String(index), title: index < 20 ? "cold" : "warm" }) as TestCase,
)
const records = cases.map((_, index) => {
const first = index < 20 ? 20 - index : [40, 0, 20][index - 20]
return JSON.stringify({
status: "passed",
metrics: {
firstCorrectObservedMs: first,
stableObservedMs: first * 2,
samples: [{ observedAtMs: first, destination: ["answer"], source: [] }],
},
extra: { preserved: "\u03b1" },
})
})
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
cases.forEach((item, index) => {
const bytes = Buffer.from(`BENCHMARK ${records[index]}\r\n`)
const split = bytes.indexOf(Buffer.from("\u03b1")) + 1
reporter.onTestEnd(item, {
status: "passed",
stdout:
index === 0
? [bytes.subarray(0, split), bytes.subarray(split)]
: ["other output\nBENCHMARK_PAGE {}\nBENCH", "MARK ", records[index], "\n"],
} as TestResult)
})
await reporter.onEnd({ status: "passed", startTime: new Date(0), duration: 0 })
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(`${records.join("\n")}\n`)
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
expect(summary).toContain("cold\n Tests: passed=20; unrun=0")
expect(summary).toContain("warm\n Tests: passed=3; unrun=0")
expect(summary).toContain("firstCorrectObservedMs: n=20, median=10.50 ms, p95=19.00 ms")
expect(summary).toContain("stableObservedMs: n=20, median=21.00 ms, p95=38.00 ms")
expect(summary).toContain("firstCorrectObservedMs: n=3, median=20.00 ms, p95=40.00 ms")
expect(summary).toContain("stableObservedMs: n=3, median=40.00 ms, p95=80.00 ms")
} finally {
log.mockRestore()
await rm(root, { recursive: true, force: true })
}
})
test("reports failures, missing records, and invalid metrics without discarding raw data", async () => {
const output = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
const log = spyOn(console, "log").mockImplementation(() => {})
try {
const reporter = new TabSwitchReporter()
const entries = [
{
status: "passed",
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":12,"stableObservedMs":24}}',
},
{
status: "failed",
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
},
{
status: "passed",
raw: '{"status":"failed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
},
{ status: "timedOut", raw: '{"status":"failed","metrics":null,"error":"Benchmark did not report metrics"}' },
{
status: "passed",
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":null,"stableObservedMs":40}}',
},
{ status: "failed", raw: '{"status":' },
{ status: "skipped", raw: undefined },
] as const
const cases = Array.from(
{ length: entries.length + 2 },
(_, index) => ({ id: String(index), title: index <= entries.length ? "cold" : "empty" }) as TestCase,
)
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
entries.forEach((entry, index) => {
reporter.onTestEnd(cases[index], {
status: entry.status,
stdout: entry.raw === undefined ? [] : [`BENCHMARK ${entry.raw}\n`],
} as TestResult)
})
await reporter.onEnd({ status: "interrupted", startTime: new Date(0), duration: 0 })
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(
entries.flatMap((entry) => (entry.raw === undefined ? [] : [`${entry.raw}\n`])).join(""),
)
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
expect(summary).toContain("Tab-switch benchmark: interrupted")
expect(summary).toContain("Tests: passed=3, failed=2, timedOut=1, skipped=1; unrun=1")
expect(summary).toContain("Records: passed=3, failed=2, invalid JSON=1; missing=2; excluded=5; invalid metrics=1")
expect(summary).toContain("firstCorrectObservedMs: n=1, median=12.00 ms, p95=12.00 ms")
expect(summary).toContain("stableObservedMs: n=1, median=24.00 ms, p95=24.00 ms")
expect(summary).toContain("empty\n Tests: none; unrun=1")
expect(summary).toContain("Records: none; missing=1; excluded=0; invalid metrics=0")
expect(summary).toContain("firstCorrectObservedMs: n=0, median=n/a, p95=n/a")
expect(summary).toContain("stableObservedMs: n=0, median=n/a, p95=n/a")
} finally {
log.mockRestore()
await rm(output, { recursive: true, force: true })
}
})
@@ -8,8 +8,7 @@ const projectID = "proj_composer_editing"
const sessionID = "ses_composer_editing"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("keeps a narrow session composer contained when invoking a built-in", async ({ page }) => {
await page.setViewportSize({ width: 800, height: 600 })
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -41,8 +40,6 @@ test("keeps a narrow session composer contained when invoking a built-in", async
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
.toBe(`"${String.fromCodePoint(0x200b)}"`)
await expectAppVisible(composer)
await expect(page.locator('[data-slot="session-chat-panel"]')).toHaveCSS("min-width", "0px")
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await input.fill("keep me")
await composer.getByRole("button", { name: "Add images and files" }).click()
@@ -1,4 +1,4 @@
import { expect, test, type Locator } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -65,64 +65,11 @@ for (const lines of [6000, 25000]) {
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
expect(await events.evaluate((events) => events.count)).toBe(1)
await expect(input).toBeFocused()
await expectCaretVisible(input)
const scroll = page.locator('[data-component="composer-scroll"]')
await expect(scroll.locator(".scroll-view__viewport")).toHaveCSS("scrollbar-width", "none")
await expect(scroll.locator(".scroll-view__thumb")).toBeVisible()
await page.keyboard.type("!")
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
await expectCaretVisible(input)
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
const bounds = await scroll.boundingBox()
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
await page.mouse.down()
await page.mouse.move(thumb.x + thumb.width / 2, bounds.y + 8 + thumb.height / 2)
await page.mouse.up()
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
await expect(input).toBeFocused()
await page.keyboard.press("ControlOrMeta+Home")
await page.keyboard.press("ControlOrMeta+End")
await expectCaretVisible(input)
})
}
async function expectCaretVisible(input: Locator) {
await expect
.poll(() =>
input.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
const caret = selection.getRangeAt(0).getBoundingClientRect()
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
}),
)
.toBe(true)
}
for (const width of [390, 1280]) {
for (const direction of ["ltr", "rtl"]) {
test(`reveals a multiline paste in the middle at ${width}px in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 })
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
const suffix = "\nExisting trailing content".repeat(100)
await input.fill("Before " + suffix)
await input.press("ControlOrMeta+Home")
await input.press("ArrowRight")
const text = "Pasted line /tmp/example.ts 123 \u0645\u0631\u062d\u0628\u0627\n".repeat(100) + "End of paste"
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
await page.keyboard.press("ControlOrMeta+V")
await expect.poll(() => input.innerText()).toBe("B" + text + "efore " + suffix)
await expectCaretVisible(input)
await page.keyboard.type("!")
await expect.poll(() => input.innerText()).toBe("B" + text + "!efore " + suffix)
await expectCaretVisible(input)
})
}
}
for (const text of [
"single line <b> &amp;",
"first\nsecond",
@@ -123,7 +123,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessionID, tabKey }) => {
({ directory, server, sessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -131,8 +131,10 @@ async function setup(page: Page) {
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -142,6 +144,6 @@ async function setup(page: Page) {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
{ directory, server, sessionID },
)
}
@@ -49,8 +49,8 @@ test("renames, exports, and deletes a home session from its context menu", async
await row.click({ button: "right", position: { x: 48, y: 12 } })
await expect(page).toHaveURL("/")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
@@ -88,13 +88,13 @@ test("renames, exports, and deletes a home session from its context menu", async
await renamedRow.click({ button: "right" })
const download = page.waitForEvent("download")
const exportItem = page.getByRole("menuitem", { name: "Export" })
const exportItem = page.getByRole("menuitem", { name: "Export..." })
await exportItem.click()
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
await expect(exportItem).toBeHidden()
await renamedRow.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete" }).click()
await page.getByRole("menuitem", { name: "Delete..." }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
const removed = page.waitForRequest(
@@ -1,75 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("inactive tabs load attention, but read transcript and inbox only on selection", async ({ page }) => {
const reads: string[] = []
const mutations: string[] = []
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
page.on("response", (response) => {
if (new URL(response.url()).pathname.startsWith("/api/") && !response.ok())
errors.push(`HTTP ${response.status()}: ${response.url()}`)
})
const state = { text: "Original fixture answer" }
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (!path.startsWith("/api/")) return
if (request.method() === "GET") reads.push(path)
if (request.method() === "DELETE" || /\/(interrupt|prompt)$/.test(path)) mutations.push(path)
})
await mockOpenCodeServer(page, {
...fixture,
pageMessages: (id) => ({
items: [
{ id: `msg_${id}_user`, type: "user", text: "Review the renderer change", time: { created: 1 } },
{
id: `msg_${id}_assistant`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
content: [{ type: "text", text: state.text }],
time: { created: 2, completed: 3 },
},
],
}),
})
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID] })
const attention = Promise.all(
[fixture.targetID, fixture.childID].flatMap((id) =>
["permission", "form"].map((kind) =>
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${id}/${kind}`),
),
),
)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.sourceID}_assistant:text:0"]`)).toContainText(
state.text,
)
await attention
const child = page
.locator("[data-titlebar-tab-slot]")
.filter({ has: page.locator(`a[href="${stressSessionHref(fixture.childID)}"]`) })
await child.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(child).toHaveCount(0)
state.text = "Latest fixture answer after tab restoration"
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
await expectSessionTitle(page, fixture.expected.targetTitle)
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.targetID}_assistant:text:0"]`)).toContainText(
state.text,
)
for (const id of [fixture.sourceID, fixture.targetID]) {
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
}
expect(
reads.filter(
(path) => path === `/api/session/${fixture.childID}/message` || path === `/api/session/${fixture.childID}/inbox`,
),
).toEqual([])
expect(mutations).toEqual([])
expect(errors).toEqual([])
})
@@ -1,110 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile files browse, search, switch, and close shared file tabs in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: (path) =>
path
? []
: ["first.ts", "second.ts"].map((name) => ({
name,
path: name,
absolute: `${fixture.directory}/${name}`,
type: "file",
ignored: false,
})),
fileContent: (path) => `contents:${path}`,
findFiles: ({ query }) => ["first.ts", "second.ts"].filter((path) => path.includes(query)),
})
await page.goto(stressSessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
const files = page.locator('[data-slot="session-mobile-files"]')
await expect(files.getByRole("button", { name: "first.ts", exact: true })).toBeVisible()
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expect(files.locator('[data-slot="session-mobile-files-header"]')).toHaveCSS("border-bottom-width", "0px")
await expect
.poll(() =>
files.getByRole("tablist", { name: "Open files", exact: true }).evaluate((element) => {
const header = element.closest('[data-slot="session-mobile-files-header"]')!
const separator = getComputedStyle(element, "::before")
return {
height: separator.height,
fullWidth: parseFloat(separator.width) === header.clientWidth,
start: separator.insetInlineStart,
bottom: separator.bottom,
}
}),
)
.toEqual({ height: "1px", fullWidth: true, start: "0px", bottom: "0px" })
await files.getByRole("button", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files.locator('[data-column-number="1"]').click()
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
await expect(editor.getByRole("textbox")).toBeVisible()
for (const width of [390, 700]) {
await page.setViewportSize({ width, height: 844 })
await expect
.poll(async () => {
const panel = await files.boundingBox()
const comment = await editor.boundingBox()
if (!panel || !comment) return false
return Math.abs(comment.x - panel.x - 12) < 2 && Math.abs(comment.width - panel.width + 24) < 2
})
.toBe(true)
}
await editor.getByRole("textbox").fill("Full-width file comment")
await editor.getByRole("button", { name: "Comment", exact: true }).click()
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
await expect(comment).toContainText("Full-width file comment")
await expect
.poll(async () => {
const panel = await files.boundingBox()
const card = await comment.boundingBox()
if (!panel || !card) return false
return Math.abs(card.x - panel.x - 12) < 2 && Math.abs(card.width - panel.width + 24) < 2
})
.toBe(true)
await page.setViewportSize({ width: 390, height: 844 })
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeHidden()
await files.getByRole("button", { name: "All files", exact: true }).click()
await files.getByRole("combobox", { name: "Filter files", exact: true }).fill("second")
await files.getByRole("option", { name: "second.ts", exact: true }).click()
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
const openTabs = files.getByRole("tablist", { name: "Open files", exact: true })
await expect(openTabs.getByRole("tab")).toHaveText(["first.ts", "second.ts"])
await expect
.poll(() =>
openTabs.getByRole("tab", { name: "second.ts", exact: true }).evaluate((element) => {
const tab = element.closest('[data-slot="tabs-v2-trigger-wrapper"]')!
const header = element.closest('[data-slot="session-mobile-files-header"]')!
return Math.abs(tab.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom) < 1
}),
)
.toBe(true)
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
.filter({ has: page.getByRole("tab", { name: "first.ts", exact: true }) })
.getByRole("button", { name: "Close tab", exact: true })
.click()
await expect(openTabs.getByRole("tab")).toHaveText(["second.ts"])
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
await files.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(openTabs.getByRole("tab")).toHaveCount(0)
})
}
@@ -1,43 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test("opening changed files selects the requested tab before file loading completes", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
const loading = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: () => [],
fileContent: async (path) => {
if (path === "second.ts") await loading.promise
return `contents:${path}`
},
vcsDiff: ["first.ts", "second.ts"].map((file) => ({
file,
status: "modified",
additions: 1,
deletions: 1,
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-before\n+after\n`,
})),
})
await page.goto(stressSessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
const files = page.locator('[data-slot="session-mobile-files"]')
for (const file of ["first.ts", "second.ts", "first.ts", "second.ts"]) {
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
const diff = page.locator(`[data-component="session-review"] [data-file="${file}"]`)
const header = diff.getByRole("button", { name: file, exact: true })
await expect(header).toBeEnabled()
if ((await header.getAttribute("aria-expanded")) === "false") await header.click()
await diff.getByRole("button", { name: "Open file", exact: true }).click()
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(files.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
if (file === "second.ts") loading.resolve()
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
}
})
@@ -1,138 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile change summaries appear only for expanded diffs in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page, {
vcsDiff: [
{
file: "added.ts",
status: "added",
additions: 1,
deletions: 0,
patch:
"diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+export const added = 1\n",
},
{
file: "removed.ts",
status: "deleted",
additions: 0,
deletions: 1,
patch:
"diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const removed = 1\n",
},
{
file: "modified.ts",
status: "modified",
additions: 1,
deletions: 1,
patch: `diff --git a/modified.ts b/modified.ts\n--- a/modified.ts\n+++ b/modified.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = "${"long content ".repeat(30)}"\n`,
},
],
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", exact: true }).click()
const review = page.locator('[data-component="session-review"]')
await expect(review.getByRole("button", { name: "Expand all", exact: true })).toBeVisible()
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expect(review.locator('[data-file="added.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
"border-top-width",
"0px",
)
await expect(review.locator('[data-file="modified.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
"border-bottom-width",
"0px",
)
for (const change of [
{ file: "added.ts", status: "Added", additions: "+1", deletions: "-0" },
{ file: "removed.ts", status: "Removed", additions: "+0", deletions: "-1" },
{ file: "modified.ts", status: undefined, additions: "+1", deletions: "-1" },
]) {
const item = review.locator(`[data-file="${change.file}"]`)
const trigger = item.getByRole("button", { name: change.file, exact: true })
const summary = item.locator('[data-slot="session-review-change-summary"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(trigger.locator('[data-component="diff-changes"]')).toHaveCount(0)
await expect(trigger.locator('[data-slot="session-review-change"]')).toHaveCount(0)
await expect(summary).toHaveCount(0)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
if (change.file === "modified.ts") {
await expect(item.locator('[data-slot="accordion-content"]')).toHaveCSS("border-bottom-width", "0px")
await expect(trigger).not.toHaveCSS("border-bottom-width", "0px")
}
await expect(summary).toBeVisible()
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCount(0)
}
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
const modified = review.locator('[data-file="modified.ts"]')
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
await expect(modified.locator("[data-line-number-content]")).toHaveText(["1", "1"])
await expect(modified.locator("[data-diff]")).not.toHaveAttribute("data-disable-line-numbers")
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
await expect(review.getByRole("button", { name: "Diff options", exact: true })).toHaveCount(0)
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
await expect(wrap).toBeChecked()
await wrapControl.click()
await expect(wrap).not.toBeChecked()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(false)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
.toBe(true)
await modified.locator("[data-code]").evaluate((element) => {
element.scrollLeft = 100
})
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => Math.abs(element.scrollLeft)))
.toBeGreaterThan(0)
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await page.keyboard.press("Control+,")
await expect(wrap).not.toBeChecked()
await wrapControl.click()
await expect(wrap).toBeChecked()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(true)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
await expect(openFile).toBeVisible()
await expect
.poll(async () => {
const button = await openFile.boundingBox()
const summary = await modified.locator('[data-slot="session-review-change-summary"]').boundingBox()
if (!button || !summary) return false
return direction === "ltr"
? button.x > summary.x + summary.width / 2
: button.x + button.width < summary.x + summary.width / 2
})
.toBe(true)
await openFile.click()
await expect(
page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Files", exact: true }),
).toHaveAttribute("aria-selected", "true")
const files = page.locator('[data-slot="session-mobile-files"]')
await expect(files.getByRole("tab", { name: "modified.ts", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(files).toHaveAttribute("data-browsing", "false")
})
}
@@ -1,184 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const position of ["top", "bottom"] as const) {
test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: () => [],
})
await installStressSessionTabs(page)
await page.addInitScript(
(position) =>
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: position } })),
position,
)
await page.route("**/api/pty*", (route) =>
route.fulfill({
json: {
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
data: {
id: "pty_mobile_views",
title: "Terminal 1",
command: "sh",
args: [],
cwd: fixture.directory,
status: "running",
pid: 1,
},
},
}),
)
await page.routeWebSocket("**/api/pty/pty_mobile_views/connect", () => undefined)
await page.route("**/api/pty/pty_mobile_views/connect-token*", (route) =>
route.fulfill({
json: {
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
},
}),
)
await page.goto(stressSessionHref(fixture.targetID))
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = navigation.getByRole("button", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(message).toBeVisible()
await expect(composer).toBeVisible()
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
})
.toBe(true)
await expect
.poll(async () => {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
await expect(page.locator("[data-session-title]")).toHaveCount(0)
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
await expect(drawer).toHaveAttribute("data-open", "")
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await page.getByRole("button", { name: "Back to app", exact: true }).click()
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(picker).toHaveCount(0)
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
const usage = page.locator('[data-slot="session-usage-content"]')
await expect(usage).toHaveCSS("padding-top", "16px")
await expect(usage).toHaveCSS("padding-inline-start", "16px")
await expect(usage).toHaveCSS("padding-inline-end", "16px")
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
const panel = page.locator("#terminal-panel")
await expect(panel).toHaveAttribute("data-opened", "true")
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
await expect(panel.locator('[data-component="terminal"]')).toBeVisible()
await expect(panel.locator("textarea")).toBeEditable()
await expect(panel).toHaveCount(1)
await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
await expect(message).toBeVisible()
await expect(panel).toBeHidden()
await expect(panel).toHaveAttribute("inert", "")
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(panel).toBeVisible()
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Session")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(picker).toHaveText("Session")
await expect(panel).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
await page.goto(stressSessionHref(fixture.sourceID))
await expect(picker).toHaveText("Session")
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.sourceTitle)
await page.setViewportSize({ width: 1280, height: 900 })
await expect(picker).toBeHidden()
await expect(page.locator("[data-session-title]")).toBeVisible()
})
}
@@ -1,92 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
)
},
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
)
await page.goto("/")
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
test("mobile project selection and drawer navigation preserve session identity", async ({ page }) => {
const projects = page.getByRole("button", { name: "Projects", exact: true })
await projects.click()
const picker = page.getByRole("dialog", { name: "Projects", exact: true })
await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible()
await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click()
await expect(picker).toBeHidden()
await expect(projects).toContainText(fixture.project.name)
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveText([fixture.project.name, fixture.project.name])
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
const help = drawer.getByRole("button", { name: "Help", exact: true })
await expect(settings).toBeVisible()
await expect(help).toBeVisible()
expect((await settings.boundingBox())?.width).toBeGreaterThan((await help.boundingBox())?.width ?? 0)
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle }).click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await expect(drawer).toBeHidden()
await expect(trigger).toContainText(fixture.expected.sourceTitle)
await trigger.click()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(drawer).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
test("mobile settings section menu stays above a full-width panel", async ({ page }) => {
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await page.locator('[data-slot="mobile-tabs-drawer"]').getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
const panel = settings.getByRole("tabpanel")
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeHidden()
await expect(settings).toBeVisible()
await menu.click()
await page.getByRole("menuitemradio", { name: "Models", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("switch", { name: "Claude Opus 4.6", exact: true })).toBeAttached()
await expect
.poll(async () => {
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
const content = await panel.boundingBox()
return !!navigation && !!content && navigation.y + navigation.height <= content.y
})
.toBe(true)
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -1,37 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
await page.mouse.down()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 + 1)
await page.mouse.move(bounds!.x + bounds!.width / 2, 843)
await page.mouse.up()
}
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
await expect(overlay).toHaveCount(0)
await expect(more).toBeFocused()
}
})
@@ -42,7 +42,7 @@ test("selects a base branch for a new workspace", async ({ page }) => {
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
@@ -9,17 +9,12 @@ const projectID = "proj_workspace_pending"
const draftID = "draft_workspace_pending"
const otherID = "ses_workspace_pending_other"
const text = "Create the workspace, then explain the pending session."
const followUp = "Then explain the setup scripts.\nInclude the install command."
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionPath = `/server/${base64Encode(server)}/session/`
const draftPath = `/new-session?draftId=${draftID}`
const headers = { "access-control-allow-origin": "*" }
test.use({
serviceWorkers: "block",
viewport: { width: 1280, height: 900 },
permissions: ["clipboard-read", "clipboard-write"],
})
test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
@@ -29,44 +24,22 @@ for (const viewport of [
await page.setViewportSize(viewport)
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
const editor = page.locator('[data-component="composer-editor"]')
await draftFollowUp(page)
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await editor.press("Enter")
await editor.press("ControlOrMeta+Enter")
await page.locator('[data-component="composer"]').dispatchEvent("submit")
await expect(editor).toHaveText(followUp)
await expect(editor).toBeInViewport()
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await expect(pending.title).toBeInViewport()
const spinner = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
)
await expect(spinner).toBeVisible()
await testInfo.attach("creating-worktree", {
body: await page.screenshot({ path: testInfo.outputPath(`pending-${viewport.name}.png`) }),
contentType: "image/png",
})
if (viewport.name === "mobile") {
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await expect(tab.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await tab.click()
await expect(drawer).toBeHidden()
await page.locator("html").evaluate((element) => {
element.setAttribute("dir", "rtl")
element.dir = "rtl"
})
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await expect(pending.title).toBeInViewport()
await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl")
expect(
await page
@@ -80,14 +53,11 @@ for (const viewport of [
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(pending.shimmer).toBeHidden()
await expect(spinner).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
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(editor).toHaveText(followUp)
expect(mock.calls).toEqual(["worktree"])
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
@@ -118,133 +88,12 @@ for (const viewport of [
await expect(page).toHaveURL(pending.url)
await expect(pending.shimmer).toHaveCount(0)
await expect(spinner).toHaveCount(1)
await expect(pending.message).toHaveCount(1)
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(editor).toHaveText(followUp)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
expect(mock.prompts).toHaveLength(1)
await page.locator('[data-action="composer-submit"]').click()
await expect
.poll(() => mock.prompts)
.toEqual([
{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) },
{ sessionID: pending.sessionID, body: expect.objectContaining({ text: followUp }) },
])
})
}
for (const direction of ["ltr", "rtl"]) {
test(`keeps the title and message stable through worktree creation in ${direction}`, async ({ page }) => {
const mock = await openDraft(page)
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
const pending = await submitPending(page, mock)
await draftFollowUp(page)
await page.locator('[data-component="composer-editor"]').press("ControlOrMeta+Home")
const title = page.locator("[data-session-title]").getByRole("heading", { level: 1 })
const before = await title.boundingBox()
const messageBefore = await pending.message.boundingBox()
// Observe painted frames during the handoff, without using frame counts to wait for readiness.
const observation = await page.evaluateHandle(() => {
const frames: { title: string | null; message: boolean; spinner: boolean; draft: string | null }[] = []
let frame = 0
const sample = () => {
const title = document.querySelector<HTMLElement>("[data-session-title] h1")
const message = document.querySelector<HTMLElement>('[data-component="user-message"]')
const spinner = document.querySelector(
'[data-titlebar-tab-slot][data-active="true"] [data-component="session-progress-indicator-v2"]',
)
const editor = document.querySelector('[data-component="composer-editor"]')
frames.push({
title: title?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? title.textContent : null,
message: !!message?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
spinner: !!spinner?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
draft: editor?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? editor.textContent : null,
})
frame = requestAnimationFrame(sample)
}
sample()
return {
stop: () => {
cancelAnimationFrame(frame)
return frames
},
}
})
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(title).toHaveText("Created workspace session")
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
const frames = await observation.evaluate((observation) => observation.stop())
await observation.dispose()
expect(frames.length).toBeGreaterThan(0)
expect(
frames.filter(
(frame) =>
!frame.message ||
!frame.spinner ||
frame.draft !== followUp ||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
),
).toEqual([])
const after = await title.boundingBox()
const messageAfter = await pending.message.boundingBox()
expect(after?.y).toBe(before?.y)
expect(after?.height).toBe(before?.height)
expect(messageAfter).toEqual(messageBefore)
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
})
}
for (const failure of ["worktree", "session"]) {
test(`preserves both inputs when ${failure} creation fails`, async ({ page }) => {
const mock = await openDraft(page, { failSessionCreate: failure === "session" })
const pending = await submitPending(page, mock)
await draftFollowUp(page)
mock.worktree.resolve(
failure === "worktree"
? { status: 500, json: { message: "Worktree creation failed" } }
: { status: 200, json: { directory: workspace } },
)
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(pending.shimmer).toHaveCount(0)
expect(mock.prompts).toEqual([])
})
}
test("preserves both inputs when the initial prompt cannot be sent", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
await draftFollowUp(page)
await page.route(`**/api/session/${pending.sessionID}/prompt`, (route) =>
route.fulfill({
status: 500,
json: { message: "Prompt admission failed" },
headers,
}),
)
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt", "prompt"])
expect(mock.prompts).toEqual([])
})
test("restores the original draft when worktree creation fails", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
@@ -255,7 +104,7 @@ test("restores the original draft when worktree creation fails", async ({ page }
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(0)
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toHaveCount(0)
@@ -295,7 +144,6 @@ test("retains the draft and reuses the created workspace after session creation
test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
await draftFollowUp(page)
const tab = page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click()
@@ -316,16 +164,15 @@ test("restores the draft after closing and revisiting a pending session that fai
await expect(pending.shimmer).toBeVisible()
await expect(pending.shimmer).toContainText("Creating worktree")
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
expect(mock.calls).toEqual(["worktree"])
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } })
await expect(page).toHaveURL(draftPath)
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator(`[data-titlebar-tab-link][href="${draftPath}"]`)).toHaveCount(1)
await expect(tab).toHaveCount(0)
await expect(pending.shimmer).toHaveCount(0)
@@ -335,17 +182,6 @@ test("restores the draft after closing and revisiting a pending session that fai
expect(mock.prompts).toEqual([])
})
async function draftFollowUp(page: Page) {
const editor = page.locator('[data-component="composer-editor"]')
await editor.pressSequentially("!")
await expect(editor).toHaveText("!")
await expect(editor).toHaveAttribute("dir", "auto")
await editor.fill("")
await page.evaluate((text) => navigator.clipboard.writeText(text), followUp)
await editor.press("ControlOrMeta+V")
await expect(editor).toHaveText(followUp)
}
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
@@ -418,24 +254,6 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
headers,
})
})
await page.route("**/api/agent?**", (route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
},
headers,
}),
)
await page.addInitScript(
({ directory, draftID, otherID, server }) => {
localStorage.setItem(
@@ -458,8 +276,8 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
await page.goto(draftPath)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, worktreeRequests, calls, creates, prompts }
}
@@ -474,11 +292,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
const preparing = page.locator('[data-component="session-preparing"]')
const message = page.locator('[data-component="user-message"]')
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(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)
await expect(message).toHaveCount(1)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
@@ -490,5 +304,5 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
await expect.poll(() => mock.calls).toEqual(["worktree"])
expect(mock.creates).toEqual([])
expect(mock.prompts).toEqual([])
return { url, sessionID, messageID, message, shimmer, title }
return { url, sessionID, messageID, message, shimmer }
}
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
})
await page.addInitScript(
({ directory, server, sessionID, tabKey }) => {
({ directory, server, sessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -88,8 +88,10 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -99,7 +101,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -0,0 +1,103 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/extensions-demo"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const session = {
id: "ses_project_extensions",
title: "Existing session",
directory,
projectID: "proj_extensions_demo",
time: { created: 1700000000000, updated: 1700000000000 },
}
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test("project Extensions stays inside settings while plugins load", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: session.projectID,
canonical: directory,
name: "Extensions demo",
vcs: "git",
time: session.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: session.id, directory },
)
const href = `/server/${base64Encode(server)}/session/${session.id}`
await page.goto(href)
await expect(page.getByRole("heading", { name: session.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByText("Extensions demo", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused()
const globalPlugins = Promise.withResolvers<void>()
const projectPlugins = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
const project = new URL(route.request().url()).searchParams.get("location[directory]")
await (project ? projectPlugins : globalPlugins).promise
await route.fulfill({
json: {
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
status: "active",
tui: false,
})),
},
})
},
)
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(page).toHaveURL(href)
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings).toBeVisible()
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
globalPlugins.resolve()
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
projectPlugins.resolve()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
await expect(page).toHaveURL(href)
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
})
@@ -6,9 +6,8 @@ const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${p
const serverB = "http://127.0.0.1:4097"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id }
test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => {
test("tab busy indicator reflects the tab server's own session status", async ({ page }) => {
await mockServers(page)
await page.addInitScript(
({ serverA, serverB, sessionA, sessionB }) => {
@@ -26,13 +25,13 @@ test("tab busy indicator reflects activity in the tab session family", async ({
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(hrefB)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.goto(hrefA)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
// The parent is idle, but its tab remains active while the background child runs.
// Session B is busy on server B while server A stays the active server, so the
// busy indicator must come from the tab server's status, not the active server's.
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await tabB.screenshot({ path: info.outputPath("subagent-tab-activity.png") })
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
@@ -61,12 +60,8 @@ async function mockServers(page: Page) {
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session")
return json(route, {
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
cursor: {},
})
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
@@ -89,7 +89,7 @@ async function openReview(page: Page) {
)
await changes.click()
expect((await (await diffResponse).json()).data).toHaveLength(1)
await expect(changes).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID, tabKey }) => {
({ directory, server, sessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -69,8 +69,10 @@ test("opens and searches project files inline", async ({ page }) => {
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
@@ -80,7 +82,7 @@ test("opens and searches project files inline", async ({ page }) => {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -93,18 +95,11 @@ test("opens and searches project files inline", async ({ page }) => {
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await panel.getByRole("button", { name: "Open file" }).click()
const openFileTab = panel.getByRole("tab", { name: "Open file" })
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
await expect(openFileTab).toHaveAttribute("data-selected", "")
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
await expect(openFileTabClose).toHaveCSS("opacity", "1")
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(openFileTabClose).toHaveCSS("opacity", "0")
await expect(sidebar).toBeHidden()
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })

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