mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 21:46:19 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4b9a2f29a | ||
|
|
797d51c828 | ||
|
|
4afd8e81be | ||
|
|
485bdc9c4e | ||
|
|
1a3aee39de | ||
|
|
2ddf257c6f | ||
|
|
ef50e0b6d8 | ||
|
|
583a1a2b6f | ||
|
|
68c1207b52 | ||
|
|
f77647ad12 | ||
|
|
c746ea3210 | ||
|
|
d323b34826 | ||
|
|
bf50194f99 | ||
|
|
4a977b2b31 | ||
|
|
b1e3a7b222 | ||
|
|
b12d43698e | ||
|
|
5cd2c22b27 | ||
|
|
c2ef80a287 | ||
|
|
4df3029536 | ||
|
|
e70d667a9f | ||
|
|
8ba434b597 | ||
|
|
171947787c | ||
|
|
106629aa11 | ||
|
|
3ee2e482ce | ||
|
|
849824efd2 | ||
|
|
cf2c3a536d | ||
|
|
7852cecd72 | ||
|
|
6cfffeb031 | ||
|
|
6e954f75ee | ||
|
|
0116a98371 | ||
|
|
a38cbd42aa | ||
|
|
4ab31867c4 | ||
|
|
51a082cea3 |
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- production
|
||||
- beta
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -15,7 +16,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.ref_name }}
|
||||
steps:
|
||||
@@ -28,6 +29,7 @@ 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 }}
|
||||
|
||||
@@ -430,6 +430,7 @@
|
||||
"@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",
|
||||
@@ -1678,7 +1679,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@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/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/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=="],
|
||||
|
||||
@@ -2774,7 +2775,7 @@
|
||||
|
||||
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-Y1XfSefHIOub9762qm3ShafdlEE/Va8h3kLUeMq765fNeWeNLcOP2YUPr86H1SlyGwZTOqQ67RlBZPZ3k9Djgg=="],
|
||||
|
||||
@@ -3532,7 +3533,7 @@
|
||||
|
||||
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
|
||||
|
||||
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
|
||||
|
||||
@@ -3812,7 +3813,7 @@
|
||||
|
||||
"dot-prop": ["dot-prop@8.0.2", "", { "dependencies": { "type-fest": "^3.8.0" } }, "sha512-xaBe6ZT4DHPkg0k4Ytbvn5xoxgpG0jOS1dYxSOwAHPuNLjP3/OzN0gH55SrLqpx8cBfSaVt91lXYkApjb+nYdQ=="],
|
||||
|
||||
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||
|
||||
@@ -3884,7 +3885,7 @@
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
|
||||
|
||||
@@ -4206,7 +4207,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@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
@@ -5812,7 +5813,7 @@
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
@@ -5960,6 +5961,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -6114,7 +6117,9 @@
|
||||
|
||||
"@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/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
"@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/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=="],
|
||||
|
||||
@@ -6150,6 +6155,12 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -6290,12 +6301,16 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -6368,14 +6383,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6386,6 +6393,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6430,6 +6439,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6456,14 +6467,16 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6510,7 +6523,7 @@
|
||||
|
||||
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"got/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
@@ -6556,10 +6569,10 @@
|
||||
|
||||
"nitro/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"npm-package-arg/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
@@ -6642,8 +6655,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6786,6 +6797,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -6908,6 +6921,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -7130,6 +7145,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -7234,14 +7251,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7260,6 +7269,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7394,6 +7405,10 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7402,6 +7417,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7450,6 +7467,12 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7476,6 +7499,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -8154,8 +8179,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
+2
-9
@@ -1,4 +1,5 @@
|
||||
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")
|
||||
@@ -59,12 +60,4 @@ new sst.cloudflare.x.Astro("Web", {
|
||||
},
|
||||
})
|
||||
|
||||
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",
|
||||
},
|
||||
})
|
||||
createWebApp("app." + domain)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
|
||||
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
|
||||
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
|
||||
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
|
||||
"x86_64-linux": "sha256-YAKhbMPKeXAZLYgJzLRQi/fwIMjPOkVIvOWkG3mCgTE=",
|
||||
"aarch64-linux": "sha256-UnIhoAleUQXKP8E0pVG0OyEpugmKEhfh6fEvpkpN2QI=",
|
||||
"aarch64-darwin": "sha256-m68nJKpcVrX9R6EZqVvPKgRmZEXilJGcISMaQpN/k+Q=",
|
||||
"x86_64-darwin": "sha256-DEjeoEn10K5YdJJCWtLw7LtthXChUXc0Vniccl/6KIc="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,6 +831,7 @@ 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
|
||||
}
|
||||
@@ -840,7 +841,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
if (content.length > 0) messages.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -848,6 +849,7 @@ 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
|
||||
}
|
||||
@@ -891,7 +893,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
|
||||
)
|
||||
}
|
||||
messages.push({ role: "assistant", content })
|
||||
if (content.length > 0) messages.push({ role: "assistant", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1019,10 +1021,11 @@ 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 =
|
||||
request.system.length === 0
|
||||
systemParts.length === 0
|
||||
? undefined
|
||||
: request.system.map((part) => ({
|
||||
: systemParts.map((part) => ({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
@@ -1343,20 +1346,28 @@ 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: {
|
||||
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 }),
|
||||
},
|
||||
pendingFinish,
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
@@ -1367,7 +1378,17 @@ 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 finished = Lifecycle.finish(lifecycle, 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, {
|
||||
reason: state.pendingFinish?.reason ?? {
|
||||
normalized: "unknown",
|
||||
raw: undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -262,24 +262,28 @@ const providerMetadata = (key: string, metadata: Record<string, unknown>): Provi
|
||||
|
||||
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return (
|
||||
part.encrypted ??
|
||||
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
|
||||
)
|
||||
if (part.encrypted !== undefined) return part.encrypted
|
||||
if (ProviderShared.isRecord(metadata) && typeof metadata.signature === "string") return metadata.signature
|
||||
}
|
||||
|
||||
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
|
||||
? metadata.redactedData
|
||||
: undefined
|
||||
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)]])),
|
||||
)
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
toolUse: {
|
||||
toolUseId: part.id,
|
||||
name: part.name,
|
||||
input: part.input,
|
||||
input: removeEmptyToolInputKeys(part.input),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -414,7 +418,12 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
) => {
|
||||
const content = system
|
||||
.filter((part) => part.text.length > 0)
|
||||
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
return content.length === 0 ? undefined : content
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
@@ -422,38 +431,42 @@ 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 =
|
||||
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 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 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.`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
modelId: request.model.id,
|
||||
messages,
|
||||
system,
|
||||
inferenceConfig:
|
||||
const inferenceConfig = (() => {
|
||||
if (
|
||||
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,
|
||||
},
|
||||
)
|
||||
return undefined
|
||||
return {
|
||||
maxTokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
topP: generation?.topP,
|
||||
stopSequences: generation?.stop,
|
||||
}
|
||||
})()
|
||||
return {
|
||||
modelId: request.model.id,
|
||||
messages,
|
||||
system,
|
||||
inferenceConfig,
|
||||
toolConfig,
|
||||
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
|
||||
// as a model-specific field, so it goes through additionalModelRequestFields.
|
||||
@@ -469,7 +482,6 @@ 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"
|
||||
}
|
||||
|
||||
@@ -504,6 +516,16 @@ interface ParserState {
|
||||
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) =>
|
||||
@@ -551,23 +573,46 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||
const events: LLMEvent[] = []
|
||||
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
|
||||
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 }
|
||||
})()
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningSignatures: reasoning.signature
|
||||
? { ...state.reasoningSignatures, [index]: reasoning.signature }
|
||||
: state.reasoningSignatures,
|
||||
reasoningSignatures,
|
||||
reasoningRedactedContent,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
@@ -595,16 +640,24 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, index)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
})()
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
{
|
||||
@@ -618,12 +671,24 @@ 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,
|
||||
@@ -670,23 +735,22 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
|
||||
const framing = BedrockEventStream.framing(ADAPTER)
|
||||
|
||||
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
|
||||
})()
|
||||
: []
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
if (!state.pendingFinish) return []
|
||||
const normalized = (() => {
|
||||
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
|
||||
return state.pendingFinish.reason.normalized
|
||||
})()
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
...state.pendingFinish.reason,
|
||||
normalized,
|
||||
},
|
||||
usage: state.pendingFinish.usage,
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Protocol And Bedrock Route
|
||||
@@ -711,6 +775,7 @@ export const protocol = Protocol.make({
|
||||
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 } from "../schema/index.js"
|
||||
import { AIError, AIErrorReason, InvalidProviderOutputError } from "../schema/index.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
|
||||
@@ -22,6 +22,10 @@ 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.
|
||||
@@ -33,9 +37,23 @@ const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferSta
|
||||
return { buffer: next, offset: 0 }
|
||||
}
|
||||
|
||||
const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8Array) =>
|
||||
const consumeFrames = (route: string) => (state: FrameBufferState, input: FrameInput) =>
|
||||
Effect.gen(function* () {
|
||||
let cursor = appendChunk(state, chunk)
|
||||
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)
|
||||
const out: object[] = []
|
||||
while (cursor.buffer.length - cursor.offset >= 4) {
|
||||
const view = cursor.buffer.subarray(cursor.offset)
|
||||
@@ -113,7 +131,12 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
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.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
|
||||
frame: (bytes) =>
|
||||
bytes.pipe(
|
||||
Stream.map((bytes): FrameInput => ({ _tag: "Chunk", bytes })),
|
||||
Stream.concat(Stream.succeed(endOfStream)),
|
||||
Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route)),
|
||||
),
|
||||
})
|
||||
|
||||
export * as BedrockEventStream from "./bedrock-event-stream.js"
|
||||
|
||||
@@ -240,6 +240,10 @@ 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>
|
||||
}
|
||||
|
||||
@@ -571,19 +575,23 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
let lifecycle = state.lifecycle
|
||||
if (state.reasoningSignature !== undefined)
|
||||
if (state.reasoningId !== undefined)
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
state.reasoningId,
|
||||
state.reasoningSignature === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
if (state.textSignature !== undefined)
|
||||
if (state.textId !== undefined)
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
state.textId,
|
||||
state.textSignature === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
)
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
@@ -632,6 +640,10 @@ 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)
|
||||
|
||||
@@ -657,27 +669,51 @@ 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,
|
||||
"reasoning-0",
|
||||
reasoningId,
|
||||
part.text,
|
||||
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
|
||||
)
|
||||
continue
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
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.textDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
textId,
|
||||
part.text,
|
||||
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
|
||||
)
|
||||
@@ -695,14 +731,28 @@ 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("-", "")}`
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
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.stepStart(lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
@@ -725,6 +775,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
reasoningId,
|
||||
textId,
|
||||
nextReasoningId,
|
||||
nextTextId,
|
||||
seenCallIds,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
@@ -752,6 +806,8 @@ 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,6 +1,7 @@
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
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"
|
||||
@@ -577,10 +577,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension)),
|
||||
})
|
||||
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
|
||||
if (content.length > 0) input.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ 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"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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,6 +7,7 @@ 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"
|
||||
@@ -169,17 +170,19 @@ export interface GenerateMethod {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
const resolveRequestOptions = (request: LLMRequest) => {
|
||||
const routeDefaults = request.model.route.defaults
|
||||
const modelDefaults = request.model.defaults
|
||||
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
|
||||
return LLMRequest.update(request, {
|
||||
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, {
|
||||
generation: generation ?? new GenerationOptions({}),
|
||||
providerOptions: mergeProviderOptions(
|
||||
routeDefaults.providerOptions,
|
||||
modelDefaults?.providerOptions,
|
||||
request.providerOptions,
|
||||
normalized.providerOptions,
|
||||
),
|
||||
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
|
||||
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, normalized.http),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -446,7 +449,9 @@ 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 resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
|
||||
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 route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
appendMissingResults()
|
||||
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,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, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolDefinition, 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,6 +77,55 @@ 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({
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+47
File diff suppressed because one or more lines are too long
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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,6 +74,48 @@ 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(
|
||||
@@ -903,6 +945,54 @@ 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(
|
||||
@@ -936,6 +1026,7 @@ 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!" },
|
||||
@@ -949,6 +1040,41 @@ 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,11 +1,12 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Encoding, Ref, Stream } from "effect"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ToolCallPart,
|
||||
@@ -83,6 +84,17 @@ 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",
|
||||
@@ -113,6 +125,50 @@ 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(
|
||||
@@ -255,6 +311,97 @@ 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_1",
|
||||
content: [{ text: "Tool result missing" }],
|
||||
status: "error",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
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: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: ["tool_empty_key", "tool_empty_object"].map((toolUseId) => ({
|
||||
toolResult: { toolUseId, content: [{ text: "Tool result missing" }], status: "error" as const },
|
||||
})),
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -385,19 +532,77 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps truncation and malformed output stop reasons", () =>
|
||||
it.effect("rejects truncated event-stream frames after message stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["model_context_window_exceeded", "length"],
|
||||
["malformed_model_output", "error"],
|
||||
["malformed_tool_use", "error"],
|
||||
] as const
|
||||
const partialFrames = [
|
||||
eventFrame("metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }).subarray(0, 3),
|
||||
exceptionFrame("modelStreamErrorException", { originalMessage: "Upstream model failed" }).subarray(0, -1),
|
||||
]
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
|
||||
for (const partial of partialFrames) {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(concat([eventFrame("messageStop", { stopReason: "end_turn" }), partial]))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, 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()
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -678,6 +883,18 @@ describe("Bedrock Converse route", () => {
|
||||
{ toolUse: { toolUseId: "call_1", name: "lookup", input: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "call_1",
|
||||
content: [{ text: "Tool result missing" }],
|
||||
status: "error",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -853,7 +1070,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 = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const redactedData = "AQID"
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
@@ -863,10 +1080,8 @@ describe("Bedrock Converse route", () => {
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: redactedData } } },
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AgM=" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
[
|
||||
"contentBlockStart",
|
||||
@@ -882,12 +1097,17 @@ describe("Bedrock Converse route", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
|
||||
expect(response.events.filter((event) => event.type === "reasoning-delta" && event.text === "").at(-1)).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,
|
||||
@@ -918,6 +1138,73 @@ 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([
|
||||
|
||||
@@ -906,6 +906,94 @@ 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(
|
||||
@@ -1183,6 +1271,16 @@ describe("Gemini route", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: ["tool_0", "tool_1", "tool_2"].map((id) => ({
|
||||
functionResponse: {
|
||||
id,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "Tool result missing" },
|
||||
},
|
||||
})),
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1215,6 +1313,16 @@ describe("Gemini route", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: ["tool_0", "tool_1"].map((id) => ({
|
||||
functionResponse: {
|
||||
id,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "Tool result missing" },
|
||||
},
|
||||
})),
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1280,6 +1388,56 @@ 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(
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
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: "{}" } }],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "Ab12Cd34E", name: "lookup", content: "Tool result missing" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
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" } }))),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
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,
|
||||
)
|
||||
})
|
||||
@@ -1068,6 +1068,7 @@ describe("OpenAI Chat route", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: "Tool result missing", cache_control: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -96,6 +96,27 @@ 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({
|
||||
|
||||
@@ -3474,6 +3474,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "No prefix separator." }],
|
||||
encrypted_content: undefined,
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
@@ -3488,6 +3489,8 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
arguments: '{"query":"news"}',
|
||||
},
|
||||
{ type: "function_call_output", call_id: "call_1", output: "Tool result missing" },
|
||||
{ type: "function_call_output", call_id: "call_2", output: "Tool result missing" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -3789,6 +3792,7 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
{ type: "function_call_output", call_id: "call_1", output: "Tool result missing" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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 at step boundaries", () => {
|
||||
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",
|
||||
"tool",
|
||||
])
|
||||
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[5]?.content).toEqual([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "trailing",
|
||||
name: "trailing",
|
||||
result: { type: "error", value: "Tool result missing" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
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])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
src/assets/theme.css
|
||||
e2e/test-results
|
||||
e2e/performance/results/
|
||||
e2e/playwright-report
|
||||
component-tests/test-results
|
||||
component-tests/playwright-report
|
||||
|
||||
+22
-1
@@ -71,4 +71,25 @@ Environment options:
|
||||
|
||||
## Deployment
|
||||
|
||||
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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("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!)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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, 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.
|
||||
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.
|
||||
|
||||
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,13 +81,60 @@ 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=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.
|
||||
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 rendered in that fresh browser context. Warm means its complex answer was rendered and ready before switching away and back. Both use the app's normal restored-tab data prefetch, which completes before measurement; neither includes app startup, the source session's Markdown engine initialization, or a cold backend fetch. The suite asserts no message fetch during either measured switch. 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`.
|
||||
|
||||
```sh
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
|
||||
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --retries=0
|
||||
```
|
||||
|
||||
**The tab-switch fixture is not an end-to-end cold-data benchmark.** It prefetches destination messages and returns full history. Measure cold API navigation, Home-row opening, and prefetched-but-unvisited tabs separately with normal pagination. Do not combine these entry paths or compare different transports and machine-load periods as one experiment.
|
||||
|
||||
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 prefetched 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:
|
||||
@@ -111,7 +158,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 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.
|
||||
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.
|
||||
|
||||
```sh
|
||||
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
|
||||
|
||||
@@ -5,16 +5,20 @@ 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 = {}) => {
|
||||
@@ -49,9 +53,9 @@ export const benchmark = base.extend<BenchmarkFixtures>({
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
page: async ({ page }, use, testInfo) => {
|
||||
page: async ({ page, traceScope }, use, testInfo) => {
|
||||
const name = benchmarkName(testInfo)
|
||||
const diagnostics = await observePerformancePage(page, name)
|
||||
const diagnostics = await observePerformancePage(page, name, traceScope)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
@@ -75,25 +79,30 @@ function benchmarkName(testInfo: TestInfo) {
|
||||
|
||||
export { expect }
|
||||
|
||||
async function observePerformancePage(page: Page, name: string) {
|
||||
async function observePerformancePage(page: Page, name: string, traceScope: "page" | "interaction" = "page") {
|
||||
const navigations: string[] = []
|
||||
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
|
||||
if (frame === page.mainFrame()) navigations.push(frame.url())
|
||||
}
|
||||
page.on("framenavigated", onNavigation)
|
||||
const stopTrace = await startChromeTrace(page, name).catch((error) => {
|
||||
page.off("framenavigated", onNavigation)
|
||||
throw error
|
||||
})
|
||||
let stopTrace: Awaited<ReturnType<typeof startChromeTrace>>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -130,6 +139,7 @@ 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,
|
||||
|
||||
@@ -14,7 +14,6 @@ const categories = [
|
||||
"blink.console",
|
||||
"blink.user_timing",
|
||||
"latencyInfo",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
]
|
||||
|
||||
@@ -34,6 +33,9 @@ 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"]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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)
|
||||
@@ -0,0 +1,33 @@
|
||||
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 }>
|
||||
milestones: Record<string, { selector: string; visible?: boolean; text?: string }>
|
||||
navigate: () => Promise<void>
|
||||
},
|
||||
) {
|
||||
@@ -47,11 +47,19 @@ export async function measureNavigationMilestones(
|
||||
const marked = new Set<string>()
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const visible = (selector: string) =>
|
||||
const visible = (selector: string, text?: 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()
|
||||
const style = getComputedStyle(element)
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > 0 &&
|
||||
rect.top < innerHeight &&
|
||||
rect.right > 0 &&
|
||||
rect.left < innerWidth
|
||||
)
|
||||
})
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
@@ -61,7 +69,9 @@ 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.visible === false
|
||||
? !document.querySelector(milestone.selector)
|
||||
: visible(milestone.selector, milestone.text),
|
||||
]),
|
||||
)
|
||||
samples.push({
|
||||
@@ -93,36 +103,46 @@ export async function measureNavigationMilestones(
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
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 },
|
||||
)
|
||||
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)
|
||||
;(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 },
|
||||
)
|
||||
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 }
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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" })
|
||||
})
|
||||
@@ -1,71 +1,129 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
import { benchmark, benchmarkDiagnostics, expect } 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 = [
|
||||
{ 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 },
|
||||
]
|
||||
{ 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" })
|
||||
|
||||
scenarios.forEach((scenario) => {
|
||||
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)!)
|
||||
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
|
||||
|
||||
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),
|
||||
})
|
||||
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)
|
||||
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: "prefetched",
|
||||
transport: process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE === "1" ? "http" : "playwright-route",
|
||||
inputEvent: "mousedown",
|
||||
requireReadyAnswer: true,
|
||||
},
|
||||
testInfo,
|
||||
)
|
||||
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 })
|
||||
if (testInfo.repeatEachIndex === 0) {
|
||||
await page.screenshot({ path: testInfo.outputPath("destination.png") })
|
||||
await testInfo.attach("destination", { path: testInfo.outputPath("destination.png"), contentType: "image/png" })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
// Restored tabs prefetch their data even when their transcript has never rendered.
|
||||
const prefetch = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
expect(await (await prefetch).finished()).toBeNull()
|
||||
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.toSorted()).toEqual([fixture.sourceID, fixture.targetID].toSorted())
|
||||
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)
|
||||
@@ -80,4 +138,20 @@ 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,66 +2,77 @@ import { benchmark, expect } from "../benchmark"
|
||||
import { measureSessionSwitch } from "./session-tab-switch-probe"
|
||||
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
|
||||
|
||||
benchmark("starts at mousedown and excludes hidden or unfinished destination content", async ({ page, report }) => {
|
||||
await page.setContent(`
|
||||
<a href="/session/destination">Destination</a>
|
||||
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}>
|
||||
<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("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)
|
||||
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.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"
|
||||
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"
|
||||
})
|
||||
},
|
||||
})
|
||||
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,124 +14,132 @@ async function installSessionSwitchProbe(
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
triggerSelector?: string
|
||||
href: string
|
||||
},
|
||||
) {
|
||||
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(() => {
|
||||
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 = () => {
|
||||
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 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)
|
||||
})
|
||||
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 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,
|
||||
})
|
||||
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 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 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForStableSessionSwitch(page: Page) {
|
||||
@@ -159,9 +167,17 @@ 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
|
||||
})
|
||||
return classifySessionSwitch(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
|
||||
}
|
||||
|
||||
export async function measureSessionSwitch(
|
||||
@@ -172,6 +188,7 @@ export async function measureSessionSwitch(
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
triggerSelector?: string
|
||||
href: string
|
||||
switch: () => Promise<void>
|
||||
},
|
||||
@@ -185,6 +202,7 @@ export async function measureSessionSwitch(
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
|
||||
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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}.**
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("keeps history visible and recovers the composer by choosing another directory", async ({ page }) => {
|
||||
const directory = "/projects/deleted"
|
||||
const destination = "/projects/restored"
|
||||
const sessionID = "ses_missing_location"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
|
||||
const events: OpenCodeEvent[] = []
|
||||
const moves: unknown[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: destination,
|
||||
project: { ...fixture.project, worktree: destination },
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
events: () => events.splice(0),
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route("**/api/**", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
|
||||
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("status")).toContainText("Session location unavailable")
|
||||
await expect(page.getByRole("status")).toContainText(directory)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
const choose = page.getByRole("button", { name: "Choose directory", exact: true })
|
||||
await expect(choose).toBeEnabled()
|
||||
await choose.click()
|
||||
const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true })
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(choose).toBeFocused()
|
||||
expect(moves).toEqual([])
|
||||
|
||||
// The server can still reject a destination after it was selected in the picker.
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { _tag: "InvalidRequestError", message: "Destination is unavailable" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await choose.press("Enter")
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible()
|
||||
await expect(choose).toBeEnabled()
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
await expect(choose).toBeFocused()
|
||||
expect(moves).toEqual([{ directory: destination }])
|
||||
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
session.directory = destination
|
||||
events.push({
|
||||
id: "evt_location_recovered",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await choose.click()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toBeVisible()
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
|
||||
expect(moves).toEqual([{ directory: destination }, { directory: destination }])
|
||||
})
|
||||
|
||||
for (const create of [false, true]) {
|
||||
test(`recovers into ${create ? "a new" : "an existing"} worktree without resolving the deleted location`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const canonical = "/projects/repository"
|
||||
const directory = "/projects/deleted-worktree"
|
||||
const destination = create ? "/projects/new-worktree" : "/projects/existing-worktree"
|
||||
const sessionID = "ses_worktree_recovery"
|
||||
const events: OpenCodeEvent[] = []
|
||||
const requests: { operation: string; body: unknown }[] = []
|
||||
let listing = Promise.withResolvers<void>()
|
||||
let listingRequested = Promise.withResolvers<void>()
|
||||
const moving = Promise.withResolvers<void>()
|
||||
const moveRequested = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: canonical,
|
||||
project: { ...fixture.project, worktree: canonical },
|
||||
provider: fixture.provider,
|
||||
sessions: [{ id: sessionID, projectID: fixture.project.id, directory }],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_worktree", type: "user", text: "Recover my worktree", time: { created: 1 } }],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
if (url.searchParams.get("location[directory]") === directory)
|
||||
return route.fulfill({ status: 500, body: "", headers })
|
||||
if (url.pathname === `/api/worktree/${fixture.project.id}`) {
|
||||
if (route.request().method() === "GET") {
|
||||
listingRequested.resolve()
|
||||
await listing.promise
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ directory: canonical, strategy: null },
|
||||
{ directory: "/projects/existing-worktree", strategy: "git" },
|
||||
],
|
||||
headers,
|
||||
})
|
||||
}
|
||||
if (route.request().method() === "POST") {
|
||||
requests.push({ operation: "create", body: route.request().postDataJSON() })
|
||||
return route.fulfill({
|
||||
json: { directory: destination, name: "new-worktree", branch: "new-worktree" },
|
||||
headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (url.pathname === `/api/session/${sessionID}/move` && route.request().method() === "POST") {
|
||||
requests.push({ operation: "move", body: route.request().postDataJSON() })
|
||||
moveRequested.resolve()
|
||||
await moving.promise
|
||||
events.push({
|
||||
id: "evt_worktree_recovered",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
return route.fulfill({ status: 204, headers })
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await listingRequested.promise
|
||||
await expect(page.getByRole("menuitem", { name: "Loading", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menu")).toHaveCount(0)
|
||||
listing = Promise.withResolvers<void>()
|
||||
listingRequested = Promise.withResolvers<void>()
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await listingRequested.promise
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
await page.getByRole("menuitem", { name: create ? "New workspace" : "existing-worktree", exact: true }).click()
|
||||
await moveRequested.promise
|
||||
await expect(page.getByRole("button", { name: "Moving session…", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Choose worktree", exact: true })).toBeDisabled()
|
||||
moving.resolve()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
expect(requests).toEqual([
|
||||
...(create ? [{ operation: "create", body: { strategy: "git", from: canonical, directory: "/projects/" } }] : []),
|
||||
{ operation: "move", body: { directory: destination } },
|
||||
])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
for (const window of ["assistant-only", "mixed"] as const) {
|
||||
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
|
||||
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
|
||||
// Both 20-message pages begin with an assistant; only page three supplies its parent.
|
||||
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
|
||||
const id = `msg_hydration_${index}`
|
||||
const time = { created: 1700000000000 + index * 1_000 }
|
||||
if (index === 0 || (window === "mixed" && index === 39))
|
||||
return { id, type: "user", time, text: `Prompt ${index}` }
|
||||
return {
|
||||
id,
|
||||
type: "assistant",
|
||||
time: { ...time, completed: time.created + 500 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
|
||||
}
|
||||
})
|
||||
const gates = [21, 1].map((index) => ({
|
||||
before: messages[index]!.id,
|
||||
parent: messages[index === 21 ? 1 : 0]!.id,
|
||||
requested: Promise.withResolvers<void>(),
|
||||
release: Promise.withResolvers<void>(),
|
||||
}))
|
||||
const requests: (string | undefined)[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions: [session],
|
||||
beforeMessagesResponse: async ({ before }) => {
|
||||
requests.push(before)
|
||||
if (!before) return
|
||||
const gate = gates.find((gate) => gate.before === before)
|
||||
if (!gate) throw new Error(`Unexpected older-page boundary: ${before}`)
|
||||
gate.requested.resolve()
|
||||
await gate.release.promise
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
expect(limit).toBe(20)
|
||||
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
|
||||
},
|
||||
})
|
||||
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
|
||||
const markdown = tail.locator('[data-component="markdown"]')
|
||||
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: tail })
|
||||
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
|
||||
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
|
||||
})
|
||||
const expectReadyTail = async () => {
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(markdown.getByRole("heading", { name: "Hydrated tail", exact: true })).toBeInViewport({ ratio: 1 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
viewport.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto(stressSessionHref(session.id))
|
||||
await gates[0]!.requested.promise
|
||||
// This must pass while the first older response is still held.
|
||||
await expectReadyTail()
|
||||
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
|
||||
if (window === "mixed")
|
||||
await expect(
|
||||
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
|
||||
).toBeInViewport()
|
||||
const original = await markdown.elementHandle()
|
||||
|
||||
for (const gate of gates) {
|
||||
await gate.requested.promise
|
||||
gate.release.resolve()
|
||||
// Parent ownership proves the page reached the projection, not just the network.
|
||||
await expect(orphan).toHaveAttribute("data-message-id", gate.parent)
|
||||
await expectReadyTail()
|
||||
expect(await markdown.evaluate((element, original) => element === original, original)).toBe(true)
|
||||
}
|
||||
expect(requests).toEqual([undefined, ...gates.map((gate) => gate.before)])
|
||||
const ids = await content
|
||||
.locator("[data-timeline-part-id]")
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-part-id")))
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
} finally {
|
||||
gates.forEach((gate) => gate.release.resolve())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { expected, messages } from "../performance/timeline/session-tab-switch.fixture"
|
||||
import { installTimelineSettings, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
type Reveal = { pending: number; clipped: string[]; bottomError: number; tables: number; codeBlocks: number }
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`reveals measured Markdown after the worker completes at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(/markdown\.worker(?:-[^/?]+\.js|\.ts)(?:\?.*)?$/, async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
await route.continue()
|
||||
})
|
||||
await page.addInitScript((partID) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const answer = document.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
||||
const content = answer?.closest<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const root = content?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
if (!answer || !content || !root || !content.checkVisibility({ checkVisibilityCSS: true })) return
|
||||
const spacer = content.querySelector('[data-timeline-row="bottom-spacer"]')
|
||||
;(window as Window & { __coldReveal?: Reveal }).__coldReveal = {
|
||||
pending: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
|
||||
clipped: [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")].flatMap((row) =>
|
||||
(row.firstElementChild?.getBoundingClientRect().height ?? 0) > row.getBoundingClientRect().height + 1
|
||||
? [row.dataset.timelineKey!]
|
||||
: [],
|
||||
),
|
||||
bottomError: (spacer?.getBoundingClientRect().bottom ?? Infinity) - root.getBoundingClientRect().bottom,
|
||||
tables: answer.querySelectorAll("table").length,
|
||||
codeBlocks: answer.querySelectorAll("pre").length,
|
||||
}
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] })
|
||||
}, expected[fixture.sourceID].answerID)
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
pageMessages: () => ({ items: messages[fixture.sourceID] }),
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
try {
|
||||
await page.goto(stressSessionHref(fixture.sourceID), { waitUntil: "domcontentloaded" })
|
||||
await requested.promise
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "hidden")
|
||||
release.resolve()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
const reveal = await page.evaluate(() => (window as Window & { __coldReveal?: Reveal }).__coldReveal)
|
||||
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
|
||||
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("scrolls within a long answer without mounting unrelated history", async ({ page }) => {
|
||||
await openTimeline(page, messages[fixture.sourceID])
|
||||
const answer = page.locator(`[data-timeline-part-id="${expected[fixture.sourceID].answerID}"]`)
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(answer.getByRole("table")).toHaveCount(1)
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: answer })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
const rows = page.locator("[data-timeline-key]")
|
||||
const keys = await rows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-timeline-key")),
|
||||
)
|
||||
const top = await answer.evaluate((element) => element.getBoundingClientRect().top)
|
||||
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, -240)
|
||||
|
||||
await expect.poll(() => answer.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(top + 240, 0)
|
||||
expect(
|
||||
await rows.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-key"))),
|
||||
).toEqual(keys)
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
})
|
||||
|
||||
test("fills a short cold transcript before revealing it", async ({ page }) => {
|
||||
const history = messages[fixture.sourceID].slice(-6).map((message, index) => {
|
||||
if (message.type === "user") return { ...message, text: `Prompt ${index}`, metadata: undefined }
|
||||
if (message.type === "assistant")
|
||||
return { ...message, content: [{ type: "text" as const, text: `**Answer ${index}**` }] }
|
||||
return message
|
||||
})
|
||||
await openTimeline(page, history)
|
||||
for (const message of history) {
|
||||
if (message.type === "user") {
|
||||
await expect(page.locator(`[data-timeline-row="UserMessage"][data-message-id="${message.id}"]`)).toBeInViewport()
|
||||
}
|
||||
if (message.type === "assistant") {
|
||||
const answer = page.locator(`[data-timeline-part-id="${message.id}:text:0"]`)
|
||||
await expect(answer).toBeInViewport()
|
||||
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function openTimeline(page: Page, history: SessionMessageInfo[]) {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
pageMessages: () => ({ items: history }),
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
@@ -102,6 +102,14 @@ test("cramped tabs only show the close button for the active tab", async ({ page
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
await page.setViewportSize({ width: 450, height: 720 })
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
|
||||
}
|
||||
})
|
||||
|
||||
test("vertical tabs show project details, resize, and navigate", async ({ page }) => {
|
||||
@@ -288,11 +296,12 @@ async function mockServer(page: Page) {
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/inbox`)) return json(route, { data: [] })
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
|
||||
@@ -355,6 +355,14 @@ test.describe("smoke: session timeline", () => {
|
||||
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
|
||||
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
|
||||
// The shell is below a long diff; reveal it rather than depending on offscreen overscan.
|
||||
while ((await shell.count()) === 0) {
|
||||
const before = await timelineState(page)
|
||||
await timelineScroller(page).press("PageDown")
|
||||
await expect.poll(async () => (await timelineState(page)).signature).not.toBe(before.signature)
|
||||
}
|
||||
await shell.scrollIntoViewIfNeeded()
|
||||
await expect(shell).toBeInViewport()
|
||||
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
@@ -694,6 +702,7 @@ async function expectSessionTimelineReady(
|
||||
expectedMessageIDs: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
await waitForTimelineStable(page)
|
||||
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
|
||||
const currentState = await timelineState(page)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/NewProject"
|
||||
|
||||
test("creates a session in a new project and selects its model", async ({ page }) => {
|
||||
// An empty draft must remain usable when the file viewer is unavailable.
|
||||
await page.route(/(?:\/_assets\/file-(?!icon-)[^/]+\.js|\/session-ui\/src\/components\/file\.tsx)(?:\?|$)/, (route) =>
|
||||
route.abort(),
|
||||
)
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -60,7 +66,7 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
{ providerID: "opencode", modelID: "free-model", visibility: "show" },
|
||||
{ providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
|
||||
],
|
||||
recent: [],
|
||||
recent: [{ providerID: "opencode-go", modelID: "go-model-1" }],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
@@ -81,11 +87,73 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
await expectAppVisible(page.locator('[data-component="composer"]'))
|
||||
|
||||
const modelControl = page.locator('[data-action="composer-model"]')
|
||||
await expect(modelControl).toContainText("Go Model 1")
|
||||
await modelControl.click()
|
||||
await page.locator('[data-option-key="opencode:free-model"]').click()
|
||||
await expect(modelControl).toContainText("Free Model")
|
||||
|
||||
await modelControl.click()
|
||||
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
|
||||
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||
await expect(goModel).toBeVisible()
|
||||
await goModel.click()
|
||||
|
||||
await expect(modelControl).toContainText("Go Model 1")
|
||||
})
|
||||
|
||||
test("restores each existing session's model and variant when switching tabs", async ({ page }) => {
|
||||
const sessions = ["A", "B"].map((name) => ({
|
||||
...fixture.sessions[0],
|
||||
id: `ses_model_${name}`,
|
||||
title: `Model ${name}`,
|
||||
model: { id: `model-${name}`, providerID: "opencode", variant: "balanced" },
|
||||
}))
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: Object.fromEntries(
|
||||
sessions.map((session) => [
|
||||
session.model.id,
|
||||
{
|
||||
id: session.model.id,
|
||||
name: session.title,
|
||||
limit: { context: 200_000 },
|
||||
variants: { balanced: {}, high: {} },
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: sessions[0]!.model.id },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
|
||||
const hrefA = stressSessionHref(sessions[0]!.id)
|
||||
const hrefB = stressSessionHref(sessions[1]!.id)
|
||||
await page.goto(hrefA)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const modelControl = composer.locator('[data-action="composer-model"]')
|
||||
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
|
||||
await expect(modelControl).toHaveText("Model A")
|
||||
await expect(variant).toHaveText("balanced")
|
||||
await variant.click()
|
||||
await page.getByRole("menuitemradio", { name: "high", exact: true }).click()
|
||||
await expect(variant).toHaveText("high")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).click()
|
||||
await expect(page).toHaveURL(hrefB)
|
||||
await expect(modelControl).toHaveText("Model B")
|
||||
await expect(variant).toHaveText("balanced")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${hrefA}"]`).click()
|
||||
await expect(page).toHaveURL(hrefA)
|
||||
await expect(modelControl).toHaveText("Model A")
|
||||
await expect(variant).toHaveText("high")
|
||||
})
|
||||
|
||||
@@ -47,7 +47,6 @@ type MockStreamWindow = Window & {
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
await page.addInitScript(
|
||||
@@ -135,13 +134,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}, 50)
|
||||
page.on("close", () => clearInterval(timer))
|
||||
}
|
||||
const transport = HttpRouter.toWebHandler(
|
||||
HttpApiBuilder.layer(MockApi).pipe(
|
||||
Layer.provide(mockHandlers(config, state)),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
)
|
||||
const transport = createMockServerHandler(config)
|
||||
page.on("close", () => void transport.dispose())
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
@@ -173,6 +166,16 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
export function createMockServerHandler(config: MockServerConfig) {
|
||||
return HttpRouter.toWebHandler(
|
||||
HttpApiBuilder.layer(MockApi).pipe(
|
||||
Layer.provide(mockHandlers(config, { cursors: new Map<string, string>(), nextCursor: 0 })),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
)
|
||||
}
|
||||
|
||||
const corsHeaders = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers": "*",
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
|
||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
|
||||
"bench:tabs": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
|
||||
"bench:entry": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-entry-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
|
||||
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
@@ -150,7 +150,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
ref={(element) => {
|
||||
editor = element
|
||||
props.controller.setEditor(element)
|
||||
renderComposerEditor(element, props.controller.parts())
|
||||
}}
|
||||
data-component="composer-editor"
|
||||
role="textbox"
|
||||
|
||||
@@ -278,16 +278,15 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const directory = project?.worktree ?? session.location.directory
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
ctx.data.session.remember(session)
|
||||
ctx.projects.open(directory)
|
||||
if (options?.background) {
|
||||
tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||
return
|
||||
}
|
||||
ctx.projects.touch(directory)
|
||||
if (!options?.background) void ctx.data.session.message.sync(session.id).catch(() => undefined)
|
||||
// Commit cache/project changes with navigation instead of rebuilding
|
||||
// the outgoing Home list before leaving it.
|
||||
void startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||
tabs.select(tab)
|
||||
if (!options?.background) tabs.select(tab)
|
||||
ctx.data.session.remember(session)
|
||||
ctx.projects.open(directory)
|
||||
if (!options?.background) ctx.projects.touch(directory)
|
||||
})
|
||||
},
|
||||
archive: async (session: SessionInfo) => {
|
||||
|
||||
@@ -221,7 +221,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
}
|
||||
|
||||
const current = () => {
|
||||
const current = createMemo(() => {
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => agent.current()?.model,
|
||||
@@ -229,7 +229,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
)
|
||||
if (!item) return
|
||||
return models.find(item)
|
||||
}
|
||||
})
|
||||
|
||||
const configured = () => {
|
||||
const item = agent.current()
|
||||
|
||||
@@ -2,6 +2,12 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
|
||||
|
||||
export const dict = {
|
||||
...DESKTOP_NATIVE_ENGLISH,
|
||||
"session.location.unavailable": "Session location unavailable",
|
||||
"session.location.description": "Choose another directory to continue this session.",
|
||||
"session.location.choose": "Choose directory",
|
||||
"session.location.worktree": "Choose worktree",
|
||||
"session.location.worktreesFailed": "Failed to load worktrees",
|
||||
"session.location.moving": "Moving session…",
|
||||
"command.category.suggested": "Suggested",
|
||||
"command.category.view": "View",
|
||||
"command.category.project": "Project",
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
|
||||
}
|
||||
|
||||
function getCurrentServerUrl() {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { createMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createResource, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
|
||||
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
|
||||
const language = useLanguage()
|
||||
const sdk = useServerSDK()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
|
||||
const project = () => data.project.get(props.projectID)
|
||||
const [worktrees] = createResource(
|
||||
() => (state.worktreesOpen ? props.projectID : undefined),
|
||||
async (projectID) => {
|
||||
try {
|
||||
await sdk.api.worktree.refresh({ projectID })
|
||||
return await sdk.api.worktree.list({ projectID })
|
||||
} catch {
|
||||
showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
|
||||
return []
|
||||
}
|
||||
},
|
||||
// Seed latest so even the first fetch does not enter Suspense.
|
||||
{ initialValue: [] },
|
||||
)
|
||||
let button: HTMLButtonElement | undefined
|
||||
|
||||
const move = createMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; directory?: string }) => {
|
||||
// A deleted worktree cannot resolve its own location. Create from the
|
||||
// project's saved canonical checkout instead.
|
||||
const current = project()
|
||||
const destination =
|
||||
input.directory ??
|
||||
(current &&
|
||||
(await createWorktree({
|
||||
api: sdk.api,
|
||||
directory: current.canonical,
|
||||
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
|
||||
})))
|
||||
if (!destination) return
|
||||
await sdk.api.session.move({ sessionID: input.sessionID, directory: destination })
|
||||
},
|
||||
onError: (error) => {
|
||||
setState("restoreFocus", true)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.move.failed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.restoreFocus || move.isPending || dialog.active) return
|
||||
setState("restoreFocus", false)
|
||||
button?.focus()
|
||||
})
|
||||
|
||||
function choose() {
|
||||
if (move.isPending) return
|
||||
const sessionID = props.sessionID
|
||||
pickDirectory({
|
||||
server: sdk.server,
|
||||
title: language.t("session.location.choose"),
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) move.mutate({ sessionID, directory })
|
||||
if (!directory) setState("restoreFocus", true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-location-missing">
|
||||
<DockShell class="flex flex-col gap-2 p-3">
|
||||
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
|
||||
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
|
||||
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
|
||||
<div class="text-text-base">{language.t("session.location.description")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockShell>
|
||||
<DockTray class="flex flex-wrap justify-end gap-2 p-2">
|
||||
<Show when={project()?.vcs === "git"}>
|
||||
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
|
||||
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
|
||||
{language.t("session.location.worktree")}
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
|
||||
<Menu.Item onSelect={() => move.mutate({ sessionID: props.sessionID })} disabled={move.isPending}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={worktrees.loading}>
|
||||
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
|
||||
</Show>
|
||||
<For each={worktrees.latest.filter((item) => item.strategy && item.directory !== props.directory)}>
|
||||
{(worktree) => (
|
||||
<Menu.Item
|
||||
title={worktree.directory}
|
||||
onSelect={() => move.mutate({ sessionID: props.sessionID, directory: worktree.directory })}
|
||||
disabled={move.isPending}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="truncate">{getFilename(worktree.directory)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
|
||||
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
|
||||
</Button>
|
||||
</DockTray>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, on, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, on, onMount, Show } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { createComposerModel, type ComposerModel } from "@/composer/model"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
@@ -32,6 +32,7 @@ import { SessionQueuePanel } from "./queue-panel"
|
||||
import { resolveSessionComposerSelection } from "./selection"
|
||||
import { createSessionRequestModel } from "../requests/model"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
|
||||
export function createActiveSessionRegion(input: {
|
||||
session: SessionModel
|
||||
@@ -219,6 +220,14 @@ export function ActiveSessionComposerRegion(props: {
|
||||
onResponseSubmit: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const location = useWorkspaceLocation()
|
||||
const missing = createMemo(() => {
|
||||
const error = location().error
|
||||
const current = props.session.data.info()?.location
|
||||
if (error && current?.directory === error.location.directory && current.workspaceID === error.location.workspaceID)
|
||||
return current.directory
|
||||
return undefined
|
||||
})
|
||||
const region = createSessionComposerRegionController({
|
||||
state: props.model.region.state,
|
||||
parentID: props.session.data.parentID,
|
||||
@@ -247,12 +256,26 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<SessionComposerRegion
|
||||
controller={region}
|
||||
composer={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={missing()}
|
||||
keyed
|
||||
fallback={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(directory) => (
|
||||
<SessionLocationMissing
|
||||
sessionID={requireSessionID(props.session)}
|
||||
projectID={props.session.data.info()!.projectID}
|
||||
directory={directory}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { SessionInfo, SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@opencode-ai/client/promise"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
type Task =
|
||||
| { id: string; type: "subagent"; label: string; agent?: string }
|
||||
| { id: string; type: "shell"; label: string }
|
||||
|
||||
export function createSessionBackground(input: {
|
||||
sessionID: () => string | undefined
|
||||
messages: (id: string) => SessionMessageInfo[]
|
||||
sessions: () => SessionInfo[]
|
||||
status: (id: string) => "idle" | "running"
|
||||
shells: () => ShellInfo[]
|
||||
}) {
|
||||
const history = createMemo(() => {
|
||||
const completed = new Set<string>()
|
||||
const subagents: { id: string; type: "subagent"; label: string; agent: string | undefined }[] = []
|
||||
const shells: { partID: string; task: { id: string; type: "shell"; label: string } }[] = []
|
||||
const id = input.sessionID()
|
||||
const assistant = (id ? input.messages(id) : []).reduce<SessionMessageAssistant | undefined>((latest, message) => {
|
||||
if (message.type === "synthetic") {
|
||||
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
|
||||
completed.add(message.metadata.childID)
|
||||
if (message.metadata?.source === "shell") {
|
||||
if (typeof message.metadata.shellID === "string") completed.add(message.metadata.shellID)
|
||||
if (typeof message.metadata.jobID === "string") completed.add(message.metadata.jobID)
|
||||
}
|
||||
return latest
|
||||
}
|
||||
if (message.type !== "assistant") return latest
|
||||
message.content.forEach((part) => {
|
||||
if (part.type !== "tool" || (part.name !== "subagent" && part.name !== "shell")) return
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return
|
||||
if (part.name === "subagent") {
|
||||
const sessionID = part.state.metadata.sessionID
|
||||
if (typeof sessionID !== "string") return
|
||||
const description = part.state.input.description
|
||||
const agent = part.state.input.agent
|
||||
subagents.push({
|
||||
id: sessionID,
|
||||
type: "subagent",
|
||||
label: typeof description === "string" ? description : sessionID,
|
||||
agent: typeof agent === "string" ? agent : undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
const shellID = part.state.metadata.shellID
|
||||
const command = part.state.input.command
|
||||
shells.push({
|
||||
partID: part.id,
|
||||
task: {
|
||||
id: typeof shellID === "string" ? shellID : part.id,
|
||||
type: "shell",
|
||||
label: typeof command === "string" ? command : part.id,
|
||||
},
|
||||
})
|
||||
})
|
||||
return message.time.completed === undefined ? message : latest
|
||||
}, undefined)
|
||||
|
||||
return {
|
||||
// Completion notices can identify the shell or its original tool call.
|
||||
subagents: subagents.filter((task) => !completed.has(task.id)),
|
||||
shells: shells
|
||||
.filter((item) => !completed.has(item.partID) && !completed.has(item.task.id))
|
||||
.map((item) => item.task),
|
||||
blocking:
|
||||
assistant?.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return []
|
||||
if (part.name !== "shell" && part.name !== "subagent") return []
|
||||
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
|
||||
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
|
||||
return [
|
||||
{
|
||||
type: part.name as "shell" | "subagent",
|
||||
partID: part.id,
|
||||
id: typeof value === "string" ? value : undefined,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
},
|
||||
]
|
||||
}) ?? [],
|
||||
}
|
||||
})
|
||||
const blocking = createMemo(() => history().blocking)
|
||||
const tasks = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return []
|
||||
const current = history()
|
||||
const active = input.sessions().flatMap((info) => {
|
||||
if (info?.parentID !== id) return []
|
||||
if (input.status(info.id) === "idle") return []
|
||||
if (
|
||||
current.blocking.some(
|
||||
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
|
||||
})
|
||||
const running = input.shells().flatMap((shell) => {
|
||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||
if (
|
||||
current.blocking.some(
|
||||
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
|
||||
})
|
||||
return [
|
||||
...new Map<string, Task>(
|
||||
[...current.subagents, ...active, ...current.shells, ...running].map((task) => [task.id, task]),
|
||||
).values(),
|
||||
]
|
||||
})
|
||||
return { blocking, tasks }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
|
||||
import { createSessionBackground } from "@/session/requests/background"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
|
||||
export function createSessionRequestModel() {
|
||||
@@ -46,105 +47,12 @@ export function createSessionRequestModel() {
|
||||
const id = params.id
|
||||
return !!id && !data.session.get(id)?.parentID
|
||||
}
|
||||
const backgroundBlocking = createMemo(() => {
|
||||
if (!primary()) return []
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const assistant = data.session.message
|
||||
.list(id)
|
||||
.findLast((message) => message.type === "assistant" && message.time.completed === undefined)
|
||||
if (assistant?.type !== "assistant") return []
|
||||
return assistant.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return []
|
||||
if (part.name !== "shell" && part.name !== "subagent") return []
|
||||
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
|
||||
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
|
||||
return [
|
||||
{
|
||||
type: part.name as "shell" | "subagent",
|
||||
partID: part.id,
|
||||
id: typeof value === "string" ? value : undefined,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
const backgroundTasks = createMemo(() => {
|
||||
if (!primary()) return []
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const blocking = backgroundBlocking()
|
||||
const messages = data.session.message.list(id)
|
||||
const completed = new Set(
|
||||
messages.flatMap((message) => {
|
||||
if (message.type !== "synthetic") return []
|
||||
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
|
||||
return [message.metadata.childID]
|
||||
if (message.metadata?.source === "shell")
|
||||
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
|
||||
return []
|
||||
}),
|
||||
)
|
||||
const backgrounded = messages.flatMap((message) => {
|
||||
if (message.type !== "assistant") return []
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.name !== "subagent") return []
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
|
||||
const sessionID = part.state.metadata.sessionID
|
||||
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
|
||||
const description = part.state.input.description
|
||||
const agent = part.state.input.agent
|
||||
return [
|
||||
{
|
||||
id: sessionID,
|
||||
type: "subagent" as const,
|
||||
label: typeof description === "string" ? description : sessionID,
|
||||
agent: typeof agent === "string" ? agent : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
const active = data.session.list().flatMap((info) => {
|
||||
if (info?.parentID !== id) return []
|
||||
if (data.session.status(info.id) === "idle") return []
|
||||
if (
|
||||
blocking.some(
|
||||
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
|
||||
})
|
||||
const backgroundShells = messages.flatMap((message) => {
|
||||
if (message.type !== "assistant") return []
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
|
||||
const shellID = part.state.metadata.shellID
|
||||
if (typeof shellID === "string" && completed.has(shellID)) return []
|
||||
const command = part.state.input.command
|
||||
return [
|
||||
{
|
||||
id: typeof shellID === "string" ? shellID : part.id,
|
||||
type: "shell" as const,
|
||||
label: typeof command === "string" ? command : part.id,
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => {
|
||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||
if (
|
||||
blocking.some(
|
||||
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
|
||||
})
|
||||
return [
|
||||
...new Map([...backgrounded, ...active, ...backgroundShells, ...running].map((task) => [task.id, task])).values(),
|
||||
]
|
||||
const background = createSessionBackground({
|
||||
sessionID: () => (primary() ? params.id : undefined),
|
||||
messages: data.session.message.list,
|
||||
sessions: data.session.list,
|
||||
status: data.session.status,
|
||||
shells: () => data.shell.list({ directory: sdk().directory }),
|
||||
})
|
||||
const moveToBackground = async () => {
|
||||
if (!primary()) return
|
||||
@@ -191,8 +99,8 @@ export function createSessionRequestModel() {
|
||||
permissionRequest,
|
||||
permissionResponding,
|
||||
background: {
|
||||
blocking: backgroundBlocking,
|
||||
tasks: backgroundTasks,
|
||||
blocking: background.blocking,
|
||||
tasks: background.tasks,
|
||||
move: moveToBackground,
|
||||
},
|
||||
decide,
|
||||
|
||||
@@ -5,18 +5,27 @@ import { createSessionResolution } from "./session-resolution"
|
||||
describe("session resolution", () => {
|
||||
test("waits for a route session ID", () => {
|
||||
createRoot((dispose) => {
|
||||
let syncs = 0
|
||||
const syncs = { session: 0, message: 0 }
|
||||
const sessions = {
|
||||
get: () => undefined,
|
||||
sync: () => {
|
||||
syncs++
|
||||
syncs.session++
|
||||
return Promise.resolve()
|
||||
},
|
||||
message: {
|
||||
sync: () => {
|
||||
syncs.message++
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
}
|
||||
const session = createSessionResolution(() => undefined, () => sessions)
|
||||
const session = createSessionResolution(
|
||||
() => undefined,
|
||||
() => sessions,
|
||||
)
|
||||
|
||||
expect(session()).toBeUndefined()
|
||||
expect(syncs).toBe(0)
|
||||
expect(syncs).toEqual({ session: 0, message: 0 })
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createMemo, createRenderEffect, createSignal, on, onCleanup } from "solid-js"
|
||||
import { sessionNotFoundError } from "@/runtime/server/errors"
|
||||
|
||||
type SessionStore<T> = {
|
||||
get: (id: string) => T | undefined
|
||||
sync: (id: string, options?: { children?: boolean }) => Promise<unknown>
|
||||
message: {
|
||||
sync: (id: string) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type Resolution<T> = { id: string; store: SessionStore<T> } & (
|
||||
@@ -40,13 +43,17 @@ export function createSessionResolution<T>(
|
||||
})
|
||||
const [status, setStatus] = createSignal<Resolution<T>>()
|
||||
|
||||
createEffect(
|
||||
// Start independent reads before constructing the selected view, including
|
||||
// when its metadata is cached but its transcript has never been loaded.
|
||||
createRenderEffect(
|
||||
on([sessionID, sessions] as const, ([id, store]) => {
|
||||
if (!id) return
|
||||
let stale = false
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
// The timeline owns message errors; metadata resolution stays independent.
|
||||
void store.message.sync(id).catch(() => undefined)
|
||||
if (cached() && !options?.children) {
|
||||
setStatus({ id, store, state: "settled" })
|
||||
return
|
||||
|
||||
@@ -79,6 +79,17 @@ describe("visibleTimelineMessages", () => {
|
||||
?.map((message) => message.id),
|
||||
).toEqual(["msg_2", "msg_5"])
|
||||
expect(projection.assistantMessagesByParent().has(steer.id)).toBe(false)
|
||||
expect([...projection.messageRowIndex()]).toEqual([
|
||||
["msg_1", 0],
|
||||
["msg_3", 2],
|
||||
])
|
||||
expect([...projection.messageLastRowIndex()]).toEqual([
|
||||
["msg_1", 1],
|
||||
["msg_3", 3],
|
||||
])
|
||||
expect([...projection.lastAssistantGroupKey()]).toEqual([["msg_1", "context:msg_5:tool_read"]])
|
||||
expect(projection.rowByKey().get("user-message:msg_1")).toBe(projection.rows()[0])
|
||||
expect(projection.rowByKey().size).toBe(projection.rows().length)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
|
||||
import { getReadyMarkdown, preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { createTimelineVirtualizer } from "./virtualizer"
|
||||
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
@@ -316,6 +317,16 @@ type MessageTimelineProps = {
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
const controller = createTimelineController({ session: props.session })
|
||||
const tail = props.pinned ? controller.data.projection.rows().at(-1) : undefined
|
||||
if (tail?._tag === "AssistantPart" && tail.group.type === "part") {
|
||||
const message = controller.data.projection.messageByID().get(tail.group.ref.messageID)
|
||||
if (message?.type === "assistant" && message.time.completed !== undefined) {
|
||||
const content = Timeline.resolveContent(message, tail.group.ref.partID)
|
||||
// Start the required worker job while the rest of the selected view is constructed.
|
||||
if (content?.type === "text" && content.text.trim())
|
||||
void preloadMarkdown(content.text, tail.group.ref.partID).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
|
||||
)
|
||||
@@ -400,6 +411,38 @@ function MessageTimelineView(
|
||||
onSelectionInteraction: props.onSelectionInteraction,
|
||||
onUserScroll: props.onUserScroll,
|
||||
onHistoryScroll: props.onHistoryScroll,
|
||||
canRenderImmediately: (row, disclosure) => {
|
||||
if (row._tag === "TurnGap" || row._tag === "TurnDivider") return true
|
||||
if (row._tag === "Notice") {
|
||||
const message = messageByID().get(row.messageID)
|
||||
return (
|
||||
(message?.type === "system" || message?.type === "synthetic") &&
|
||||
(message.description ?? message.text).length <= 1024
|
||||
)
|
||||
}
|
||||
if (row._tag === "UserMessage") {
|
||||
const message = messageByID().get(row.userMessageID)
|
||||
if (message?.type !== "user" || message.text.length > 1024 || message.files?.length || message.agents?.length)
|
||||
return false
|
||||
const presentation = readPromptPresentation(message.metadata)
|
||||
return (
|
||||
(presentation?.displayText ?? message.text).length <= 1024 &&
|
||||
!presentation?.comments?.length &&
|
||||
!parseCommentNote(message.text)
|
||||
)
|
||||
}
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
|
||||
const message = messageByID().get(row.group.ref.messageID)
|
||||
if (message?.type !== "assistant" || message.time.completed === undefined) return false
|
||||
const content = Timeline.resolveContent(message, row.group.ref.partID)
|
||||
if (content?.type === "reasoning")
|
||||
return !(disclosure[row.group.ref.partID] ?? props.data.reasoningMode() === "full")
|
||||
return (
|
||||
content?.type === "text" &&
|
||||
content.text.length <= 1024 &&
|
||||
!!getReadyMarkdown({ raw: content.text, src: content.text }, `${row.group.ref.partID}:0:full`)
|
||||
)
|
||||
},
|
||||
setRevealMessage: props.setRevealMessage,
|
||||
setScrollToEnd: props.setScrollToEnd,
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ export {
|
||||
|
||||
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
|
||||
const data = useData()
|
||||
const prepared = new Set<string>()
|
||||
|
||||
const [resource] = createResource(
|
||||
() => input.session.identity.sessionID(),
|
||||
@@ -30,14 +29,12 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
|
||||
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
|
||||
maxPages: leadingTurnPageLimit,
|
||||
}).catch(() => undefined)
|
||||
if (input.session.identity.sessionKey() === key) prepared.add(key)
|
||||
},
|
||||
)
|
||||
const ready = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
if (!id || prepared.has(input.session.identity.sessionKey()) || !resource.loading) return true
|
||||
const messages = data.session.message.list(id)
|
||||
return messages.length > 0 && !leadingTurnNeedsParent(messages)
|
||||
// Enrich the partial leading group without withholding the already loaded tail.
|
||||
return !id || data.session.message.list(id).length > 0 || !resource.loading
|
||||
})
|
||||
const more = () => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
@@ -96,38 +96,29 @@ export function createTimelineProjection(input: {
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(previous, projection().rows),
|
||||
)
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
|
||||
const messageRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
const indexes = createMemo(() => {
|
||||
const rowByKey = new Map<string, TimelineRow.TimelineRow>()
|
||||
const messageRowIndex = new Map<string, number>()
|
||||
const messageLastRowIndex = new Map<string, number>()
|
||||
const lastAssistantGroupKey = new Map<string, string>()
|
||||
rows().forEach((row, index) => {
|
||||
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
|
||||
result.set(row.userMessageID, index)
|
||||
rowByKey.set(TimelineRow.key(row), row)
|
||||
if (!("userMessageID" in row)) return
|
||||
if (!messageRowIndex.has(row.userMessageID)) messageRowIndex.set(row.userMessageID, index)
|
||||
messageLastRowIndex.set(row.userMessageID, index)
|
||||
if (row._tag === "AssistantPart") lastAssistantGroupKey.set(row.userMessageID, row.group.key)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const messageLastRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
rows().forEach((row, index) => {
|
||||
if ("userMessageID" in row) result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const lastAssistantGroupKey = createMemo(() => {
|
||||
const result = new Map<string, string>()
|
||||
rows().forEach((row) => {
|
||||
if (row._tag === "AssistantPart") result.set(row.userMessageID, row.group.key)
|
||||
})
|
||||
return result
|
||||
return { rowByKey, messageRowIndex, messageLastRowIndex, lastAssistantGroupKey }
|
||||
})
|
||||
|
||||
return {
|
||||
activeMessageID,
|
||||
assistantMessagesByParent,
|
||||
lastAssistantGroupKey,
|
||||
lastAssistantGroupKey: () => indexes().lastAssistantGroupKey,
|
||||
messageByID: sessionMessageByID,
|
||||
messageRowIndex,
|
||||
messageLastRowIndex,
|
||||
rowByKey,
|
||||
messageRowIndex: () => indexes().messageRowIndex,
|
||||
messageLastRowIndex: () => indexes().messageLastRowIndex,
|
||||
rowByKey: () => indexes().rowByKey,
|
||||
rows,
|
||||
sessionMessageByID,
|
||||
userContextByID,
|
||||
|
||||
@@ -55,6 +55,10 @@ type Input = {
|
||||
onSelectionInteraction: (event: MouseEvent) => void
|
||||
onUserScroll: (target?: EventTarget | null) => void
|
||||
onHistoryScroll: () => void
|
||||
canRenderImmediately?: (
|
||||
row: TimelineRow.TimelineRow,
|
||||
disclosure: Readonly<Record<string, boolean | undefined>>,
|
||||
) => boolean
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
}
|
||||
@@ -75,17 +79,63 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const coldBottomMount = !initialMeasurements?.length && input.pinned()
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
||||
const [overscan, setOverscan] = createSignal(2)
|
||||
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
|
||||
const rows = input.projection.rows
|
||||
const rowByKey = input.projection.rowByKey
|
||||
const knownKeys = new Set(rows().map(TimelineRow.key))
|
||||
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
|
||||
equals: (previous, next) => previous.length === next.length && previous.every((key, index) => key === next[index]),
|
||||
})
|
||||
const knownKeys = new Set(rowKeys())
|
||||
const addedKeys = new Set<string>()
|
||||
const getItemKey = createMemo(() => {
|
||||
const keys = rowKeys()
|
||||
keys
|
||||
.filter((key) => !knownKeys.has(key))
|
||||
.forEach((key) => {
|
||||
knownKeys.add(key)
|
||||
addedKeys.add(key)
|
||||
})
|
||||
return (index: number) => keys[index] ?? `removed:${index}`
|
||||
})
|
||||
const rangeExtractor = createMemo(() => {
|
||||
const id = input.projection.activeMessageID()
|
||||
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
|
||||
const initialTail = rendering.initialTail && input.pinned()
|
||||
return (range: Range) => {
|
||||
// Batch a bounded cheap suffix, but stop before unknown/large content.
|
||||
// A large tail still mounts alone before estimates expose earlier history.
|
||||
const start = Math.max(0, range.startIndex - 2)
|
||||
const boundary = initialTail
|
||||
? rows()
|
||||
.slice(start, range.count)
|
||||
.findLastIndex(
|
||||
(row) =>
|
||||
!(
|
||||
row._tag === "AssistantPart" &&
|
||||
row.group.type === "context" &&
|
||||
row.group.refs.length <= 16 &&
|
||||
!toolOpen[`context:${row.group.key}`]
|
||||
) && !input.canRenderImmediately?.(row, toolOpen),
|
||||
)
|
||||
: -1
|
||||
const first = Math.min(range.count - 1, start + boundary + 1)
|
||||
const indexes = initialTail
|
||||
? Array.from({ length: range.count - first }, (_, index) => first + index)
|
||||
: defaultRangeExtractor({ ...range, overscan: 2 })
|
||||
return filterVirtualIndexes(
|
||||
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
range.count,
|
||||
)
|
||||
}
|
||||
})
|
||||
const measuredElements = new WeakSet<Element>()
|
||||
let touchStart: number | undefined
|
||||
let pointerHeld = false
|
||||
let maxScroll = 0
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
let scrollTop = 0
|
||||
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
|
||||
let batchingColdSizes = false
|
||||
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
@@ -93,10 +143,16 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
// Route navigation detaches and reattaches the scroll element, which drops its offset.
|
||||
observeElementOffset: (instance, callback) =>
|
||||
observeElementOffsetReconnectAware(instance, callback, () => {
|
||||
observeElementOffset: (instance, callback) => {
|
||||
reportOffset = (offset, scrolling) => {
|
||||
callback(offset, scrolling)
|
||||
settleColdBottom()
|
||||
}
|
||||
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
}),
|
||||
settleColdBottom()
|
||||
})
|
||||
},
|
||||
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => fallbackItemSize,
|
||||
@@ -110,28 +166,17 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (box) return Math.round(box.blockSize)
|
||||
if (initial) {
|
||||
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
|
||||
if (size !== undefined) return size
|
||||
if (size !== undefined || coldPending) return size ?? fallbackItemSize
|
||||
}
|
||||
return element.offsetHeight
|
||||
},
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
if (batchingColdSizes && input.pinned()) return
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
},
|
||||
get getItemKey() {
|
||||
const items = rows()
|
||||
items
|
||||
.map(TimelineRow.key)
|
||||
.filter((key) => !knownKeys.has(key))
|
||||
.forEach((key) => {
|
||||
knownKeys.add(key)
|
||||
addedKeys.add(key)
|
||||
})
|
||||
return (index: number) => {
|
||||
const row = items[index]
|
||||
if (!row) return `removed:${index}`
|
||||
return TimelineRow.key(row)
|
||||
}
|
||||
return getItemKey()
|
||||
},
|
||||
get anchorTo() {
|
||||
return input.pinned() ? "end" : "start"
|
||||
@@ -145,16 +190,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
},
|
||||
paddingEnd: 64,
|
||||
get rangeExtractor() {
|
||||
const id = input.projection.activeMessageID()
|
||||
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
|
||||
const buffer = overscan()
|
||||
return (range: Range) => {
|
||||
const indexes = defaultRangeExtractor({ ...range, overscan: buffer })
|
||||
return filterVirtualIndexes(
|
||||
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
|
||||
range.count,
|
||||
)
|
||||
}
|
||||
return rangeExtractor()
|
||||
},
|
||||
})
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
@@ -166,7 +202,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const row = rows()[index]
|
||||
if (!row) return
|
||||
const key = TimelineRow.key(row)
|
||||
if (virtualizer.itemSizeCache.get(key) === size) {
|
||||
if ((virtualizer.itemSizeCache.get(key) ?? fallbackItemSize) === size) {
|
||||
pendingSizes.delete(index)
|
||||
return
|
||||
}
|
||||
@@ -178,12 +214,19 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (!pendingSizes.size) return
|
||||
const sizes = [...pendingSizes]
|
||||
pendingSizes.clear()
|
||||
// The hidden pinned mount needs one bottom write after the whole batch,
|
||||
// not a layout-forcing scroll adjustment for every measured row.
|
||||
batchingColdSizes = coldPending && input.pinned()
|
||||
batch(() => {
|
||||
sizes.forEach(([index, value]) => {
|
||||
const row = rows()[index]
|
||||
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
|
||||
})
|
||||
})
|
||||
batchingColdSizes = false
|
||||
if (coldPending) pinColdBottom()
|
||||
settleColdBottom()
|
||||
if (coldPending) return
|
||||
if (!input.pinned()) return
|
||||
const root = listRoot()
|
||||
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
|
||||
@@ -217,41 +260,73 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
})
|
||||
})
|
||||
|
||||
let settleFrame: number | undefined
|
||||
let overscanFrame: number | undefined
|
||||
let overscanTimer: number | undefined
|
||||
const expandOverscan = () => {
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
overscanFrame = undefined
|
||||
// Let the visible rows paint before building the normal interaction buffer.
|
||||
overscanTimer = window.setTimeout(() => {
|
||||
overscanTimer = undefined
|
||||
setOverscan(20)
|
||||
}, 0)
|
||||
})
|
||||
let coldPending = coldBottomMount
|
||||
let settleQueued = false
|
||||
let contentObserver: MutationObserver | undefined
|
||||
let viewportObserver: ResizeObserver | undefined
|
||||
const pinColdBottom = () => {
|
||||
const root = listRoot()
|
||||
if (!input.pinned() || !virtualContent || !root) return
|
||||
// scrollToEnd computes its target from the DOM, not the new size cache.
|
||||
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
if (Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon) virtualizer.scrollToEnd()
|
||||
// Report after core size adjustments finish so they cannot apply a delta
|
||||
// twice. This avoids waiting a frame for the native scroll event.
|
||||
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
|
||||
}
|
||||
const pendingMeasurements = () => {
|
||||
const items = virtualizer.getVirtualItems()
|
||||
return (
|
||||
(rows().length > 0 && items.length === 0) ||
|
||||
items.some((item) => !virtualizer.elementsCache.get(item.key)?.isConnected)
|
||||
)
|
||||
}
|
||||
const pendingMeasurements = () =>
|
||||
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
|
||||
const settleColdBottom = () => {
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
|
||||
settleFrame = requestAnimationFrame(settleColdBottom)
|
||||
return
|
||||
}
|
||||
settleFrame = requestAnimationFrame(() => {
|
||||
if (input.pinned()) virtualizer.scrollToEnd()
|
||||
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
|
||||
if (!coldPending || settleQueued) return
|
||||
settleQueued = true
|
||||
queueMicrotask(() => {
|
||||
settleQueued = false
|
||||
const root = listRoot()
|
||||
if (!coldPending || !virtualContent?.isConnected || !root) return
|
||||
if (virtualContent.querySelector(pendingMarkdown)) return
|
||||
if (!root.clientHeight) return
|
||||
// Markdown can finish before ResizeObserver delivers its new box. The
|
||||
// normal measureElement path skips reads while scrolling; this gate needs
|
||||
// current boxes before expanding the estimated range or revealing it.
|
||||
virtualizer.elementsCache.forEach((element) => {
|
||||
if (element.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(element), element.offsetHeight)
|
||||
})
|
||||
if (pendingSizes.size || pendingMeasurements()) return
|
||||
pinColdBottom()
|
||||
if (input.pinned() && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > 1) return
|
||||
// The scroll event must update the range before newly exposed rows can reveal.
|
||||
if (root.scrollHeight > root.clientHeight && Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1)
|
||||
return
|
||||
if (rendering.initialTail) {
|
||||
setRendering("initialTail", false)
|
||||
settleColdBottom()
|
||||
return
|
||||
}
|
||||
settleFrame = undefined
|
||||
virtualContent?.style.removeProperty("visibility")
|
||||
expandOverscan()
|
||||
if (pendingSizes.size || pendingMeasurements() || virtualContent.querySelector(pendingMarkdown)) return
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
viewportObserver?.disconnect()
|
||||
virtualContent.style.removeProperty("visibility")
|
||||
})
|
||||
}
|
||||
onMount(() => {
|
||||
if (coldBottomMount) settleFrame = requestAnimationFrame(settleColdBottom)
|
||||
if (!coldBottomMount) expandOverscan()
|
||||
if (!coldPending || !virtualContent) return
|
||||
contentObserver = new MutationObserver(settleColdBottom)
|
||||
contentObserver.observe(virtualContent, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["data-markdown-ready"],
|
||||
})
|
||||
viewportObserver = new ResizeObserver(settleColdBottom)
|
||||
const root = listRoot()
|
||||
if (root) viewportObserver.observe(root)
|
||||
settleColdBottom()
|
||||
})
|
||||
|
||||
let measuredSessionKey = input.sessionKey()
|
||||
@@ -265,12 +340,14 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
|
||||
const bindListRoot = (root: HTMLDivElement) => {
|
||||
if (root === listRoot()) return
|
||||
setListRoot(root)
|
||||
// TanStack owns anchoring; browser scroll anchoring would fight its adjustments.
|
||||
root.style.overflowAnchor = "none"
|
||||
setListRoot(root)
|
||||
scrollTop = root.scrollTop
|
||||
maxScroll = root.scrollHeight - root.clientHeight
|
||||
input.setScrollRef(root)
|
||||
viewportObserver?.observe(root)
|
||||
settleColdBottom()
|
||||
}
|
||||
|
||||
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
|
||||
@@ -278,13 +355,11 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
input.onUserScroll(event.target)
|
||||
if (event.deltaY < 0) input.onUnpin()
|
||||
setOverscan(20)
|
||||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
input.onUserScroll(event.target)
|
||||
touchStart = event.touches[0]?.clientY
|
||||
setOverscan(20)
|
||||
}
|
||||
|
||||
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
|
||||
@@ -301,7 +376,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
input.onUserScroll(event.target)
|
||||
pointerHeld = true
|
||||
setOverscan(20)
|
||||
}
|
||||
const releasePointer = () => {
|
||||
pointerHeld = false
|
||||
@@ -322,7 +396,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
|
||||
input.onUserScroll(event.currentTarget)
|
||||
if (upwardKeys.has(key)) input.onUnpin()
|
||||
setOverscan(20)
|
||||
}
|
||||
|
||||
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
|
||||
@@ -338,6 +411,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
|
||||
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
|
||||
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
|
||||
settleColdBottom()
|
||||
input.onScheduleScrollState(root)
|
||||
input.onHistoryScroll()
|
||||
}
|
||||
@@ -476,9 +550,9 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
cache.delete(ownerSessionKey)
|
||||
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
while (cache.size > 16) cache.delete(cache.keys().next().value!)
|
||||
if (settleFrame !== undefined) cancelAnimationFrame(settleFrame)
|
||||
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
|
||||
if (overscanTimer !== undefined) window.clearTimeout(overscanTimer)
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
viewportObserver?.disconnect()
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
input.setScrollToEnd?.(() => {})
|
||||
|
||||
@@ -11,9 +11,8 @@ import Shell from "@/shell/shell"
|
||||
import { requireServerKey } from "./session"
|
||||
|
||||
export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const loadDraftRoute = () => Promise.all([import("@/new-session/route"), File.preload()]).then(([module]) => module)
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
|
||||
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
|
||||
@@ -43,12 +43,12 @@ export default function Layout(props: ParentProps) {
|
||||
style={{
|
||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
// The native Windows titlebar already includes the gap above the content panels.
|
||||
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
|
||||
"--shell-top-inset":
|
||||
platform.platform === "desktop" &&
|
||||
platform.os === "windows" &&
|
||||
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
|
||||
? "0px"
|
||||
? "1px"
|
||||
: "8px",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,6 @@ export function TabNavItem(props: {
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
forceTruncate?: boolean
|
||||
suppressNavigation?: boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
@@ -98,11 +97,13 @@ export function TabNavItem(props: {
|
||||
|
||||
createEffect(() => {
|
||||
title()
|
||||
props.forceTruncate
|
||||
props.active
|
||||
props.orientation
|
||||
editing()
|
||||
scheduleTitleOverflow()
|
||||
})
|
||||
|
||||
// The overflow fade changes title padding; observe the stable tab box, not that feedback.
|
||||
createResizeObserver(() => tabRoot, scheduleTitleOverflow)
|
||||
onCleanup(() => {
|
||||
if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, For, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
@@ -26,7 +25,6 @@ function SessionTabSlot(props: {
|
||||
id: string
|
||||
index: number
|
||||
active: boolean
|
||||
forceTruncate: boolean
|
||||
orientation: "horizontal" | "vertical"
|
||||
session: SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
@@ -69,7 +67,6 @@ function SessionTabSlot(props: {
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragging={sortable.isDragSource()}
|
||||
orientation={props.orientation}
|
||||
/>
|
||||
@@ -82,7 +79,6 @@ function SessionTabEntry(props: {
|
||||
id: string
|
||||
index: number
|
||||
active: boolean
|
||||
forceTruncate: boolean
|
||||
orientation: "horizontal" | "vertical"
|
||||
serverCtx: ServerCtx | undefined
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
@@ -169,7 +165,6 @@ function SessionTabEntry(props: {
|
||||
id={props.id}
|
||||
index={props.index}
|
||||
active={props.active}
|
||||
forceTruncate={props.forceTruncate}
|
||||
orientation={props.orientation}
|
||||
session={session()}
|
||||
fallbackTitle={
|
||||
@@ -238,19 +233,15 @@ export function TitlebarTabStrip(props: {
|
||||
orientation?: "horizontal" | "vertical"
|
||||
tabs: Tab[]
|
||||
currentTab: Tab | undefined
|
||||
forceTruncate: boolean
|
||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||
onClose: (tab: Tab) => void
|
||||
onReorder: (keys: string[]) => void
|
||||
onOverflowChange: (overflowing: boolean) => void
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const vertical = () => props.orientation === "vertical"
|
||||
let scrollRef!: HTMLDivElement
|
||||
let listRef!: HTMLDivElement
|
||||
let resizeFrame: number | undefined
|
||||
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
|
||||
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
|
||||
const visibleTabIds = () => visibleTabs().map(tabKey)
|
||||
@@ -281,38 +272,6 @@ export function TitlebarTabStrip(props: {
|
||||
if (next) props.onNavigate(next)
|
||||
}
|
||||
|
||||
function refreshOverflow() {
|
||||
if (!scrollRef) return
|
||||
props.onOverflowChange(
|
||||
vertical() ? scrollRef.scrollHeight > scrollRef.clientHeight : scrollRef.scrollWidth > scrollRef.clientWidth,
|
||||
)
|
||||
}
|
||||
|
||||
createResizeObserver(
|
||||
() => [scrollRef, listRef],
|
||||
() => {
|
||||
if (resizeFrame !== undefined) return
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
refreshOverflow()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
refreshOverflow()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.tabs.length
|
||||
visibleTabIds()
|
||||
refreshOverflow()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot={vertical() ? "vertical-tabs" : "titlebar-tabs"}
|
||||
@@ -327,7 +286,6 @@ export function TitlebarTabStrip(props: {
|
||||
"flex-row items-center gap-1.5 overflow-x-auto": !vertical(),
|
||||
"max-h-full flex-col overflow-y-auto overflow-x-hidden": vertical(),
|
||||
}}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
@@ -398,7 +356,6 @@ export function TitlebarTabStrip(props: {
|
||||
id={id}
|
||||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
forceTruncate={props.forceTruncate}
|
||||
orientation={vertical() ? "vertical" : "horizontal"}
|
||||
serverCtx={serverCtx()}
|
||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
@@ -336,8 +336,6 @@ export function Titlebar(props: {
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
|
||||
@@ -382,8 +380,6 @@ export function Titlebar(props: {
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant" })
|
||||
@@ -424,8 +420,6 @@ export function Titlebar(props: {
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
forceTruncate={false}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo, SessionMessageAssistantTool, ShellInfo } from "@opencode-ai/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSessionBackground } from "@/session/requests/background"
|
||||
|
||||
const tool = (
|
||||
id: string,
|
||||
name: string,
|
||||
metadata: Record<string, string>,
|
||||
input: Record<string, string> = {},
|
||||
status: "completed" | "running" = "completed",
|
||||
): SessionMessageAssistantTool => ({
|
||||
id,
|
||||
name,
|
||||
type: "tool",
|
||||
state:
|
||||
status === "running"
|
||||
? { status, input, metadata }
|
||||
: { status, input, metadata, content: [{ type: "text", text: "backgrounded" }] },
|
||||
time: { created: 0 },
|
||||
})
|
||||
|
||||
const assistant = (id: string, content: SessionMessageAssistantTool[], completed?: number) => ({
|
||||
id,
|
||||
type: "assistant" as const,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content,
|
||||
time: { created: 0, completed },
|
||||
})
|
||||
|
||||
const session = (id: string): SessionInfo => ({
|
||||
id,
|
||||
title: id,
|
||||
parentID: "root",
|
||||
projectID: "project",
|
||||
location: { directory: "/project" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
})
|
||||
|
||||
const shell = (id: string, command: string): ShellInfo => ({
|
||||
id,
|
||||
command,
|
||||
status: "running",
|
||||
cwd: "/project",
|
||||
shell: "sh",
|
||||
file: "output",
|
||||
metadata: { sessionID: "root" },
|
||||
time: { started: 0 },
|
||||
})
|
||||
|
||||
const notification = (id: string, metadata: Record<string, string>) => ({
|
||||
id,
|
||||
type: "synthetic" as const,
|
||||
text: "complete",
|
||||
metadata,
|
||||
time: { created: 0 },
|
||||
})
|
||||
|
||||
describe("createSessionBackground", () => {
|
||||
test("excludes completed children and shells using either shell or tool-call IDs", () => {
|
||||
createRoot((dispose) => {
|
||||
const background = createSessionBackground({
|
||||
sessionID: () => "root",
|
||||
messages: () => [
|
||||
notification("before", { source: "subagent", childID: "before-child" }),
|
||||
assistant("assistant", [
|
||||
tool("before-part", "subagent", { status: "running", sessionID: "before-child" }),
|
||||
tool("shell-part", "shell", { status: "running", shellID: "process" }),
|
||||
tool("shell-call", "shell", { status: "running", shellID: "shell-id" }),
|
||||
tool("legacy-call", "shell", { status: "running", shellID: "legacy-shell" }),
|
||||
tool("child-part", "subagent", { status: "running", sessionID: "child" }, { agent: "explore" }),
|
||||
]),
|
||||
notification("shell-done", { source: "shell", jobID: "shell-part" }),
|
||||
notification("shell-id-done", { source: "shell", shellID: "shell-id" }),
|
||||
notification("legacy-done", { source: "shell", jobID: "legacy-shell" }),
|
||||
],
|
||||
sessions: () => [],
|
||||
status: () => "idle",
|
||||
shells: () => [],
|
||||
})
|
||||
expect(background.tasks()).toEqual([{ id: "child", type: "subagent", label: "child", agent: "explore" }])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("joins live tasks while idle without rescanning history, then switches sessions", () => {
|
||||
createRoot((dispose) => {
|
||||
const [store, setStore] = createStore({
|
||||
id: "root" as string | undefined,
|
||||
messages: [
|
||||
assistant("assistant", [
|
||||
tool("shell-part", "shell", { status: "running", shellID: "shell" }, { command: "old command" }),
|
||||
tool("child-part", "subagent", { status: "running", sessionID: "child" }),
|
||||
]),
|
||||
],
|
||||
sessions: [session("live-child"), session("child")],
|
||||
status: { root: "idle", child: "idle", "live-child": "idle" } as Record<string, "idle" | "running">,
|
||||
shells: [{ ...shell("shell", "command"), status: "exited" as ShellInfo["status"] }],
|
||||
})
|
||||
let scans = 0
|
||||
const background = createSessionBackground({
|
||||
sessionID: () => store.id,
|
||||
messages: (id) => {
|
||||
scans += 1
|
||||
return id === "root" ? store.messages : []
|
||||
},
|
||||
sessions: () => store.sessions,
|
||||
status: (id) => store.status[id],
|
||||
shells: () => store.shells,
|
||||
})
|
||||
const blocking = background.blocking()
|
||||
const initial = background.tasks()
|
||||
expect(initial.map((task) => task.id)).toEqual(["child", "shell"])
|
||||
|
||||
setStore("status", { child: "running", "live-child": "running" })
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["child", "live-child", "shell"])
|
||||
setStore("shells", 0, "status", "running")
|
||||
expect(background.tasks().at(-1)?.label).toBe("command")
|
||||
setStore("sessions", 1, "title", "renamed")
|
||||
expect(background.tasks()[0]?.label).toBe("renamed")
|
||||
setStore("shells", 0, "command", "updated command")
|
||||
const live = background.tasks()
|
||||
expect(live).toEqual([
|
||||
{ id: "child", type: "subagent", label: "renamed" },
|
||||
{ id: "live-child", type: "subagent", label: "live-child" },
|
||||
{ id: "shell", type: "shell", label: "updated command" },
|
||||
])
|
||||
expect(background.blocking()).toBe(blocking)
|
||||
expect(scans).toBe(1)
|
||||
|
||||
setStore("id", "other")
|
||||
expect(background.tasks()).toEqual([])
|
||||
setStore("id", "root")
|
||||
expect(background.tasks()).toEqual(live)
|
||||
setStore("status", { child: "idle", "live-child": "idle" })
|
||||
setStore("shells", 0, "status", "exited")
|
||||
expect(background.tasks()).toEqual(initial)
|
||||
expect(scans).toBe(3)
|
||||
setStore("id", undefined)
|
||||
expect(background.tasks()).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks blocking, backgrounding, and completion through nested store updates", () => {
|
||||
createRoot((dispose) => {
|
||||
const [store, setStore] = createStore({
|
||||
messages: [
|
||||
assistant("earlier", [tool("old-part", "subagent", { sessionID: "old-child" }, {}, "running")]),
|
||||
assistant("current", [
|
||||
tool("child-part", "subagent", { sessionID: "child" }, {}, "running"),
|
||||
tool("shell-part", "shell", {}, { command: "build" }, "running"),
|
||||
]),
|
||||
assistant("completed", [], 0),
|
||||
],
|
||||
notification: notification("notice", { source: "subagent", childID: "other-child" }),
|
||||
status: { child: "running", "old-child": "running" } as Record<string, "idle" | "running">,
|
||||
})
|
||||
const messages = store.messages
|
||||
const background = createSessionBackground({
|
||||
sessionID: () => "root",
|
||||
messages: () => [...store.messages, store.notification],
|
||||
sessions: () => [session("child"), session("old-child")],
|
||||
status: (id) => store.status[id],
|
||||
shells: () => [shell("shell", "build")],
|
||||
})
|
||||
expect(background.blocking()).toEqual([
|
||||
{ type: "subagent", partID: "child-part", id: "child", label: undefined },
|
||||
{ type: "shell", partID: "shell-part", id: undefined, label: "build" },
|
||||
])
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["old-child"])
|
||||
|
||||
setStore("messages", 1, "content", 0, "state", {
|
||||
status: "completed",
|
||||
input: { description: "background child" },
|
||||
metadata: { status: "running", sessionID: "child" },
|
||||
content: [{ type: "text", text: "backgrounded" }],
|
||||
})
|
||||
expect(store.messages).toBe(messages)
|
||||
expect(background.blocking().map((task) => task.partID)).toEqual(["shell-part"])
|
||||
setStore("status", "child", "idle")
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["child", "old-child"])
|
||||
expect(background.tasks()[0]?.label).toBe("background child")
|
||||
setStore("notification", "metadata", "childID", "child")
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["old-child"])
|
||||
setStore("messages", [0, 1], "time", "completed", 1)
|
||||
expect(background.blocking()).toEqual([])
|
||||
expect(background.tasks().map((task) => task.id)).toEqual(["old-child", "shell"])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,8 +15,10 @@ function createFixture(initial: Record<string, Session> = {}) {
|
||||
const [cache, setCache] = createSignal(initial)
|
||||
const deferred = new Map<string, PromiseWithResolvers<unknown>>()
|
||||
const resolves: string[] = []
|
||||
const messages = { syncs: [] as string[], ...Promise.withResolvers<unknown>() }
|
||||
return {
|
||||
resolves,
|
||||
messages,
|
||||
sessions: {
|
||||
get: (id: string) => cache()[id],
|
||||
sync: (id: string) => {
|
||||
@@ -25,6 +27,12 @@ function createFixture(initial: Record<string, Session> = {}) {
|
||||
deferred.set(id, entry)
|
||||
return entry.promise
|
||||
},
|
||||
message: {
|
||||
sync: (id: string) => {
|
||||
messages.syncs.push(id)
|
||||
return messages.promise
|
||||
},
|
||||
},
|
||||
},
|
||||
settle(id: string) {
|
||||
setCache({ ...cache(), [id]: sessionOf(id) })
|
||||
@@ -51,7 +59,34 @@ const flush = async () => {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
test("resolves an uncached session", async () => {
|
||||
test("starts metadata and messages in parallel once the route has a session ID", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const fixture = createFixture()
|
||||
const [id, setId] = createSignal<string>()
|
||||
const current = createSessionResolution(id, () => fixture.sessions)
|
||||
|
||||
expect(current()).toBeUndefined()
|
||||
await flush()
|
||||
expect(fixture.resolves).toEqual([])
|
||||
expect(fixture.messages.syncs).toEqual([])
|
||||
|
||||
setId("ses_a")
|
||||
expect(fixture.resolves).toEqual(["ses_a"])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a"])
|
||||
|
||||
fixture.messages.resolve(undefined)
|
||||
await flush()
|
||||
expect(current()).toBeUndefined()
|
||||
|
||||
fixture.settle("ses_a")
|
||||
await flush()
|
||||
expect(current()?.id).toBe("ses_a")
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("message failure does not fail metadata resolution", async () => {
|
||||
await createRoot(async (dispose) => {
|
||||
const fixture = createFixture()
|
||||
const current = createSessionResolution(
|
||||
@@ -59,9 +94,10 @@ test("resolves an uncached session", async () => {
|
||||
() => fixture.sessions,
|
||||
)
|
||||
|
||||
expect(current()).toBeUndefined()
|
||||
await flush()
|
||||
expect(fixture.resolves).toEqual(["ses_a"])
|
||||
fixture.messages.reject(new Error("message sync failed"))
|
||||
await flush()
|
||||
expect(current()).toBeUndefined()
|
||||
|
||||
fixture.settle("ses_a")
|
||||
await flush()
|
||||
@@ -82,12 +118,15 @@ test("re-resolves when navigating to an uncached session without a remount", asy
|
||||
|
||||
await flush()
|
||||
expect(current()?.id).toBe("ses_a")
|
||||
expect(fixture.resolves).toEqual([])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a"])
|
||||
|
||||
expect(() => {
|
||||
setId("ses_b")
|
||||
current()
|
||||
}).not.toThrow()
|
||||
expect(fixture.resolves).toEqual(["ses_b"])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b"])
|
||||
|
||||
fixture.settle("ses_b")
|
||||
await flush()
|
||||
@@ -139,6 +178,7 @@ test("returning to a pruned session re-resolves instead of throwing not found",
|
||||
current()
|
||||
}).not.toThrow()
|
||||
expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b", "ses_a"])
|
||||
|
||||
fixture.settle("ses_a")
|
||||
await flush()
|
||||
@@ -167,6 +207,7 @@ test("revisiting a session whose resolution failed while unfocused retries clean
|
||||
current()
|
||||
}).not.toThrow()
|
||||
expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
|
||||
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b", "ses_a"])
|
||||
|
||||
fixture.settle("ses_a")
|
||||
await flush()
|
||||
@@ -197,6 +238,8 @@ test("re-resolves against a replaced session store", async () => {
|
||||
}).not.toThrow()
|
||||
await flush()
|
||||
expect(second.resolves).toEqual(["ses_a"])
|
||||
expect(first.messages.syncs).toEqual(["ses_a"])
|
||||
expect(second.messages.syncs).toEqual(["ses_a"])
|
||||
|
||||
second.settle("ses_a")
|
||||
await flush()
|
||||
|
||||
@@ -198,3 +198,41 @@ test("stale pinned indexes do not produce missing virtual items after count shri
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("snapshots materialize only measured rows and restore their current geometry", () => {
|
||||
const options = {
|
||||
count: 100,
|
||||
getItemKey: (index: number) => `row-${index}`,
|
||||
estimateSize: () => 60,
|
||||
getScrollElement: () => null,
|
||||
scrollToFn: () => {},
|
||||
observeElementRect: () => {},
|
||||
observeElementOffset: () => {},
|
||||
}
|
||||
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options)
|
||||
expect(virtualizer.getTotalSize()).toBe(6000)
|
||||
virtualizer.resizeItem(4, 100)
|
||||
virtualizer.resizeItem(99, 140)
|
||||
const measurements = virtualizer.getMeasurements()
|
||||
const reads: number[] = []
|
||||
virtualizer.getMeasurements = () =>
|
||||
new Proxy(measurements, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key === "string" && /^\d+$/.test(key)) reads.push(Number(key))
|
||||
return Reflect.get(target, key, receiver)
|
||||
},
|
||||
})
|
||||
|
||||
const snapshot = virtualizer.takeSnapshot()
|
||||
expect(reads).toEqual([4, 99])
|
||||
expect(snapshot).toEqual([
|
||||
{ index: 4, key: "row-4", start: 240, size: 100, end: 340, lane: 0 },
|
||||
{ index: 99, key: "row-99", start: 5980, size: 140, end: 6120, lane: 0 },
|
||||
])
|
||||
const restored = new Virtualizer<HTMLDivElement, HTMLDivElement>({ ...options, initialMeasurementsCache: snapshot })
|
||||
expect(restored.getTotalSize()).toBe(6120)
|
||||
expect(restored.takeSnapshot()).toEqual(snapshot)
|
||||
restored.setOptions({ ...options, count: 10 })
|
||||
restored.resizeItem(4, 80)
|
||||
expect(restored.takeSnapshot()).toEqual([{ index: 4, key: "row-4", start: 240, size: 80, end: 320, lane: 0 }])
|
||||
})
|
||||
|
||||
@@ -107,6 +107,17 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/mistral":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/mistral",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...mapMistralOptions(input.settings),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
...(isRecord(input.settings.extraBody) ? { body: input.settings.extraBody } : {}),
|
||||
}
|
||||
case "@ai-sdk/openai":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openai",
|
||||
@@ -283,6 +294,20 @@ function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function mapMistralOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.safePrompt === "boolean" ? { safePrompt: settings.safePrompt } : {}),
|
||||
...(typeof settings.documentImageLimit === "number" ? { documentImageLimit: settings.documentImageLimit } : {}),
|
||||
...(typeof settings.documentPageLimit === "number" ? { documentPageLimit: settings.documentPageLimit } : {}),
|
||||
...(typeof settings.parallelToolCalls === "boolean" ? { parallelToolCalls: settings.parallelToolCalls } : {}),
|
||||
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(settings.promptMode === "reasoning" ? { promptMode: settings.promptMode } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
|
||||
return {
|
||||
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
|
||||
|
||||
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
const servers = new Map<string, ServerConfig>()
|
||||
for (const document of documents) {
|
||||
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
|
||||
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
servers.set(name, server)
|
||||
}
|
||||
}
|
||||
for (const [name, server] of servers) {
|
||||
if (draft.get(name)) continue
|
||||
draft.set(name, server)
|
||||
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,9 +78,15 @@ export const make = Effect.gen(function* () {
|
||||
...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const),
|
||||
]
|
||||
for (const [mime, encode] of encoders) {
|
||||
const candidate = Buffer.from(encode()).toString("base64")
|
||||
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
|
||||
return { ...content, content: candidate, encoding: "base64" as const, mime }
|
||||
const candidate = encode()
|
||||
// Base64 uses four bytes per three input bytes, including padding.
|
||||
if (Math.ceil(candidate.length / 3) * 4 <= limits.maxBase64Bytes)
|
||||
return {
|
||||
...content,
|
||||
content: Buffer.from(candidate).toString("base64"),
|
||||
encoding: "base64" as const,
|
||||
mime,
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resized.free()
|
||||
|
||||
@@ -45,6 +45,8 @@ import { InstructionBuiltIns } from "./instructions/builtins.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { SessionInstructions } from "./session/instructions.js"
|
||||
import { SessionGenerateNode } from "./session/generate-node.js"
|
||||
import { SessionPromptNode } from "./session/prompt-node.js"
|
||||
import { SessionRevertNode } from "./session/revert-node.js"
|
||||
import { McpTool } from "./tool/mcp.js"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
@@ -92,6 +94,8 @@ const nodes = [
|
||||
Form.node,
|
||||
Generate.node,
|
||||
SessionGenerateNode.node,
|
||||
SessionPromptNode.node,
|
||||
SessionRevertNode.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
|
||||
@@ -338,6 +338,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@ai-sdk/openai-compatible" ||
|
||||
name === "@ai-sdk/google" ||
|
||||
name === "@ai-sdk/groq" ||
|
||||
name === "@ai-sdk/mistral" ||
|
||||
name === "@ai-sdk/togetherai" ||
|
||||
name === "@ai-sdk/xai" ||
|
||||
name === "@openrouter/ai-sdk-provider" ||
|
||||
@@ -351,6 +352,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/groq" ||
|
||||
name === "@opencode-ai/ai/providers/mistral" ||
|
||||
name === "@opencode-ai/ai/providers/togetherai" ||
|
||||
name === "@opencode-ai/ai/providers/xai" ||
|
||||
name === "@opencode-ai/ai/providers/openrouter" ||
|
||||
|
||||
@@ -636,8 +636,8 @@ export const layer = (options?: Options) =>
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
|
||||
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
|
||||
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string, digest = bodyDigest(text)) {
|
||||
yield* kv.set(key, { updatedAt: Date.now(), digest, body: text }).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
@@ -681,12 +681,13 @@ export const layer = (options?: Options) =>
|
||||
const stored = yield* loadFromCache()
|
||||
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
|
||||
const text = yield* fetchApi()
|
||||
const digest = bodyDigest(text)
|
||||
// models.dev rarely changes between polls; skip the cache write,
|
||||
// invalidation, and Refreshed event for a byte-identical body so
|
||||
// downstream catalog.updated listeners stay quiet.
|
||||
if (!force && stored?.digest === bodyDigest(text)) return
|
||||
if (!force && stored?.digest === digest) return
|
||||
yield* decodeCatalog(text)
|
||||
yield* writeCache(text)
|
||||
yield* writeCache(text, digest)
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
}),
|
||||
|
||||
@@ -62,6 +62,7 @@ export const root = Effect.fn("Project.root")(function* (
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
/** Resolves and persists the owning Project. */
|
||||
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||
],
|
||||
["@opencode-ai/ai/providers/groq", () => import("@opencode-ai/ai/providers/groq")],
|
||||
["@opencode-ai/ai/providers/mistral", () => import("@opencode-ai/ai/providers/mistral")],
|
||||
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
|
||||
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
|
||||
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
|
||||
|
||||
+94
-757
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ export * as SessionErrors from "./error.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
@@ -10,6 +11,35 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Session.
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()("Session.MessageNotFoundError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export class MessageNotAssistantError extends Schema.TaggedError<MessageNotAssistantError>()(
|
||||
"Session.MessageNotAssistantError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessageIncompleteError extends Schema.TaggedError<MessageIncompleteError>()(
|
||||
"Session.MessageIncompleteError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessageToolIncompleteError extends Schema.TaggedError<MessageToolIncompleteError>()(
|
||||
"Session.MessageToolIncompleteError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ForkEmptyError extends Schema.TaggedError<ForkEmptyError>()("Session.ForkEmptyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {
|
||||
@@ -52,3 +82,42 @@ export class UserInterruptedError extends Schema.TaggedError<UserInterruptedErro
|
||||
return "Session interrupted by user"
|
||||
}
|
||||
}
|
||||
|
||||
export class PromptConflictError extends Schema.TaggedError<PromptConflictError>()("Session.PromptConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export class SyntheticConflictError extends Schema.TaggedError<SyntheticConflictError>()(
|
||||
"Session.SyntheticConflictError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class AttachmentError extends Schema.TaggedError<AttachmentError>()("Session.AttachmentError", {
|
||||
uri: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class CompactionConflictError extends Schema.TaggedError<CompactionConflictError>()(
|
||||
"Session.CompactionConflictError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class BusyError extends Schema.TaggedError<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()("Session.InboxConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
inboxID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export class SkillNotFoundError extends Schema.TaggedError<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: Skill.ID,
|
||||
}) {}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { SessionInbox } from "./inbox.js"
|
||||
export interface Interface {
|
||||
/** Snapshots active execution owned by this process. */
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
/** Checks process-local ownership, including interruption cleanup and terminal settlement. */
|
||||
readonly isActive: (sessionID: SessionSchema.ID) => Effect.Effect<boolean>
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
@@ -142,6 +144,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
active: coordinator.active,
|
||||
isActive: coordinator.isActive,
|
||||
interrupt: (sessionID, options) =>
|
||||
Effect.gen(function* () {
|
||||
const interrupted = yield* coordinator.interrupt(sessionID, "user")
|
||||
@@ -178,6 +181,7 @@ export const noopLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
isActive: () => Effect.succeed(false),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
|
||||
@@ -157,7 +157,7 @@ export const layer = (options?: Options) =>
|
||||
yield* notify(background)
|
||||
return
|
||||
}
|
||||
if ((yield* execution.active).has(recovery.childSessionID)) return
|
||||
if (yield* execution.isActive(recovery.childSessionID)) return
|
||||
if (!(yield* prepareResume(recovery.childSessionID))) {
|
||||
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
|
||||
return
|
||||
|
||||
+143
-111
@@ -1,7 +1,8 @@
|
||||
export * as SessionInbox from "./inbox.js"
|
||||
|
||||
import { and, asc, eq, or } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import {
|
||||
Compaction,
|
||||
CompactionPayload,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
User,
|
||||
UserPayload,
|
||||
} from "@opencode-ai/schema/session-inbox"
|
||||
import type { Database } from "../database/database.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
@@ -65,6 +66,13 @@ export class LifecycleConflict extends Schema.TaggedError<LifecycleConflict>()("
|
||||
id: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
function matches<Type extends Item["type"]>(
|
||||
stored: Info,
|
||||
request: PendingRef & { readonly type: Type },
|
||||
): stored is Extract<Info, { readonly type: Type }> {
|
||||
return stored.sessionID === request.sessionID && stored.type === request.type
|
||||
}
|
||||
|
||||
const fromRow = (row: typeof SessionInboxTable.$inferSelect): Info => {
|
||||
const base = {
|
||||
id: SessionMessage.ID.make(row.id),
|
||||
@@ -116,7 +124,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
|
||||
.pipe(Effect.orDie)
|
||||
if (row === undefined) return undefined
|
||||
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
return yield* new LifecycleConflict({ id })
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const base = { id, sessionID, timeCreated: message.time.created, delivery }
|
||||
if (message.type === "user")
|
||||
@@ -131,89 +139,140 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
|
||||
type: "synthetic",
|
||||
payload: decodeSynthetic(message),
|
||||
})
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
return yield* new LifecycleConflict({ id })
|
||||
})
|
||||
|
||||
/** Reconciles pending or delivered work without preparing a new admission payload. */
|
||||
export const reconcile = Effect.fn("SessionInbox.reconcile")(function* (
|
||||
db: DatabaseService,
|
||||
request: {
|
||||
export type Interface = Effect.Success<ReturnType<typeof make>>
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionInbox") {}
|
||||
|
||||
export const make = Effect.fn("SessionInbox.make")(function* () {
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
/** First admission wins for matching Session and type, without preparing a new payload. */
|
||||
const reconcile = Effect.fn("SessionInbox.reconcile")(function* <Type extends Item["type"]>(request: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly type: Type
|
||||
readonly delivery: Delivery
|
||||
}) {
|
||||
const existing =
|
||||
(yield* find(db, request.id)) ?? (yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery))
|
||||
if (existing === undefined) return undefined
|
||||
if (existing.type === "compaction" || !matches(existing, request))
|
||||
return yield* new LifecycleConflict({ id: request.id })
|
||||
return existing
|
||||
})
|
||||
|
||||
const admit = Effect.fn("SessionInbox.admit")(function* <Type extends Item["type"]>(request: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly item: Item & { readonly type: Type }
|
||||
}) {
|
||||
const existing = yield* reconcile({ ...request, type: request.item.type, delivery: request.item.delivery })
|
||||
if (existing !== undefined) return existing
|
||||
const admitted = yield* bus
|
||||
.publish(SessionEvent.InboxEnqueued, {
|
||||
inboxID: request.id,
|
||||
sessionID: request.sessionID,
|
||||
item: request.item,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((event) =>
|
||||
Info.make({
|
||||
id: request.id,
|
||||
sessionID: request.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(event.created),
|
||||
...request.item,
|
||||
}),
|
||||
),
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof LifecycleConflict
|
||||
? find(db, request.id).pipe(
|
||||
Effect.flatMap((stored) => (stored === undefined ? Effect.fail(defect) : Effect.succeed(stored))),
|
||||
)
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (!matches(admitted, { ...request, type: request.item.type }))
|
||||
return yield* new LifecycleConflict({ id: request.id })
|
||||
return admitted
|
||||
})
|
||||
|
||||
const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly delivery: Delivery
|
||||
},
|
||||
) {
|
||||
const existing = yield* find(db, request.id)
|
||||
if (existing !== undefined) {
|
||||
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||
return existing
|
||||
}
|
||||
return yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery)
|
||||
})
|
||||
|
||||
export const admit = Effect.fn("SessionInbox.admit")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
request: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly item: Item
|
||||
},
|
||||
) {
|
||||
const existing = yield* reconcile(db, { ...request, delivery: request.item.delivery })
|
||||
if (existing !== undefined) return existing
|
||||
return yield* bus
|
||||
.publish(SessionEvent.InboxEnqueued, {
|
||||
inboxID: request.id,
|
||||
sessionID: request.sessionID,
|
||||
item: request.item,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((event) =>
|
||||
Info.make({
|
||||
id: request.id,
|
||||
sessionID: request.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(event.created),
|
||||
...request.item,
|
||||
}),
|
||||
),
|
||||
Effect.catchDefect((defect) =>
|
||||
find(db, request.id).pipe(
|
||||
Effect.flatMap((stored) =>
|
||||
stored?.type === request.item.type ? Effect.succeed(stored) : Effect.die(defect),
|
||||
),
|
||||
),
|
||||
),
|
||||
}) {
|
||||
return yield* serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const exact = yield* find(db, input.id)
|
||||
if (exact) {
|
||||
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
|
||||
return yield* new LifecycleConflict({ id: input.id })
|
||||
}
|
||||
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
|
||||
return yield* new LifecycleConflict({ id: input.id })
|
||||
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
|
||||
if (pending) return pending
|
||||
return yield* admit({
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
item: { type: "compaction", payload: {}, delivery: Delivery.make(input.delivery) },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const cancel = Effect.fn("SessionInbox.cancel")((input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InboxCancelled, {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const steer = Effect.fn("SessionInbox.steer")((input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InboxDeliveryChanged, {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.id,
|
||||
delivery: "steer",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const queue = Effect.fn("SessionInbox.queue")((input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InboxDeliveryChanged, {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.id,
|
||||
delivery: "queue",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
list: (sessionID: SessionSchema.ID) => list(db, sessionID),
|
||||
reconcile,
|
||||
admit,
|
||||
admitCompaction,
|
||||
cancel,
|
||||
steer,
|
||||
queue,
|
||||
}
|
||||
})
|
||||
|
||||
export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
|
||||
) {
|
||||
return yield* serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const exact = yield* find(db, input.id)
|
||||
if (exact) {
|
||||
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
}
|
||||
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
|
||||
if (pending) return pending
|
||||
const admitted = yield* admit(db, bus, {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
|
||||
})
|
||||
if (admitted.type === "compaction") return admitted
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
}),
|
||||
)
|
||||
})
|
||||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
|
||||
|
||||
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -392,39 +451,11 @@ export const has = Effect.fn("SessionInbox.has")(function* (
|
||||
})
|
||||
|
||||
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
|
||||
serialized(input.sessionID, effect).pipe(Effect.asVoid)
|
||||
|
||||
export const cancel = Effect.fn("SessionInbox.cancel")((bus: Bus.Interface, input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InboxCancelled, {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const steer = Effect.fn("SessionInbox.steer")((bus: Bus.Interface, input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InboxDeliveryChanged, {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.id,
|
||||
delivery: "steer",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const queue = Effect.fn("SessionInbox.queue")((bus: Bus.Interface, input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InboxDeliveryChanged, {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.id,
|
||||
delivery: "queue",
|
||||
}),
|
||||
),
|
||||
)
|
||||
serialized(input.sessionID, effect).pipe(
|
||||
Effect.asVoid,
|
||||
// Bus projectors abort their transaction through the defect channel.
|
||||
Effect.catchDefect((defect) => (defect instanceof LifecycleConflict ? Effect.fail(defect) : Effect.die(defect))),
|
||||
)
|
||||
|
||||
const publish = Effect.fn("SessionInbox.publish")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -447,6 +478,7 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
|
||||
defect instanceof LifecycleConflict
|
||||
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
|
||||
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
||||
Effect.orDie,
|
||||
)
|
||||
: Effect.die(defect),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export * as SessionPromptNode from "./prompt-node.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Image } from "../image.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import { SessionPrompt } from "./prompt.js"
|
||||
|
||||
// Keep the supervisor implementation out of the global Session import path.
|
||||
export const node = makeLocationNode({
|
||||
service: SessionPrompt.Service,
|
||||
layer: SessionPrompt.layer,
|
||||
deps: [FSUtil.node, PluginSupervisor.node, PluginHooks.node, Image.node, Skill.node],
|
||||
})
|
||||
@@ -0,0 +1,231 @@
|
||||
export * as SessionPrompt from "./prompt.js"
|
||||
|
||||
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Image } from "../image.js"
|
||||
import { Mime } from "../mime.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor-service.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import { AttachmentError, SkillNotFoundError } from "./error.js"
|
||||
|
||||
export type Input = {
|
||||
text: string
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
skills?: PromptInput.Prompt["skills"]
|
||||
metadata?: Record<string, unknown>
|
||||
delivery?: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
export const make = Effect.fn("SessionPrompt.make")(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const image = yield* Image.Service
|
||||
const skillService = yield* Skill.Service
|
||||
|
||||
const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
|
||||
sessionID: Session.ID
|
||||
messageID: SessionMessage.ID
|
||||
input: Input
|
||||
}) {
|
||||
yield* plugins.flush
|
||||
const event = yield* hooks.trigger("session", "prompt", {
|
||||
sessionID: request.sessionID,
|
||||
messageID: request.messageID,
|
||||
prompt: structuredClone({
|
||||
text: request.input.text,
|
||||
files: request.input.files?.slice(),
|
||||
agents: request.input.agents?.slice(),
|
||||
skills: request.input.skills?.slice(),
|
||||
}),
|
||||
metadata: structuredClone(request.input.metadata),
|
||||
delivery: request.input.delivery ?? "steer",
|
||||
})
|
||||
const input = event.prompt
|
||||
const files = input.files
|
||||
? yield* Effect.forEach(input.files, materializeAttachment, { concurrency: 8 })
|
||||
: undefined
|
||||
const requested = input.skills
|
||||
const selected = yield* Effect.gen(function* () {
|
||||
if (!requested?.length) return undefined
|
||||
const prepared = new Map<Skill.ID, Skill.Name>()
|
||||
return yield* Effect.forEach(requested, (attachment) =>
|
||||
Effect.gen(function* () {
|
||||
const name = prepared.get(attachment.id)
|
||||
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
|
||||
const skill = yield* skillService.get(attachment.id)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
|
||||
prepared.set(skill.id, skill.name)
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
|
||||
mention: attachment.mention,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
return {
|
||||
type: "user",
|
||||
payload: SessionInbox.UserPayload.make({
|
||||
...Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
files,
|
||||
skills: selected?.length ? selected : undefined,
|
||||
}),
|
||||
metadata: event.metadata,
|
||||
}),
|
||||
delivery: SessionInbox.Delivery.make(event.delivery),
|
||||
} satisfies SessionInbox.Item
|
||||
})
|
||||
|
||||
const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(function* (
|
||||
input: PromptInput.FileAttachment,
|
||||
) {
|
||||
const resolved = input.uri.startsWith("data:")
|
||||
? {
|
||||
bytes: yield* decodeDataURL(input.uri),
|
||||
source: { type: "inline" as const },
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
name: undefined,
|
||||
mime: undefined,
|
||||
}
|
||||
: yield* readFileAttachment(input.uri)
|
||||
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
|
||||
return yield* new AttachmentError({
|
||||
uri: input.uri,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
|
||||
})
|
||||
|
||||
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
|
||||
const content =
|
||||
mime === "text/plain" && resolved.start !== undefined
|
||||
? Buffer.from(
|
||||
Buffer.from(resolved.bytes)
|
||||
.toString("utf8")
|
||||
.split("\n")
|
||||
.slice(resolved.start - 1, resolved.end)
|
||||
.join("\n"),
|
||||
)
|
||||
: resolved.bytes
|
||||
const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime)
|
||||
return FileAttachment.create({
|
||||
data: normalized.data,
|
||||
mime: normalized.mime,
|
||||
source: resolved.source,
|
||||
name: input.name ?? resolved.name,
|
||||
description: input.description,
|
||||
mention: input.mention,
|
||||
})
|
||||
})
|
||||
|
||||
const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachment")(function* (
|
||||
input: PromptInput.FileAttachment,
|
||||
data: string,
|
||||
mime: string,
|
||||
) {
|
||||
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
|
||||
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||
const content = { uri: label, content: data, encoding: "base64" as const, mime }
|
||||
const normalized = yield* image.normalize(label, content).pipe(
|
||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
|
||||
Effect.mapError((error) => new AttachmentError({ uri: label, message: error.message })),
|
||||
)
|
||||
return { data: Base64.make(normalized.content), mime: normalized.mime }
|
||||
})
|
||||
|
||||
const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(function* (uri: string) {
|
||||
const url = yield* Effect.try({
|
||||
try: () => new URL(uri),
|
||||
catch: () => new AttachmentError({ uri, message: `Invalid attachment URI: ${uri}` }),
|
||||
})
|
||||
if (url.protocol !== "file:")
|
||||
return yield* new AttachmentError({ uri, message: `Unsupported attachment URI: ${uri}` })
|
||||
const start = positiveInt(url.searchParams.get("start"))
|
||||
const end = positiveInt(url.searchParams.get("end"))
|
||||
const target = yield* Effect.try({
|
||||
try: () => {
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
return fileURLToPath(url)
|
||||
},
|
||||
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
|
||||
})
|
||||
const info = yield* fs
|
||||
.stat(target)
|
||||
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
|
||||
if (info.type === "Directory") {
|
||||
const entries = yield* fs
|
||||
.readDirectoryEntries(target)
|
||||
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
|
||||
return {
|
||||
bytes: Buffer.from(
|
||||
entries
|
||||
.filter((entry) => entry.type === "file" || entry.type === "directory")
|
||||
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1))
|
||||
.map((entry) => entry.name + (entry.type === "directory" ? path.sep : ""))
|
||||
.join("\n"),
|
||||
),
|
||||
source: { type: "uri" as const, uri },
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
name: path.basename(target),
|
||||
mime: "application/x-directory",
|
||||
}
|
||||
}
|
||||
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
|
||||
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
|
||||
return yield* new AttachmentError({
|
||||
uri,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
|
||||
})
|
||||
const bytes = yield* fs
|
||||
.readFile(target)
|
||||
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
|
||||
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined }
|
||||
})
|
||||
|
||||
return { prepare }
|
||||
})
|
||||
|
||||
export type Interface = Effect.Success<ReturnType<typeof make>>
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionPrompt") {}
|
||||
|
||||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
function decodeDataURL(uri: string) {
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const comma = uri.indexOf(",")
|
||||
if (comma === -1) throw new Error("Invalid data URL")
|
||||
const metadata = uri.slice(5, comma)
|
||||
const payload = uri.slice(comma + 1)
|
||||
if (!metadata.split(";").some((part) => part.toLowerCase() === "base64"))
|
||||
return Buffer.from(decodeURIComponent(payload))
|
||||
const bytes = Buffer.from(payload, "base64")
|
||||
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
|
||||
return bytes
|
||||
},
|
||||
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
|
||||
})
|
||||
}
|
||||
|
||||
function positiveInt(value: string | null) {
|
||||
if (value === null) return
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export * as SessionRevertNode from "./revert-node.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { SessionRevert } from "./revert.js"
|
||||
|
||||
// Keep the supervisor implementation out of the global Session import path.
|
||||
export const node = makeLocationNode({
|
||||
service: SessionRevert.Service,
|
||||
layer: SessionRevert.layer,
|
||||
deps: [Database.node, Bus.node, PluginSupervisor.node, Snapshot.node],
|
||||
})
|
||||
@@ -1,28 +1,97 @@
|
||||
export * as SessionRevert from "./revert.js"
|
||||
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor-service.js"
|
||||
import { RelativePath } from "../schema.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()("Session.MessageNotFoundError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
export { MessageNotFoundError }
|
||||
|
||||
interface BoundaryInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
}
|
||||
|
||||
const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
|
||||
const db = (yield* Database.Service).db
|
||||
export interface Interface {
|
||||
readonly stage: (input: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly files?: boolean
|
||||
}) => Effect.Effect<SessionSchema.Revert, MessageNotFoundError | Snapshot.Error>
|
||||
readonly clear: (session: SessionSchema.Info) => Effect.Effect<void, Snapshot.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
|
||||
|
||||
export const make = Effect.fn("SessionRevert.make")(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
|
||||
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
|
||||
yield* plugins.flush
|
||||
const original = input.session.revert?.snapshot
|
||||
? Snapshot.ID.make(input.session.revert.snapshot)
|
||||
: yield* snapshot.capture()
|
||||
const next = yield* plan(database.db, { sessionID: input.session.id, messageID: input.messageID })
|
||||
const restore = new Map<RelativePath, Snapshot.ID>()
|
||||
if (original) {
|
||||
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
|
||||
}
|
||||
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
|
||||
if (restore.size) yield* snapshot.restore({ files: restore })
|
||||
const paths = input.files === false ? [] : Array.from(next.keys())
|
||||
const files = original
|
||||
? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths })
|
||||
: []
|
||||
const revert = {
|
||||
messageID: input.messageID,
|
||||
snapshot: original,
|
||||
files,
|
||||
} satisfies SessionSchema.Info["revert"]
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID: input.session.id,
|
||||
revert,
|
||||
})
|
||||
return revert
|
||||
})
|
||||
|
||||
const clear: Interface["clear"] = Effect.fn("SessionRevert.clear")(function* (session) {
|
||||
yield* plugins.flush
|
||||
if (!session.revert) return
|
||||
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
|
||||
if (original)
|
||||
yield* snapshot.restore({
|
||||
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
|
||||
})
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Cleared, {
|
||||
sessionID: session.id,
|
||||
})
|
||||
})
|
||||
|
||||
return { stage, clear }
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) {
|
||||
if (!session.revert) return
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID: session.id,
|
||||
to: session.revert.messageID,
|
||||
})
|
||||
})
|
||||
|
||||
const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["db"], input: BoundaryInput) {
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
@@ -53,59 +122,3 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly files?: boolean
|
||||
}) {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const bus = yield* Bus.Service
|
||||
const original = input.session.revert?.snapshot
|
||||
? Snapshot.ID.make(input.session.revert.snapshot)
|
||||
: yield* snapshot.capture()
|
||||
const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
|
||||
const restore = new Map<RelativePath, Snapshot.ID>()
|
||||
if (original) {
|
||||
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
|
||||
}
|
||||
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
|
||||
if (restore.size) yield* snapshot.restore({ files: restore })
|
||||
const paths = input.files === false ? [] : Array.from(next.keys())
|
||||
const files = original
|
||||
? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths })
|
||||
: []
|
||||
const revert = {
|
||||
messageID: input.messageID,
|
||||
snapshot: original,
|
||||
files,
|
||||
} satisfies SessionSchema.Info["revert"]
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID: input.session.id,
|
||||
revert,
|
||||
})
|
||||
return revert
|
||||
})
|
||||
|
||||
export const clear = Effect.fn("SessionRevert.clear")(function* (session: SessionSchema.Info) {
|
||||
if (!session.revert) return
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
|
||||
if (original)
|
||||
yield* snapshot.restore({
|
||||
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
|
||||
})
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Cleared, {
|
||||
sessionID: session.id,
|
||||
})
|
||||
})
|
||||
|
||||
export const commit = Effect.fn("SessionRevert.commit")(function* (session: SessionSchema.Info) {
|
||||
if (!session.revert) return
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID: session.id,
|
||||
to: session.revert.messageID,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { Promotable } from "./inbox.js"
|
||||
export interface Coordinator<Key, E, Reason = never> {
|
||||
/** Snapshots keys with an execution owned by this coordinator. */
|
||||
readonly active: Effect.Effect<ReadonlySet<Key>>
|
||||
/** Checks ownership for one key, including cleanup and terminal settlement. */
|
||||
readonly isActive: (key: Key) => Effect.Effect<boolean>
|
||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
@@ -112,6 +114,8 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
|
||||
const isActive = (key: Key) => Effect.sync(() => executions.has(key))
|
||||
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
@@ -166,5 +170,5 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
export * as Session from "./session.js"
|
||||
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Location } from "../location.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor-service.js"
|
||||
import { Shell } from "../shell.js"
|
||||
import { ShellResult } from "../shell/result.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import {
|
||||
BusyError,
|
||||
CompactionConflictError,
|
||||
InboxConflictError,
|
||||
MessageIncompleteError,
|
||||
MessageNotAssistantError,
|
||||
MessageNotFoundError,
|
||||
MessageToolIncompleteError,
|
||||
NotFoundError,
|
||||
PromptConflictError,
|
||||
SkillNotFoundError,
|
||||
SyntheticConflictError,
|
||||
} from "./error.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionExecution } from "./execution.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionPrompt } from "./prompt.js"
|
||||
import { SessionRevert } from "./revert.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
|
||||
export type Services =
|
||||
| PluginSupervisor.Service
|
||||
| SessionPrompt.Service
|
||||
| SessionRevert.Service
|
||||
| Shell.Service
|
||||
| Skill.Service
|
||||
|
||||
type PromptRequest = SessionPrompt.Input & {
|
||||
id?: SessionMessage.ID
|
||||
resume?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build once in the host Scope: `const sessions = yield* Session.make(servicesFor)`.
|
||||
* Use `sessions.forSession(id)` for handles that share host services and reload current state.
|
||||
*/
|
||||
export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Location.Ref) => Layer.Layer<Services>) {
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const admission = yield* SessionInbox.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const get = Effect.fn("Session.get")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
})
|
||||
const message = Effect.fn("Session.message")(function* (sessionID: SessionSchema.ID, messageID: SessionMessage.ID) {
|
||||
const stored = yield* store.message(messageID)
|
||||
return stored?.sessionID === sessionID ? stored.message : undefined
|
||||
})
|
||||
const updateMessage = Effect.fn("Session.updateMessage")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { readonly messageID: SessionMessage.ID; readonly content: readonly SessionMessage.AssistantContent[] },
|
||||
) {
|
||||
const ref = { sessionID, messageID: input.messageID }
|
||||
yield* get(sessionID)
|
||||
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
|
||||
const current = yield* message(sessionID, input.messageID)
|
||||
if (!current) return yield* new MessageNotFoundError(ref)
|
||||
if (current.type !== "assistant") return yield* new MessageNotAssistantError(ref)
|
||||
if (!current.time.completed) return yield* new MessageIncompleteError(ref)
|
||||
if (input.content.some(isUnfinishedTool)) return yield* new MessageToolIncompleteError(ref)
|
||||
yield* bus.publish(SessionEvent.MessageContentUpdated, {
|
||||
...ref,
|
||||
content: Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent))(input.content),
|
||||
})
|
||||
const updated = yield* message(sessionID, input.messageID)
|
||||
if (updated?.type !== "assistant") return yield* new MessageNotFoundError(ref)
|
||||
return updated
|
||||
})
|
||||
const view = Effect.fn("Session.view")(function* (sessionID: SessionSchema.ID, input: { idle: number }) {
|
||||
const session = yield* get(sessionID)
|
||||
if (
|
||||
session.time.idle === undefined ||
|
||||
input.idle > DateTime.toEpochMillis(session.time.idle) ||
|
||||
(session.time.viewed !== undefined && DateTime.toEpochMillis(session.time.viewed) >= input.idle)
|
||||
)
|
||||
return
|
||||
yield* bus.publish(SessionEvent.Viewed, { sessionID, idle: input.idle })
|
||||
})
|
||||
const rename = Effect.fn("Session.rename")(function* (sessionID: SessionSchema.ID, input: { title: string }) {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
|
||||
})
|
||||
const switchAgent = Effect.fn("Session.switchAgent")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { agent: Agent.ID },
|
||||
) {
|
||||
const session = yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: input.agent, previous: session.agent })
|
||||
})
|
||||
const switchModel = Effect.fn("Session.switchModel")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { model: Model.Ref },
|
||||
) {
|
||||
const session = yield* get(sessionID)
|
||||
if (
|
||||
session.model?.providerID === input.model.providerID &&
|
||||
session.model.id === input.model.id &&
|
||||
(session.model.variant ?? "default") === (input.model.variant ?? "default")
|
||||
)
|
||||
return
|
||||
yield* bus.publish(SessionEvent.ModelSelected, { sessionID, model: input.model, previous: session.model })
|
||||
})
|
||||
const mutatePending = (
|
||||
sessionID: SessionSchema.ID,
|
||||
inboxID: SessionMessage.ID,
|
||||
mutation: (input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
}) => Effect.Effect<void, SessionInbox.LifecycleConflict>,
|
||||
) =>
|
||||
mutation({ sessionID, id: inboxID }).pipe(
|
||||
Effect.catchTag("SessionInbox.LifecycleConflict", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* get(sessionID)
|
||||
return yield* new InboxConflictError({ sessionID, inboxID })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const inbox = Effect.fn("Session.inbox")(function* (sessionID: SessionSchema.ID) {
|
||||
yield* get(sessionID)
|
||||
return yield* admission.list(sessionID)
|
||||
})
|
||||
const cancelInbox = Effect.fn("Session.cancelInbox")(
|
||||
(sessionID: SessionSchema.ID, inboxID: SessionMessage.ID) => mutatePending(sessionID, inboxID, admission.cancel),
|
||||
Effect.uninterruptible,
|
||||
)
|
||||
const steerInbox = Effect.fn("Session.steerInbox")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
inboxID: SessionMessage.ID,
|
||||
) {
|
||||
yield* mutatePending(sessionID, inboxID, admission.steer)
|
||||
yield* execution.wake(sessionID)
|
||||
}, Effect.uninterruptible)
|
||||
const queueInbox = Effect.fn("Session.queueInbox")(
|
||||
(sessionID: SessionSchema.ID, inboxID: SessionMessage.ID) => mutatePending(sessionID, inboxID, admission.queue),
|
||||
Effect.uninterruptible,
|
||||
)
|
||||
const prompt = Effect.fn("Session.prompt")((sessionID: SessionSchema.ID, input: PromptRequest) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* get(sessionID)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const admitted = yield* Effect.gen(function* () {
|
||||
const existing = yield* admission.reconcile({
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
type: "user",
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
if (existing) return existing
|
||||
const item = yield* restore(
|
||||
SessionPrompt.Service.use((preparation) => preparation.prepare({ sessionID, messageID, input })).pipe(
|
||||
Effect.provide(servicesFor(session.location)),
|
||||
),
|
||||
)
|
||||
// Commit a staged revert only after preparation succeeds, before admitting new work.
|
||||
if (session.revert) yield* SessionRevert.commit(bus, session)
|
||||
return yield* admission.admit({
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
item,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchTag("SessionInbox.LifecycleConflict", () => new PromptConflictError({ sessionID, messageID })),
|
||||
)
|
||||
if (input.resume !== false) yield* execution.wake(sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
)
|
||||
const shell = Effect.fn("Session.shell")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { id?: Event.ID; command: string },
|
||||
) {
|
||||
const session = yield* get(sessionID)
|
||||
// The server owns completion recording even if the submitting client disconnects.
|
||||
const running = yield* Effect.gen(function* () {
|
||||
// Resolve shell services here without pinning Session events to this Location after a move.
|
||||
const shell = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Shell.Service
|
||||
}).pipe(Effect.provide(servicesFor(session.location)))
|
||||
const started = yield* shell
|
||||
.create({
|
||||
command: input.command,
|
||||
cwd: session.location.directory,
|
||||
timeout: 0,
|
||||
metadata: { sessionID, background: true },
|
||||
})
|
||||
.pipe(
|
||||
Effect.tapError((error) =>
|
||||
synthetic(sessionID, {
|
||||
text: `User shell command failed to start:\n${input.command}\n\n${error.message}`,
|
||||
description: input.command,
|
||||
metadata: { source: "shell", state: "error" },
|
||||
resume: false,
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Shell.Started,
|
||||
{
|
||||
sessionID,
|
||||
shell: started,
|
||||
},
|
||||
{ id: input.id },
|
||||
)
|
||||
const terminal = yield* shell.result(started)
|
||||
const preview = yield* shell
|
||||
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable)))
|
||||
yield* bus.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID,
|
||||
shell: terminal.info,
|
||||
output: preview,
|
||||
})
|
||||
yield* synthetic(sessionID, {
|
||||
...ShellResult.userNotification(terminal),
|
||||
resume: false,
|
||||
}).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.void),
|
||||
Effect.orDie,
|
||||
)
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
yield* Fiber.join(running)
|
||||
})
|
||||
const skill = Effect.fn("Session.skill")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { id?: SessionMessage.ID; skill: Skill.ID; resume?: boolean },
|
||||
) {
|
||||
const session = yield* get(sessionID)
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(servicesFor(session.location)))
|
||||
const skill = yield* skills.get(input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* bus.publish(
|
||||
SessionEvent.Skill.Activated,
|
||||
{
|
||||
sessionID,
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: skill.content,
|
||||
},
|
||||
{ id: input.id ? Event.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined },
|
||||
)
|
||||
if (input.resume !== false)
|
||||
yield* execution
|
||||
.resume(sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
})
|
||||
const compact = Effect.fn("Session.compact")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { id?: SessionMessage.ID; delivery?: SessionInbox.Delivery },
|
||||
) {
|
||||
yield* get(sessionID)
|
||||
const inputID = input.id ?? SessionMessage.ID.create()
|
||||
const admitted = yield* admission
|
||||
.admitCompaction({
|
||||
id: inputID,
|
||||
sessionID,
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("SessionInbox.LifecycleConflict", () => new CompactionConflictError({ sessionID, inputID })),
|
||||
)
|
||||
yield* execution.wake(sessionID)
|
||||
return admitted
|
||||
})
|
||||
const wait = Effect.fn("Session.wait")(function* (sessionID: SessionSchema.ID) {
|
||||
yield* get(sessionID)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
})
|
||||
const resume = Effect.fn("Session.resume")(function* (sessionID: SessionSchema.ID) {
|
||||
yield* get(sessionID)
|
||||
yield* execution.resume(sessionID)
|
||||
})
|
||||
const synthetic = Effect.fn("Session.synthetic")(
|
||||
(
|
||||
sessionID: SessionSchema.ID,
|
||||
input: {
|
||||
id?: SessionMessage.ID
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
delivery?: SessionInbox.Delivery
|
||||
resume?: boolean
|
||||
},
|
||||
) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* get(sessionID)
|
||||
const inputID = input.id ?? SessionMessage.ID.create()
|
||||
const admittedInput = {
|
||||
type: "synthetic",
|
||||
payload: SessionInbox.SyntheticPayload.make({
|
||||
text: input.text,
|
||||
description: input.description,
|
||||
metadata: input.metadata,
|
||||
}),
|
||||
delivery: SessionInbox.Delivery.make(input.delivery ?? "steer"),
|
||||
} satisfies SessionInbox.Item
|
||||
const admitted = yield* admission
|
||||
.admit({
|
||||
id: inputID,
|
||||
sessionID,
|
||||
item: admittedInput,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag(
|
||||
"SessionInbox.LifecycleConflict",
|
||||
() => new SyntheticConflictError({ sessionID, inputID }),
|
||||
),
|
||||
)
|
||||
if (input.resume !== false && !(yield* get(sessionID)).revert) yield* execution.wake(sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
)
|
||||
const interrupt = Effect.fn("Session.interrupt")(
|
||||
(sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID, options)),
|
||||
)
|
||||
const stage = Effect.fn("Session.revert.stage")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { messageID: SessionMessage.ID; files?: boolean },
|
||||
) {
|
||||
const session = yield* get(sessionID)
|
||||
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
|
||||
return yield* SessionRevert.Service.use((revert) =>
|
||||
revert.stage({ session, messageID: input.messageID, files: input.files }),
|
||||
).pipe(Effect.provide(servicesFor(session.location)))
|
||||
})
|
||||
const clear = Effect.fn("Session.revert.clear")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* get(sessionID)
|
||||
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
|
||||
yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(
|
||||
Effect.provide(servicesFor(session.location)),
|
||||
)
|
||||
return yield* execution.wake(sessionID)
|
||||
})
|
||||
const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* get(sessionID)
|
||||
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
|
||||
return yield* SessionRevert.commit(bus, session)
|
||||
})
|
||||
const revert = { stage, clear, commit }
|
||||
const operations = {
|
||||
get,
|
||||
message,
|
||||
updateMessage,
|
||||
view,
|
||||
rename,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
inbox,
|
||||
prompt,
|
||||
synthetic,
|
||||
shell,
|
||||
skill,
|
||||
compact,
|
||||
wait,
|
||||
resume,
|
||||
interrupt,
|
||||
cancelInbox,
|
||||
steerInbox,
|
||||
queueInbox,
|
||||
revert,
|
||||
}
|
||||
|
||||
const forSession = (sessionID: SessionSchema.ID) => {
|
||||
const get = operations.get.bind(undefined, sessionID)
|
||||
const message = operations.message.bind(undefined, sessionID)
|
||||
const updateMessage = operations.updateMessage.bind(undefined, sessionID)
|
||||
const view = operations.view.bind(undefined, sessionID)
|
||||
const rename = operations.rename.bind(undefined, sessionID)
|
||||
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
|
||||
const switchModel = operations.switchModel.bind(undefined, sessionID)
|
||||
const inbox = operations.inbox.bind(undefined, sessionID)
|
||||
const prompt = operations.prompt.bind(undefined, sessionID)
|
||||
const synthetic = operations.synthetic.bind(undefined, sessionID)
|
||||
const shell = operations.shell.bind(undefined, sessionID)
|
||||
const skill = operations.skill.bind(undefined, sessionID)
|
||||
const compact = operations.compact.bind(undefined, sessionID)
|
||||
const wait = operations.wait.bind(undefined, sessionID)
|
||||
const resume = operations.resume.bind(undefined, sessionID)
|
||||
const interrupt = operations.interrupt.bind(undefined, sessionID)
|
||||
const cancelInbox = operations.cancelInbox.bind(undefined, sessionID)
|
||||
const steerInbox = operations.steerInbox.bind(undefined, sessionID)
|
||||
const queueInbox = operations.queueInbox.bind(undefined, sessionID)
|
||||
const stage = operations.revert.stage.bind(undefined, sessionID)
|
||||
const clear = operations.revert.clear.bind(undefined, sessionID)
|
||||
const commit = operations.revert.commit.bind(undefined, sessionID)
|
||||
const revert = { stage, clear, commit }
|
||||
|
||||
return {
|
||||
id: sessionID,
|
||||
get,
|
||||
message,
|
||||
updateMessage,
|
||||
view,
|
||||
rename,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
inbox,
|
||||
prompt,
|
||||
synthetic,
|
||||
shell,
|
||||
skill,
|
||||
compact,
|
||||
wait,
|
||||
resume,
|
||||
interrupt,
|
||||
cancelInbox,
|
||||
steerInbox,
|
||||
queueInbox,
|
||||
revert,
|
||||
}
|
||||
}
|
||||
return { forSession }
|
||||
})
|
||||
|
||||
export type Handle = ReturnType<Effect.Success<ReturnType<typeof make>>["forSession"]>
|
||||
|
||||
function isUnfinishedTool(content: SessionMessage.AssistantContent) {
|
||||
return content.type === "tool" && (content.state.status === "streaming" || content.state.status === "running")
|
||||
}
|
||||
|
||||
// Mirrors the shell tool's in-memory preview safety limit.
|
||||
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user