mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 21:46:19 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6b792845 | ||
|
|
0a751f162c | ||
|
|
424d1d308c | ||
|
|
68c1207b52 | ||
|
|
f77647ad12 | ||
|
|
c746ea3210 | ||
|
|
d323b34826 | ||
|
|
bf50194f99 | ||
|
|
4a977b2b31 | ||
|
|
b1e3a7b222 | ||
|
|
b12d43698e | ||
|
|
5cd2c22b27 | ||
|
|
c2ef80a287 | ||
|
|
4df3029536 | ||
|
|
e70d667a9f | ||
|
|
8ba434b597 | ||
|
|
171947787c | ||
|
|
106629aa11 | ||
|
|
3ee2e482ce | ||
|
|
849824efd2 | ||
|
|
d6639cb5ad | ||
|
|
b300116d0a |
@@ -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-No3mCuG2tGQauX1HUpO+rebiWh+rrpSHUCC7rtXFu1s=",
|
||||
"aarch64-linux": "sha256-8joWv1iDkc6TejEukGBEX0wW8DPs55wJEPY7+9+HDdM=",
|
||||
"aarch64-darwin": "sha256-4MWGFQUIP1Ae4dujztTb4G8/uDJx2wiOoypcPSUWDbw=",
|
||||
"x86_64-darwin": "sha256-BtvnraCJmVagtA3Iv+EbWodjFG74sTd2Purqgo7Wkr4="
|
||||
"x86_64-linux": "sha256-YAKhbMPKeXAZLYgJzLRQi/fwIMjPOkVIvOWkG3mCgTE=",
|
||||
"aarch64-linux": "sha256-UnIhoAleUQXKP8E0pVG0OyEpugmKEhfh6fEvpkpN2QI=",
|
||||
"aarch64-darwin": "sha256-m68nJKpcVrX9R6EZqVvPKgRmZEXilJGcISMaQpN/k+Q=",
|
||||
"x86_64-darwin": "sha256-DEjeoEn10K5YdJJCWtLw7LtthXChUXc0Vniccl/6KIc="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1343,20 +1343,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 +1375,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,17 +262,13 @@ 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 lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
@@ -422,15 +418,15 @@ 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 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 = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
if (breakpoints.dropped > 0) {
|
||||
@@ -438,22 +434,26 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
`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 +469,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 +503,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 +560,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 +627,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 +658,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 +722,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 +762,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"
|
||||
@@ -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"
|
||||
+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" },
|
||||
})
|
||||
@@ -903,6 +903,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 +984,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 +998,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",
|
||||
@@ -385,19 +397,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()
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -853,7 +923,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 +933,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 +950,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 +991,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(
|
||||
@@ -1280,6 +1368,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,694 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { Mistral } from "../../src/providers/index.js"
|
||||
import { MistralChat } from "../../src/protocols/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = Mistral.configure({ apiKey: "fixture" }).model("mistral-large-latest")
|
||||
const request = LLM.request({ model, prompt: "Hello" })
|
||||
const chunk = (delta: object, finishReason: string | null = null, usage?: object) => ({
|
||||
choices: [{ delta, finish_reason: finishReason }],
|
||||
usage,
|
||||
})
|
||||
|
||||
describe("Mistral Chat", () => {
|
||||
test("exposes native provider and protocol identities", async () => {
|
||||
const entrypoint = await import("@opencode-ai/ai/providers/mistral")
|
||||
|
||||
expect(Mistral.id).toBe("mistral")
|
||||
expect(MistralChat.protocol.id).toBe("mistral-chat")
|
||||
expect(Mistral.route).toMatchObject({
|
||||
id: "mistral-chat",
|
||||
provider: "mistral",
|
||||
providerMetadataKey: "mistral",
|
||||
protocol: "mistral-chat",
|
||||
})
|
||||
expect(Mistral.route.endpoint).toMatchObject({
|
||||
baseURL: "https://api.mistral.ai/v1",
|
||||
path: "/chat/completions",
|
||||
})
|
||||
expect(entrypoint.model).toBeFunction()
|
||||
})
|
||||
|
||||
it.effect("lowers native messages, media, tool choice, options, and replay IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: "Initial",
|
||||
messages: [
|
||||
Message.system("Updated"),
|
||||
Message.user([
|
||||
{ type: "text", text: "Inspect" },
|
||||
{ type: "media", mediaType: "image/png", data: "aW1hZ2U=" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "cGRm" },
|
||||
]),
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Think" },
|
||||
{ type: "text", text: "Calling" },
|
||||
{ type: "tool-call", id: "call.same-prefix-1", name: "lookup", input: { city: "Paris" } },
|
||||
{ type: "tool-call", id: "call.same-prefix-2", name: "other", input: {} },
|
||||
]),
|
||||
Message.tool({ id: "call.same-prefix-1", name: "lookup", result: { ok: true } }),
|
||||
],
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "lookup", description: "Look up a city", inputSchema: { type: "object" } }),
|
||||
ToolDefinition.make({ name: "other", description: "Other operation", inputSchema: { type: "object" } }),
|
||||
],
|
||||
toolChoice: "lookup",
|
||||
promptCacheKey: "session-1",
|
||||
generation: {
|
||||
maxTokens: 64,
|
||||
seed: 7,
|
||||
temperature: 0.2,
|
||||
topP: 0.8,
|
||||
frequencyPenalty: 0.1,
|
||||
presencePenalty: 0.3,
|
||||
stop: ["done"],
|
||||
},
|
||||
providerOptions: {
|
||||
safePrompt: true,
|
||||
documentImageLimit: 3,
|
||||
documentPageLimit: 8,
|
||||
parallelToolCalls: false,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "mistral-large-latest",
|
||||
tools: [{ function: { name: "lookup", strict: false } }, { function: { name: "other", strict: false } }],
|
||||
tool_choice: { type: "function", function: { name: "lookup" } },
|
||||
stream: true,
|
||||
max_tokens: 64,
|
||||
random_seed: 7,
|
||||
temperature: 0.2,
|
||||
top_p: 0.8,
|
||||
frequency_penalty: 0.1,
|
||||
presence_penalty: 0.3,
|
||||
stop: ["done"],
|
||||
prompt_cache_key: "session-1",
|
||||
safe_prompt: true,
|
||||
document_image_limit: 3,
|
||||
document_page_limit: 8,
|
||||
parallel_tool_calls: false,
|
||||
reasoning_effort: "high",
|
||||
})
|
||||
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
|
||||
{ role: "system", content: "Initial" },
|
||||
{ role: "user", content: "<system-update>\nUpdated\n</system-update>" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Inspect" },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,aW1hZ2U=" },
|
||||
{ type: "document_url", document_url: "data:application/pdf;base64,cGRm" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "ThinkCalling",
|
||||
},
|
||||
])
|
||||
const assistant = prepared.body.messages[3]
|
||||
const toolResult = prepared.body.messages[4]
|
||||
expect(assistant?.role).toBe("assistant")
|
||||
expect(toolResult?.role).toBe("tool")
|
||||
if (assistant?.role !== "assistant" || toolResult?.role !== "tool") return
|
||||
const ids = assistant.tool_calls?.map((tool) => tool.id) ?? []
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(ids[0]).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(ids[1]).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(ids[0]).not.toBe(ids[1])
|
||||
expect(toolResult.tool_call_id).toBe(ids[0])
|
||||
expect(toolResult.name).toBe("lookup")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves valid replay IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }),
|
||||
Message.tool({ id: "Ab12Cd34E", name: "lookup", result: "ok" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ tool_calls: [{ id: "Ab12Cd34E" }] },
|
||||
{ tool_call_id: "Ab12Cd34E" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies trailing prefix, cache, and reasoning options without changing earlier assistants", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
promptCacheKey: "common-key",
|
||||
messages: [Message.assistant("Earlier"), Message.user("Continue"), Message.assistant("Prefix")],
|
||||
providerOptions: { promptCacheKey: "native-key", promptMode: "reasoning" },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.prompt_cache_key).toBe("native-key")
|
||||
expect(prepared.body.prompt_mode).toBe("reasoning")
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Earlier" },
|
||||
{ role: "user", content: "Continue" },
|
||||
{ role: "assistant", content: "Prefix", prefix: true },
|
||||
])
|
||||
|
||||
const uncached = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "common-key",
|
||||
cache: "none",
|
||||
providerOptions: { promptCacheKey: "native-key" },
|
||||
}),
|
||||
)
|
||||
expect(uncached.body.prompt_cache_key).toBeUndefined()
|
||||
|
||||
const longKey = "cache-key-".repeat(10)
|
||||
const unbounded = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
promptCacheKey: longKey,
|
||||
}),
|
||||
)
|
||||
expect(unbounded.body.prompt_cache_key).toBe(longKey)
|
||||
|
||||
const conflict = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: { reasoningEffort: "high", promptMode: "reasoning" },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(conflict.message).toContain("mutually exclusive")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty assistant history unless it carries a tool call", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant(" \n "),
|
||||
Message.assistant({ type: "reasoning", text: "\t" }),
|
||||
Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [{ id: "Ab12Cd34E", type: "function", function: { name: "lookup", arguments: "{}" } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves remote media URLs and structured tool-result media", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "https://assets.example.test/input.png",
|
||||
}),
|
||||
Message.tool({
|
||||
id: "Ab12Cd34E",
|
||||
name: "inspect",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "text", text: "Result" },
|
||||
{ type: "file", mime: "image/jpeg", uri: "https://assets.example.test/output.jpg" },
|
||||
{ type: "file", mime: "application/pdf", uri: "cGRm" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: "https://assets.example.test/input.png" }],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "Ab12Cd34E",
|
||||
name: "inspect",
|
||||
content: [
|
||||
{ type: "text", text: "Result" },
|
||||
{ type: "image_url", image_url: "https://assets.example.test/output.jpg" },
|
||||
{ type: "document_url", document_url: "data:application/pdf;base64,cGRm" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("concatenates text-only user and tool content without separators", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
]),
|
||||
Message.tool({
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "text", text: "third" },
|
||||
{ type: "text", text: "fourth" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: "firstsecond" },
|
||||
{ role: "tool", tool_call_id: "Ab12Cd34E", name: "lookup", content: "thirdfourth" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams ordered thinking and text and replays native thinking metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
|
||||
chunk({ content: [{ type: "text", text: "Answer" }] }),
|
||||
chunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Consider")
|
||||
expect(response.text).toBe("Answer")
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Consider",
|
||||
providerMetadata: {
|
||||
mistral: {
|
||||
thinking: {
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
marker: "empty",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "Answer" },
|
||||
])
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
marker: "empty",
|
||||
},
|
||||
{ type: "text", text: "Answer" },
|
||||
],
|
||||
prefix: true,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays metadata-only native thinking", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(chunk({ content: [{ type: "thinking", thinking: [], marker: "opaque" }] }), chunk({}, "stop")),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: {
|
||||
mistral: { thinking: { type: "thinking", thinking: [], marker: "opaque" } },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: [], marker: "opaque" }],
|
||||
prefix: true,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges indexed argument fragments with missing continuation identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }],
|
||||
}),
|
||||
chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: '"Paris"}' } }] }),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toContainEqual({
|
||||
type: "tool-call",
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
input: { city: "Paris" },
|
||||
})
|
||||
expect(
|
||||
response.events.filter(
|
||||
(event) =>
|
||||
LLMEvent.is.toolInputStart(event) ||
|
||||
LLMEvent.is.toolInputDelta(event) ||
|
||||
LLMEvent.is.toolInputEnd(event) ||
|
||||
LLMEvent.is.toolCall(event),
|
||||
),
|
||||
).toEqual([
|
||||
{ type: "tool-input-start", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
text: '{"city":',
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
text: '"Paris"}',
|
||||
input: { city: "Paris" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
input: { city: "Paris" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes stop to tool calls when a hosted model emits indexed tool fragments", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "chatcmpl-tool-8cc4d8f9f07b298a",
|
||||
function: { name: "lookup", arguments: '{"city":"' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: 'Paris"}' } }] }),
|
||||
chunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "stop" })
|
||||
expect(response.toolCalls).toMatchObject([{ name: "lookup", input: { city: "Paris" } }])
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("generates a stable ID when the first indexed fragment has null identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [{ index: 0, id: null, function: { name: "lookup", arguments: { city: "Paris" } } }],
|
||||
}),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const call = response.message.content.find((part) => part.type === "tool-call")
|
||||
expect(call?.id).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(call).toMatchObject({ name: "lookup", input: { city: "Paris" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("generates distinct IDs for parallel null and literal-null identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{ index: 0, id: null, function: { name: "first", arguments: {} } },
|
||||
{ index: 1, id: "null", function: { name: "second", arguments: {} } },
|
||||
],
|
||||
}),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const calls = response.message.content.filter((part) => part.type === "tool-call")
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]?.id).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(calls[1]?.id).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(calls[0]?.id).not.toBe(calls[1]?.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps parallel indexed calls independent", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{ index: 0, id: "Ab12Cd34E", function: { name: "first", arguments: '{"n":' } },
|
||||
{ index: 1, id: "Fg56Hi78J", function: { name: "second", arguments: '{"n":' } },
|
||||
],
|
||||
}),
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{ index: 0, function: { arguments: "1}" } },
|
||||
{ index: 1, function: { arguments: "2}" } },
|
||||
],
|
||||
}),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.message.content.filter((part) => part.type === "tool-call")).toEqual([
|
||||
{ type: "tool-call", id: "Ab12Cd34E", name: "first", input: { n: 1 } },
|
||||
{ type: "tool-call", id: "Fg56Hi78J", name: "second", input: { n: 2 } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("correlates parallel identity-less fragments by batch position", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{ function: { name: "first", arguments: '{"n":' } },
|
||||
{ function: { name: "second", arguments: '{"n":' } },
|
||||
],
|
||||
}),
|
||||
chunk({
|
||||
tool_calls: [{ function: { arguments: "1}" } }, { function: { arguments: "2}" } }],
|
||||
}),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.message.content.filter((part) => part.type === "tool-call")).toMatchObject([
|
||||
{ name: "first", input: { n: 1 } },
|
||||
{ name: "second", input: { n: 2 } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps usage variants and clamps cache reads", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const usage of [
|
||||
{ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7, num_cached_tokens: 9 },
|
||||
{ prompt_tokens: 5, completion_tokens: 2, prompt_token_details: { cached_tokens: 2 } },
|
||||
{ prompt_tokens: 5, completion_tokens: 2, prompt_tokens_details: { cached_tokens: 3 } },
|
||||
]) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(chunk({}, "stop", usage)))),
|
||||
)
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
totalTokens: 7,
|
||||
})
|
||||
expect(response.usage?.cacheReadInputTokens).toBe(
|
||||
Math.min(
|
||||
5,
|
||||
usage.num_cached_tokens ??
|
||||
usage.prompt_token_details?.cached_tokens ??
|
||||
usage.prompt_tokens_details?.cached_tokens ??
|
||||
0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps finish reasons and does not finalize truncated tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const [raw, normalized] of [
|
||||
["stop", "stop"],
|
||||
["model_length", "length"],
|
||||
["tool_calls", "tool-calls"],
|
||||
["error", "error"],
|
||||
["future_reason", "unknown"],
|
||||
] as const) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(chunk({}, raw)))),
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
|
||||
const truncated = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }],
|
||||
}),
|
||||
chunk({}, "length"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(truncated.finishReason).toEqual({ normalized: "length", raw: "length" })
|
||||
expect(truncated.events.some(LLMEvent.is.toolCall)).toBe(false)
|
||||
expect(truncated.events.some(LLMEvent.is.toolInputEnd)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-text output parts and rejects invalid stream endings", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({ content: null }),
|
||||
chunk({
|
||||
content: [
|
||||
{ type: "reference", reference_ids: [1] },
|
||||
{ type: "image_url", image_url: "https://example.test/image.png" },
|
||||
{ type: "text", text: "Answer" },
|
||||
],
|
||||
}),
|
||||
chunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.text).toBe("Answer")
|
||||
|
||||
const missingFinish = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(chunk({ content: "partial" })))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(missingFinish.message).toContain("without finish_reason")
|
||||
|
||||
const lateContent = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(chunk({}, "stop"), chunk({ content: [{ type: "text", text: "late" }] }))),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(lateContent.message).toContain("content after the finish reason")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses environment bearer auth and custom package settings", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: Mistral.model("fixture-model", {
|
||||
baseURL: "https://mistral.test/v1",
|
||||
headers: { "x-app": "test" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { safePrompt: true },
|
||||
}),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.url).toBe("https://mistral.test/v1/chat/completions")
|
||||
expect(web.headers.get("authorization")).toBe("Bearer secret")
|
||||
expect(web.headers.get("x-app")).toBe("test")
|
||||
expect(input.text).toContain('"service_tier":"priority"')
|
||||
return input.respond(sseEvents(chunk({}, "stop")), { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { MISTRAL_API_KEY: "secret" } }))),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
+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.
|
||||
|
||||
@@ -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 } },
|
||||
])
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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")],
|
||||
|
||||
+16
-143
@@ -1,11 +1,10 @@
|
||||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
@@ -13,14 +12,13 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable, SessionTable } from "./session/sql.js"
|
||||
import { SessionMessageTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { AbsolutePath, RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import path from "path"
|
||||
import { fromRow } from "./session/info.js"
|
||||
import { SessionRunner } from "./session/runner/index.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
@@ -52,13 +50,10 @@ import { Session } from "./session/session.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { PluginSupervisor } from "./plugin/supervisor-service.js"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Skill } from "./skill.js"
|
||||
import { Job } from "./job.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionHistory } from "./session/history.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
// get project -> project.locations
|
||||
@@ -72,30 +67,8 @@ import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
export { ListAnchor }
|
||||
|
||||
const ListInputBase = {
|
||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
parentID: Schema.NullOr(SessionSchema.ID).pipe(Schema.optional),
|
||||
anchor: ListAnchor.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const ListDirectoryInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const ListProjectInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const ListAllInput = Schema.Struct(ListInputBase)
|
||||
|
||||
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||
export type ListInput = typeof ListInput.Type
|
||||
export const ListInput = SessionStore.ListInput
|
||||
export type ListInput = SessionStore.ListInput
|
||||
|
||||
type CreateBaseInput = {
|
||||
id?: SessionSchema.ID
|
||||
@@ -161,15 +134,9 @@ export interface Interface {
|
||||
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
|
||||
readonly view: (input: { sessionID: SessionSchema.ID; idle: number }) => Effect.Effect<void, NotFoundError>
|
||||
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
readonly messages: (
|
||||
input: SessionStore.MessagesInput,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
readonly message: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
@@ -233,12 +200,9 @@ export interface Interface {
|
||||
readonly shell: (
|
||||
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["shell"]>
|
||||
readonly skill: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
skill: Skill.ID
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
||||
readonly skill: (
|
||||
input: Parameters<Session.Handle["skill"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["skill"]>
|
||||
readonly compact: (
|
||||
input: CompactInput,
|
||||
) => Effect.Effect<SessionInbox.Compaction, NotFoundError | CompactionConflictError>
|
||||
@@ -278,7 +242,6 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const sessions = yield* Session.make((ref) => locations.get(ref))
|
||||
const admission = yield* SessionInbox.Service
|
||||
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
|
||||
@@ -409,83 +372,12 @@ const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Deleted, { sessionID })
|
||||
yield* bus.remove(sessionID)
|
||||
}),
|
||||
list: Effect.fn("Session.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_updated
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.parentID !== undefined)
|
||||
conditions.push(
|
||||
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
|
||||
)
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return { data: (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) }
|
||||
list: Effect.fn("Session.list")(function* (input) {
|
||||
return { data: yield* store.list(input) }
|
||||
}),
|
||||
messages: Effect.fn("Session.messages")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
direction === "previous" ? rows.toReversed() : rows,
|
||||
SessionHistory.decodeMessageRow,
|
||||
)
|
||||
return yield* store.messages(input)
|
||||
}),
|
||||
message: (input) => sessions.forSession(input.sessionID).message(input.messageID),
|
||||
updateMessage: (input) => sessions.forSession(input.sessionID).updateMessage(input),
|
||||
@@ -537,26 +429,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
shell: (input) => sessions.forSession(input.sessionID).shell(input),
|
||||
skill: Effect.fn("Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(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: input.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(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
skill: (input) => sessions.forSession(input.sessionID).skill(input),
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
MessageToolIncompleteError,
|
||||
NotFoundError,
|
||||
PromptConflictError,
|
||||
SkillNotFoundError,
|
||||
SyntheticConflictError,
|
||||
} from "./error.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
@@ -30,7 +32,12 @@ 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
|
||||
export type Services =
|
||||
| PluginSupervisor.Service
|
||||
| SessionPrompt.Service
|
||||
| SessionRevert.Service
|
||||
| Shell.Service
|
||||
| Skill.Service
|
||||
|
||||
type PromptRequest = SessionPrompt.Input & {
|
||||
id?: SessionMessage.ID
|
||||
@@ -238,6 +245,29 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
||||
}).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 },
|
||||
@@ -346,6 +376,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
||||
prompt,
|
||||
synthetic,
|
||||
shell,
|
||||
skill,
|
||||
compact,
|
||||
wait,
|
||||
resume,
|
||||
@@ -368,6 +399,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
||||
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)
|
||||
@@ -393,6 +425,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
||||
prompt,
|
||||
synthetic,
|
||||
shell,
|
||||
skill,
|
||||
compact,
|
||||
wait,
|
||||
resume,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export * as SessionStore from "./store.js"
|
||||
|
||||
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, notInArray, or, sql, type SQL } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionHistory } from "./history.js"
|
||||
@@ -11,8 +14,45 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { fromRow } from "./info.js"
|
||||
|
||||
const ListInputBase = {
|
||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
parentID: Schema.NullOr(Session.ID).pipe(Schema.optional),
|
||||
anchor: Session.ListAnchor.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const ListDirectoryInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const ListProjectInput = Schema.Struct({
|
||||
...ListInputBase,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const ListAllInput = Schema.Struct(ListInputBase)
|
||||
|
||||
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export type MessagesInput = {
|
||||
sessionID: Session.ID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
@@ -55,6 +95,83 @@ const layer = Layer.effect(
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
}),
|
||||
list: Effect.fn("SessionStore.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const sortColumn = SessionTable.time_updated
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.parentID !== undefined)
|
||||
conditions.push(
|
||||
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
|
||||
)
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(sortColumn, input.anchor.time),
|
||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
}),
|
||||
messages: Effect.fn("SessionStore.messages")(function* (input) {
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
direction === "previous" ? rows.toReversed() : rows,
|
||||
SessionHistory.decodeMessageRow,
|
||||
)
|
||||
}),
|
||||
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
|
||||
@@ -95,7 +95,7 @@ export function convertHTMLToMarkdown(html: string) {
|
||||
const remaining = limit - outputBytes
|
||||
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
|
||||
output.push(next)
|
||||
outputBytes += bytes.byteLength <= remaining ? bytes.byteLength : encoder.encode(next).byteLength
|
||||
outputBytes += encoder.encode(next).byteLength
|
||||
last = next.at(-1) ?? last
|
||||
}
|
||||
const appendRaw = (value: string) => {
|
||||
|
||||
@@ -109,6 +109,66 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps supported Mistral settings and request overlays to the native provider", () => {
|
||||
expect(
|
||||
map("@ai-sdk/mistral", {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://mistral.example/v1",
|
||||
headers: { "x-provider": "mistral" },
|
||||
extraBody: { custom: { enabled: true } },
|
||||
safePrompt: false,
|
||||
documentImageLimit: 4,
|
||||
documentPageLimit: 12,
|
||||
parallelToolCalls: false,
|
||||
promptCacheKey: "session-123",
|
||||
reasoningEffort: "high",
|
||||
promptMode: "reasoning",
|
||||
fetch: "ignored",
|
||||
generateId: "ignored",
|
||||
structuredOutputs: true,
|
||||
unsupported: true,
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/mistral",
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://mistral.example/v1",
|
||||
providerOptions: {
|
||||
safePrompt: false,
|
||||
documentImageLimit: 4,
|
||||
documentPageLimit: 12,
|
||||
parallelToolCalls: false,
|
||||
promptCacheKey: "session-123",
|
||||
reasoningEffort: "high",
|
||||
promptMode: "reasoning",
|
||||
},
|
||||
},
|
||||
headers: { "x-provider": "mistral" },
|
||||
body: { custom: { enabled: true } },
|
||||
})
|
||||
})
|
||||
|
||||
test("omits invalid and runtime-only Mistral settings", () => {
|
||||
expect(
|
||||
map("@ai-sdk/mistral", {
|
||||
headers: { valid: "header", invalid: 1 },
|
||||
extraBody: "invalid",
|
||||
safePrompt: "false",
|
||||
documentImageLimit: "4",
|
||||
documentPageLimit: null,
|
||||
parallelToolCalls: 0,
|
||||
promptCacheKey: false,
|
||||
reasoningEffort: false,
|
||||
promptMode: "unsupported",
|
||||
fetch: "ignored",
|
||||
generateId: "ignored",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/mistral",
|
||||
settings: {},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps both models.dev Bedrock packages to native providers", () => {
|
||||
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
|
||||
package: "@opencode-ai/ai/providers/amazon-bedrock",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const selected = Info.make({
|
||||
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
|
||||
package: Provider.aisdk("@ai-sdk/mistral"),
|
||||
package: Provider.aisdk("@ai-sdk/cohere"),
|
||||
})
|
||||
const runtime = LanguageModel.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
|
||||
|
||||
|
||||
@@ -1207,6 +1207,68 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live(
|
||||
"merges MCP defaults into the winning configured server without changing runtime overrides",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
timeout: { startup: 10, catalog: 20, execution: 30 },
|
||||
servers: {
|
||||
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
timeout: { catalog: 40 },
|
||||
servers: {
|
||||
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]
|
||||
const original = JSON.stringify(entries)
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
const check = yield* service.transform((draft) => {
|
||||
expect(draft.get("resources")).toEqual({
|
||||
type: "local",
|
||||
command: ["later"],
|
||||
disabled: true,
|
||||
timeout: { startup: 50, catalog: 40, execution: 30 },
|
||||
})
|
||||
})
|
||||
yield* check.dispose
|
||||
const runtime = {
|
||||
type: "local",
|
||||
command: ["runtime"],
|
||||
disabled: true,
|
||||
timeout: { catalog: 60 },
|
||||
} satisfies ConfigMCP.Local
|
||||
yield* service.add("resources", runtime)
|
||||
yield* service.reload()
|
||||
yield* service.transform((draft) => {
|
||||
expect(draft.get("resources")).toEqual(runtime)
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer("https://unused.example", undefined, undefined, {
|
||||
entries: () => Effect.succeed(entries),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(JSON.stringify(entries)).toBe(original)
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
|
||||
"manages live MCP servers entirely through scoped transforms",
|
||||
() =>
|
||||
|
||||
@@ -280,12 +280,25 @@ describe("ModelResolver", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
|
||||
it.effect("uses no native API-key auth for explicitly enabled providers without credentials", () => {
|
||||
const selected = model(Provider.aisdk("@ai-sdk/google"), {
|
||||
providerID: Provider.ID.make("gateway"),
|
||||
settings: { baseURL: "https://gateway.example.com/v1" },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
})
|
||||
const selections = [
|
||||
selected,
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
providerID: Provider.ID.make("gateway"),
|
||||
settings: { baseURL: "https://mistral.example.com/v1" },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
}),
|
||||
model("@opencode-ai/ai/providers/mistral", {
|
||||
providerID: Provider.ID.make("gateway"),
|
||||
settings: { baseURL: "https://native-mistral.example.com/v1" },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
}),
|
||||
]
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(selected.providerID),
|
||||
activation: "enabled",
|
||||
@@ -344,20 +357,23 @@ describe("ModelResolver", () => {
|
||||
return withConfigEnv({}, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const resolved = yield* resolver.resolveModel(selected)
|
||||
yield* Effect.forEach(selections, (selection) =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* resolver.resolveModel(selection)
|
||||
const headers = yield* resolved.model.route.auth.apply({
|
||||
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: resolved.model.route.endpoint.baseURL ?? "",
|
||||
body: "{}",
|
||||
headers: Headers.fromInput(resolved.model.route.defaults.headers),
|
||||
})
|
||||
|
||||
expect(resolved.limit).toEqual(selected.limit)
|
||||
const headers = yield* resolved.model.route.auth.apply({
|
||||
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://gateway.example.com/v1",
|
||||
body: "{}",
|
||||
headers: Headers.fromInput(resolved.model.route.defaults.headers),
|
||||
})
|
||||
|
||||
expect(headers["cf-access-token"]).toBe("access-token")
|
||||
expect(headers.authorization).toBeUndefined()
|
||||
expect(headers["x-goog-api-key"]).toBeUndefined()
|
||||
expect(resolved.limit).toEqual(selection.limit)
|
||||
expect(headers["cf-access-token"]).toBe("access-token")
|
||||
expect(headers.authorization).toBeUndefined()
|
||||
expect(headers["x-goog-api-key"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer)),
|
||||
)
|
||||
})
|
||||
@@ -921,6 +937,24 @@ describe("ModelResolver", () => {
|
||||
{ reasoningEffort: "high", parallelToolCalls: false },
|
||||
{ reasoningEffort: "high", parallelToolCalls: false },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/mistral",
|
||||
"@opencode-ai/ai/providers/mistral",
|
||||
{
|
||||
safePrompt: true,
|
||||
documentImageLimit: 4,
|
||||
promptCacheKey: "session-123",
|
||||
promptMode: "reasoning",
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
{
|
||||
safePrompt: true,
|
||||
documentImageLimit: 4,
|
||||
promptCacheKey: "session-123",
|
||||
promptMode: "reasoning",
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@ai-sdk/togetherai",
|
||||
"@opencode-ai/ai/providers/togetherai",
|
||||
@@ -980,6 +1014,7 @@ describe("ModelResolver", () => {
|
||||
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
|
||||
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
|
||||
["@ai-sdk/groq", "@opencode-ai/ai/providers/groq", "api-model"],
|
||||
["@ai-sdk/mistral", "@opencode-ai/ai/providers/mistral", "api-model"],
|
||||
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
|
||||
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
|
||||
@@ -1088,6 +1123,36 @@ describe("ModelResolver", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("merges mapped Mistral headers and body with catalog overlays", () =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: {
|
||||
headers: { "x-factory": "factory", "x-shared": "factory" },
|
||||
extraBody: { factory: true, custom: { source: true } },
|
||||
},
|
||||
headers: { "x-shared": "catalog" },
|
||||
body: { custom: { catalog: true } },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(settings.headers).toEqual({
|
||||
"x-factory": "factory",
|
||||
"x-shared": "catalog",
|
||||
})
|
||||
expect(settings.body).toEqual({
|
||||
factory: true,
|
||||
custom: { source: true, catalog: true },
|
||||
})
|
||||
return LanguageModel.make({ id: modelID, provider: "mistral", route: OpenAIChat.route })
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("loads supported AISDK catalog packages as native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const google = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -1114,6 +1179,11 @@ describe("ModelResolver", () => {
|
||||
settings: { reasoningEffort: "high", parallelToolCalls: false },
|
||||
}),
|
||||
)
|
||||
const mistral = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { safePrompt: true, promptCacheKey: "session-123", reasoningEffort: "high" },
|
||||
}),
|
||||
)
|
||||
const xai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
@@ -1148,6 +1218,13 @@ describe("ModelResolver", () => {
|
||||
expect(groq.route.protocol).toBe("groq-chat")
|
||||
expect(groq.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", parallelToolCalls: false })
|
||||
expect(String(groq.provider)).toBe("test-provider")
|
||||
expect(mistral.route.id).toBe("mistral-chat")
|
||||
expect(mistral.route.defaults.providerOptions).toEqual({
|
||||
safePrompt: true,
|
||||
promptCacheKey: "session-123",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
expect(String(mistral.provider)).toBe("test-provider")
|
||||
expect(xai.route.id).toBe("openai-responses")
|
||||
expect(xai.route.defaults.providerOptions).toEqual({
|
||||
reasoningEffort: "high",
|
||||
@@ -1170,8 +1247,8 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
modelID: "mistral-api-model",
|
||||
model(Provider.aisdk("@ai-sdk/cohere"), {
|
||||
modelID: "cohere-api-model",
|
||||
settings: { project: "test" },
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
@@ -1186,9 +1263,9 @@ describe("ModelResolver", () => {
|
||||
Effect.sync(() => {
|
||||
expect(runtime).toMatchObject({
|
||||
id: "test-model",
|
||||
modelID: "mistral-api-model",
|
||||
modelID: "cohere-api-model",
|
||||
providerID: "test-provider",
|
||||
package: Provider.aisdk("@ai-sdk/mistral"),
|
||||
package: Provider.aisdk("@ai-sdk/cohere"),
|
||||
settings: { project: "test", apiKey: "fallback-secret", accountId: "account" },
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
@@ -1202,7 +1279,7 @@ describe("ModelResolver", () => {
|
||||
},
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
|
||||
expect(resolved).toMatchObject({ id: "cohere-api-model", provider: "test-provider" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1210,7 +1287,7 @@ describe("ModelResolver", () => {
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
model(Provider.aisdk("@ai-sdk/cohere"), {
|
||||
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
@@ -1229,7 +1306,7 @@ describe("ModelResolver", () => {
|
||||
withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
model(Provider.aisdk("@ai-sdk/cohere"), {
|
||||
settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
@@ -1266,8 +1343,8 @@ describe("ModelResolver", () => {
|
||||
it.effect("rejects AISDK packages without an available loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { baseURL: "https://mistral.example/v1" },
|
||||
model(Provider.aisdk("@ai-sdk/cohere"), {
|
||||
settings: { baseURL: "https://cohere.example/v1" },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
@@ -1275,9 +1352,9 @@ describe("ModelResolver", () => {
|
||||
_tag: "SessionRunnerModel.UnsupportedPackageError",
|
||||
providerID: "test-provider",
|
||||
modelID: "test-model",
|
||||
package: "aisdk:@ai-sdk/mistral",
|
||||
package: "aisdk:@ai-sdk/cohere",
|
||||
})
|
||||
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
|
||||
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/cohere")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1289,8 +1366,8 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
|
||||
model(Provider.aisdk("@ai-sdk/cohere"), {
|
||||
settings: { apiKey: "", baseURL: "https://cohere.example/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ describe("Provider", () => {
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
"@opencode-ai/ai/providers/groq",
|
||||
"@opencode-ai/ai/providers/mistral",
|
||||
"@opencode-ai/ai/providers/togetherai",
|
||||
]
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { and, eq } from "drizzle-orm"
|
||||
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
@@ -16,6 +15,7 @@ import { Bus } from "../src/bus.js"
|
||||
import { Database } from "../src/database/database.js"
|
||||
import { EventTable } from "../src/event/sql.js"
|
||||
import { Image } from "../src/image.js"
|
||||
import { Location } from "../src/location.js"
|
||||
import { PluginHooks } from "../src/plugin/hooks.js"
|
||||
import { PluginSupervisor } from "../src/plugin/supervisor-service.js"
|
||||
import { ProjectTable } from "../src/project/sql.js"
|
||||
@@ -37,6 +37,7 @@ import { Shell } from "../src/shell.js"
|
||||
import { Skill } from "../src/skill.js"
|
||||
import { Snapshot } from "../src/snapshot.js"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
@@ -58,10 +59,18 @@ const it = testEffect(
|
||||
const sessionID = SessionSchema.ID.make("ses_owned")
|
||||
const otherID = SessionSchema.ID.make("ses_owned_other")
|
||||
const source = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const skillInfo = Skill.Info.make({
|
||||
id: Skill.ID.make("guide"),
|
||||
name: Skill.Name.make("Guide"),
|
||||
description: "Session guidance",
|
||||
location: AbsolutePath.make("/skills/guide/SKILL.md"),
|
||||
content: " Raw guidance\n",
|
||||
})
|
||||
|
||||
const setup = Effect.fnUntraced(function* (options?: {
|
||||
execution?: SessionExecution.Interface
|
||||
shell?: Layer.Layer<Shell.Service>
|
||||
skills?: (ref: Location.Ref) => Layer.Layer<Skill.Service>
|
||||
snapshot?: (ref: Location.Ref) => Layer.Layer<Snapshot.Service>
|
||||
}) {
|
||||
const database = yield* Database.Service
|
||||
@@ -86,11 +95,15 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
|
||||
const locations: Location.Ref[] = []
|
||||
const flushes: Location.Ref[] = []
|
||||
const resumes: SessionSchema.ID[] = []
|
||||
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
|
||||
const execution = SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set<SessionSchema.ID>()),
|
||||
isActive: () => Effect.succeed(false),
|
||||
resume: () => Effect.void,
|
||||
resume: (id) =>
|
||||
Effect.sync(() => {
|
||||
resumes.push(id)
|
||||
}),
|
||||
awaitIdle: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
wake: (id) =>
|
||||
@@ -113,7 +126,6 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
const services = Layer.mergeAll(
|
||||
Layer.succeed(PluginHooks.Service, hooks),
|
||||
Layer.mock(Image.Service, {}),
|
||||
Layer.mock(Skill.Service, {}),
|
||||
options?.shell ?? Layer.mock(Shell.Service, {}),
|
||||
)
|
||||
const servicesFor = (ref: Location.Ref): Layer.Layer<Session.Services> => {
|
||||
@@ -122,6 +134,11 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
services,
|
||||
Layer.succeed(Location.Service, location(ref)),
|
||||
options?.skills?.(ref) ??
|
||||
Layer.mock(Skill.Service, {
|
||||
get: (id) => Effect.succeed(id === skillInfo.id ? skillInfo : undefined),
|
||||
}),
|
||||
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.sync(() => {
|
||||
@@ -146,7 +163,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
>(),
|
||||
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
|
||||
)
|
||||
return { sessions, hooks, locations, flushes, wakes, db: database.db, bus, store }
|
||||
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
|
||||
})
|
||||
|
||||
describe("Session-owned handles", () => {
|
||||
@@ -339,6 +356,144 @@ describe("Session-owned handles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("activates skills through detached handles using fresh placement and ambient publication context", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup({
|
||||
skills: (ref) =>
|
||||
Layer.mock(Skill.Service, { get: () => Effect.succeed({ ...skillInfo, content: ref.directory }) }),
|
||||
})
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
const { skill } = handle
|
||||
const events: Event.Payload[] = []
|
||||
yield* fixture.bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
events.push(event)
|
||||
}),
|
||||
)
|
||||
const initial = SessionMessage.ID.make("msg_owned_skill_initial")
|
||||
yield* skill({ id: initial, skill: skillInfo.id, resume: false }).pipe(
|
||||
Effect.satisfiesServicesType<never>(),
|
||||
Effect.setContext(Context.empty()),
|
||||
)
|
||||
const moved = SessionMessage.ID.make("msg_owned_skill_moved")
|
||||
const activation = skill({ id: moved, skill: skillInfo.id, resume: false })
|
||||
const destination = Location.Ref.make({ directory: AbsolutePath.make("/project/moved") })
|
||||
yield* fixture.bus.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
location: destination,
|
||||
projectID: Project.ID.global,
|
||||
subpath: RelativePath.make("moved"),
|
||||
})
|
||||
|
||||
yield* activation.pipe(Effect.satisfiesServicesType<never>(), Effect.setContext(Context.empty()))
|
||||
yield* skill({ skill: skillInfo.id, resume: false }).pipe(
|
||||
Effect.provideService(Location.Service, location(source)),
|
||||
)
|
||||
|
||||
expect(fixture.locations).toEqual([source, destination, destination])
|
||||
expect(yield* handle.message(initial)).toMatchObject({ type: "skill", text: source.directory })
|
||||
expect(yield* handle.message(moved)).toMatchObject({ type: "skill", text: destination.directory })
|
||||
expect(
|
||||
events.filter((event) => event.type === SessionEvent.Skill.Activated.type).map((event) => event.location),
|
||||
).toEqual([undefined, undefined, source])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("checks Session existence before skill lookup and leaves missing activations untouched", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
const events: Event.Payload[] = []
|
||||
yield* fixture.bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
events.push(event)
|
||||
}),
|
||||
)
|
||||
const missingID = SessionSchema.ID.make("ses_missing_skill")
|
||||
expect(
|
||||
yield* fixture.sessions.forSession(missingID).skill({ skill: skillInfo.id }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Session.NotFoundError", sessionID: missingID })
|
||||
expect(fixture.locations).toEqual([])
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
const before = yield* handle.get()
|
||||
const missing = Skill.ID.make("missing")
|
||||
|
||||
expect(yield* handle.skill({ skill: missing }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.SkillNotFoundError",
|
||||
skill: missing,
|
||||
})
|
||||
|
||||
expect(fixture.locations).toEqual([source])
|
||||
expect(events).toEqual([])
|
||||
expect(yield* handle.get()).toEqual(before)
|
||||
expect(yield* handle.inbox()).toEqual([])
|
||||
expect(yield* fixture.store.context(sessionID)).toEqual([])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("publishes skills before detached resumes and owns those resumes in the host scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const host = yield* Scope.fork(scope, "sequential")
|
||||
const calls: string[] = []
|
||||
const stopped: SessionSchema.ID[] = []
|
||||
const execution = yield* SessionExecution.Service.pipe(Effect.provide(SessionExecution.noopLayer))
|
||||
const fixture = yield* setup({
|
||||
execution: {
|
||||
...execution,
|
||||
resume: (id) =>
|
||||
Effect.gen(function* () {
|
||||
calls.push(`resume:${id}`)
|
||||
yield* Effect.never
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.sync(() => {
|
||||
stopped.push(id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
wake: () =>
|
||||
Effect.sync(() => {
|
||||
calls.push("wake")
|
||||
}),
|
||||
},
|
||||
}).pipe(Scope.provide(host))
|
||||
yield* fixture.bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === SessionEvent.Skill.Activated.type) calls.push(`published:${event.id}`)
|
||||
}),
|
||||
)
|
||||
const { skill } = fixture.sessions.forSession(sessionID)
|
||||
|
||||
yield* skill({ id: SessionMessage.ID.make("msg_skill_no_resume"), skill: skillInfo.id, resume: false })
|
||||
expect(calls).toEqual(["published:evt_skill_no_resume"])
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{ id: SessionMessage.ID.make("msg_skill_default_resume"), skill: skillInfo.id },
|
||||
{ id: SessionMessage.ID.make("msg_skill_explicit_resume"), skill: skillInfo.id, resume: true },
|
||||
],
|
||||
(input) => skill(input).pipe(Effect.scoped, Effect.forkScoped, Effect.flatMap(Fiber.join)),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
"published:evt_skill_no_resume",
|
||||
"published:evt_skill_default_resume",
|
||||
`resume:${sessionID}`,
|
||||
"published:evt_skill_explicit_resume",
|
||||
`resume:${sessionID}`,
|
||||
])
|
||||
expect(stopped).toEqual([])
|
||||
yield* Scope.close(host, Exit.void)
|
||||
expect(stopped).toEqual([sessionID, sessionID])
|
||||
expect(yield* fixture.sessions.forSession(sessionID).inbox()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps prompt wakes independent of shell work across handles", () =>
|
||||
Effect.gen(function* () {
|
||||
const blocked = yield* Deferred.make<void>()
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { fromRow } from "@opencode-ai/core/session/info"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import {
|
||||
InstructionStateTable,
|
||||
@@ -32,9 +33,10 @@ import { testEffect } from "./lib/effect"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
]),
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
|
||||
[[Bus.node, Bus.configured({ persist: true })]],
|
||||
),
|
||||
)
|
||||
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
|
||||
const sessionID = Session.ID.make("ses_projector_test")
|
||||
@@ -278,7 +280,9 @@ describe("SessionProjector", () => {
|
||||
yield* db.run(sql`update session_message set data = '{"time":{"created":0}}' where id = ${messageID}`)
|
||||
|
||||
const sessions = yield* Session.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const expected = { _tag: "Session.MessageDecodeError", sessionID, messageID }
|
||||
expect(yield* store.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
|
||||
expect(yield* sessions.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
|
||||
expect(yield* sessions.context(sessionID).pipe(Effect.flip)).toMatchObject(expected)
|
||||
expect(yield* sessions.message({ sessionID, messageID }).pipe(Effect.catchDefect(Effect.succeed))).toMatchObject(
|
||||
@@ -287,6 +291,21 @@ describe("SessionProjector", () => {
|
||||
}).pipe(Effect.provide(sessionsLayer)),
|
||||
)
|
||||
|
||||
it.effect("checks session existence before resolving a missing message cursor", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const missing = Session.ID.make("ses_missing")
|
||||
expect(
|
||||
yield* sessions
|
||||
.messages({
|
||||
sessionID: missing,
|
||||
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
|
||||
})
|
||||
.pipe(Effect.flip),
|
||||
).toEqual(new Session.NotFoundError({ sessionID: missing }))
|
||||
}).pipe(Effect.provide(sessionsLayer)),
|
||||
)
|
||||
|
||||
it.effect("consumes the pending row and projects the message at promotion", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
|
||||
@@ -17,12 +17,14 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionPrompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
|
||||
@@ -32,7 +34,7 @@ const info = Skill.Info.make({
|
||||
name: Skill.Name.make("Effect"),
|
||||
description: "Effect guidance",
|
||||
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
|
||||
content: "Use Effect",
|
||||
content: " Use Effect\n",
|
||||
})
|
||||
const locations = makeGlobalNode({
|
||||
service: LocationServiceMap.Service,
|
||||
@@ -146,17 +148,32 @@ describe("Session.skill", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects the caller-supplied message ID", () =>
|
||||
it.effect("publishes raw standalone content under the caller-supplied ID without inbox admission", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const session = yield* sessions.create({ location })
|
||||
const id = SessionMessage.ID.make("msg_caller_skill")
|
||||
const events: Event.Payload[] = []
|
||||
yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
events.push(event)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* sessions.skill({ id, sessionID: session.id, skill: Skill.ID.make("effect"), resume: false })
|
||||
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
|
||||
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }),
|
||||
)
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "evt_caller_skill",
|
||||
type: SessionEvent.Skill.Activated.type,
|
||||
data: { sessionID: session.id, id: info.id, name: info.name, text: info.content },
|
||||
}),
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([
|
||||
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: info.content }),
|
||||
])
|
||||
expect(yield* sessions.inbox(session.id)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
]),
|
||||
)
|
||||
|
||||
const seedSessions = (rows: { id: string; updated: number }[]) =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const directory = AbsolutePath.make("/project")
|
||||
yield* database.db.insert(ProjectTable).values({ id: Project.ID.global, worktree: directory, sandboxes: [] }).run()
|
||||
yield* Effect.forEach(rows, (row) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = Session.ID.make(row.id)
|
||||
yield* bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
projectID: Project.ID.global,
|
||||
location: { directory },
|
||||
slug: "store-test",
|
||||
version: "test",
|
||||
})
|
||||
yield* bus.replay({
|
||||
id: Event.ID.create(),
|
||||
created: row.updated,
|
||||
aggregateID: sessionID,
|
||||
seq: 1,
|
||||
type: Bus.versionedType(SessionEvent.Renamed.type, 1),
|
||||
data: { sessionID, title: row.id },
|
||||
})
|
||||
}),
|
||||
)
|
||||
return bus
|
||||
})
|
||||
|
||||
describe("SessionStore", () => {
|
||||
it.effect("lists by updated time and ID with exclusive two-item pages in either direction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedSessions([
|
||||
{ id: "ses_d", updated: 20 },
|
||||
{ id: "ses_z", updated: 10 },
|
||||
{ id: "ses_a", updated: 30 },
|
||||
{ id: "ses_c", updated: 20 },
|
||||
{ id: "ses_y", updated: 10 },
|
||||
{ id: "ses_e", updated: 30 },
|
||||
{ id: "ses_b", updated: 20 },
|
||||
])
|
||||
const store = yield* SessionStore.Service
|
||||
expect((yield* store.list()).map((session) => String(session.id))).toEqual([
|
||||
"ses_e",
|
||||
"ses_a",
|
||||
"ses_d",
|
||||
"ses_c",
|
||||
"ses_b",
|
||||
"ses_z",
|
||||
"ses_y",
|
||||
])
|
||||
expect((yield* store.list({ order: "asc" })).map((session) => String(session.id))).toEqual([
|
||||
"ses_y",
|
||||
"ses_z",
|
||||
"ses_b",
|
||||
"ses_c",
|
||||
"ses_d",
|
||||
"ses_a",
|
||||
"ses_e",
|
||||
])
|
||||
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
|
||||
{ order: "asc", direction: "next", ids: ["ses_d", "ses_a"] },
|
||||
{ order: "asc", direction: "previous", ids: ["ses_z", "ses_b"] },
|
||||
{ order: "desc", direction: "next", ids: ["ses_b", "ses_z"] },
|
||||
{ order: "desc", direction: "previous", ids: ["ses_a", "ses_d"] },
|
||||
]
|
||||
yield* Effect.forEach(pages, (page) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* store.list({
|
||||
order: page.order,
|
||||
limit: 2,
|
||||
anchor: { id: Session.ID.make("ses_c"), time: 20, direction: page.direction },
|
||||
})
|
||||
expect(sessions.map((session) => String(session.id))).toEqual(page.ids)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("pages messages by durable sequence, not timestamp or ID, and scopes cursor lookup", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = Session.ID.make("ses_messages")
|
||||
const foreignID = Session.ID.make("ses_foreign")
|
||||
const bus = yield* seedSessions([
|
||||
{ id: sessionID, updated: 0 },
|
||||
{ id: foreignID, updated: 0 },
|
||||
])
|
||||
const store = yield* SessionStore.Service
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{ id: "evt_z", created: 300 },
|
||||
{ id: "evt_b", created: 700 },
|
||||
{ id: "evt_x", created: 100 },
|
||||
{ id: "evt_c", created: 400 },
|
||||
{ id: "evt_w", created: 200 },
|
||||
{ id: "evt_a", created: 600 },
|
||||
{ id: "evt_y", created: 500 },
|
||||
],
|
||||
(event, index) =>
|
||||
bus.replay({
|
||||
id: Event.ID.make(event.id),
|
||||
created: event.created,
|
||||
aggregateID: sessionID,
|
||||
seq: index + 2,
|
||||
type: Bus.versionedType(SessionEvent.Synthetic.type, 1),
|
||||
data: { sessionID, text: event.id },
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID: foreignID, text: "foreign" },
|
||||
{
|
||||
id: Event.ID.make("evt_foreign"),
|
||||
},
|
||||
)
|
||||
expect((yield* store.messages({ sessionID })).map((message) => String(message.id))).toEqual([
|
||||
"msg_y",
|
||||
"msg_a",
|
||||
"msg_w",
|
||||
"msg_c",
|
||||
"msg_x",
|
||||
"msg_b",
|
||||
"msg_z",
|
||||
])
|
||||
expect((yield* store.messages({ sessionID, order: "asc" })).map((message) => String(message.id))).toEqual([
|
||||
"msg_z",
|
||||
"msg_b",
|
||||
"msg_x",
|
||||
"msg_c",
|
||||
"msg_w",
|
||||
"msg_a",
|
||||
"msg_y",
|
||||
])
|
||||
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
|
||||
{ order: "asc", direction: "next", ids: ["msg_w", "msg_a"] },
|
||||
{ order: "asc", direction: "previous", ids: ["msg_b", "msg_x"] },
|
||||
{ order: "desc", direction: "next", ids: ["msg_x", "msg_b"] },
|
||||
{ order: "desc", direction: "previous", ids: ["msg_a", "msg_w"] },
|
||||
]
|
||||
yield* Effect.forEach(pages, (page) =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* store.messages({
|
||||
sessionID,
|
||||
order: page.order,
|
||||
limit: 2,
|
||||
cursor: { id: SessionMessage.ID.make("msg_c"), direction: page.direction },
|
||||
})
|
||||
expect(messages.map((message) => String(message.id))).toEqual(page.ids)
|
||||
}),
|
||||
)
|
||||
expect(yield* store.messages({ sessionID: Session.ID.make("ses_missing") })).toEqual([])
|
||||
expect(
|
||||
yield* store.messages({
|
||||
sessionID,
|
||||
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
|
||||
}),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* store.messages({
|
||||
sessionID,
|
||||
order: "asc",
|
||||
cursor: { id: SessionMessage.ID.make("msg_foreign"), direction: "next" },
|
||||
}),
|
||||
).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
@@ -126,6 +127,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[Session.ID, schemaSession.Session.ID],
|
||||
[Session.Info, schemaSession.Session.Info],
|
||||
[Session.ListAnchor, schemaSession.Session.ListAnchor],
|
||||
[Session.ListInput, SessionStore.ListInput],
|
||||
[coreSessionInbox.Delivery, SessionInbox.Delivery],
|
||||
[coreSessionInbox.Item, SessionInbox.Item],
|
||||
[coreSessionInbox.User, SessionInbox.User],
|
||||
|
||||
@@ -128,15 +128,6 @@ describe("WebFetchTool helpers", () => {
|
||||
expect(output).toHaveLength(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024)
|
||||
})
|
||||
|
||||
test.each(["x", "\u00e9", "\u{1f600}"])("preserves UTF-8 boundaries at the content limit for %s", (character) => {
|
||||
const budget = WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024
|
||||
const fitting = "aa" + character.repeat(Math.floor((budget - 2) / Buffer.byteLength(character)))
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(fitting)).toBe(fitting)
|
||||
const truncated = WebFetchTool.convertHTMLToMarkdown(fitting + character)
|
||||
expect(truncated).toBe(fitting)
|
||||
expect(Buffer.byteLength(truncated)).toBe(Buffer.byteLength(fitting))
|
||||
})
|
||||
|
||||
test("bounds deeply nested list output and fragmented code fences", () => {
|
||||
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
|
||||
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
import type { CustomMacSignOptions } from "app-builder-lib"
|
||||
import type { Configuration } from "electron-builder"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
@@ -29,6 +30,18 @@ async function signWindows(configuration: { path: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function macSignOptions(options: CustomMacSignOptions): CustomMacSignOptions {
|
||||
return {
|
||||
...options,
|
||||
optionsForFile: (file) => {
|
||||
const defaults = options.optionsForFile?.(file)
|
||||
if (file !== path.join(options.app, "Contents/Resources/opencode-cli")) return defaults ?? {}
|
||||
// The Bun CLI loads bun-pty's native library; Electron and its helpers do not need this exception.
|
||||
return { ...defaults, entitlements: path.join(packageDir, "resources/entitlements.cli.plist") }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
@@ -88,6 +101,10 @@ const getBase = (appId: string): Configuration => ({
|
||||
gatekeeperAssess: false,
|
||||
entitlements: "resources/entitlements.plist",
|
||||
entitlementsInherit: "resources/entitlements.plist",
|
||||
sign: async (options) => {
|
||||
const { sign } = await import("app-builder-lib/out/codeSign/macCodeSign")
|
||||
await sign(macSignOptions(options))
|
||||
},
|
||||
notarize: true,
|
||||
target: ["dmg", "zip"],
|
||||
},
|
||||
|
||||
@@ -44,6 +44,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",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
|
||||
import {
|
||||
CodeRenderable,
|
||||
createMarkdownCodeBlockRenderer,
|
||||
type MarkdownCodeBlockRenderer,
|
||||
type MarkdownOptions,
|
||||
type Renderable,
|
||||
} from "@opentui/core"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
@@ -88,8 +94,26 @@ export function combineMarkdownRenderers(
|
||||
for (const source of sources) {
|
||||
for (const [language, render] of Object.entries(source)) renderers.set(language, render)
|
||||
}
|
||||
if (renderers.size === 0) return undefined
|
||||
return createMarkdownCodeBlockRenderer(renderers)
|
||||
const render = createMarkdownCodeBlockRenderer(renderers)
|
||||
return (token, context) => {
|
||||
if (token.type !== "code" && token.type !== "list") return render?.(token, context)
|
||||
let fallback: Renderable | null | undefined
|
||||
const custom = render?.(token, {
|
||||
...context,
|
||||
defaultRender: () => (fallback ??= context.defaultRender()),
|
||||
})
|
||||
if (custom && custom !== fallback) return custom
|
||||
const node = fallback ?? context.defaultRender()
|
||||
if (!node) return undefined
|
||||
deferCodePaint(node)
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
function deferCodePaint(node: Renderable) {
|
||||
// Streaming nodes already retain styled text; only completed fences opt into raw first paint.
|
||||
if (node instanceof CodeRenderable && !node.streaming) node.drawUnstyledText = false
|
||||
node.getChildren().forEach(deferCodePaint)
|
||||
}
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
|
||||
|
||||
@@ -114,6 +114,7 @@ import { SessionLocationMissing } from "./location-missing"
|
||||
import { isRecord } from "../../util/record"
|
||||
import { createHistoryPrepend } from "./history"
|
||||
import { useSessionTerminals } from "../../context/session-terminals"
|
||||
import { installSyntaxHighlightCache } from "../../util/syntax-highlight-cache"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -161,6 +162,7 @@ export function Session(props: {
|
||||
visibleTerminalID?: string
|
||||
width?: number
|
||||
}) {
|
||||
installSyntaxHighlightCache()
|
||||
const setEpilogue = useEpilogue()
|
||||
const clipboard = useClipboard()
|
||||
const writeExport = async (file: string, content: string) => {
|
||||
@@ -3349,6 +3351,7 @@ function Write(props: ToolProps) {
|
||||
>
|
||||
<line_number fg={theme.text.subdued} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
drawUnstyledText={false}
|
||||
conceal={false}
|
||||
fg={theme.text.default}
|
||||
filetype={filetype(stringValue(props.input.path))}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getTreeSitterClient, type TreeSitterClient } from "@opentui/core"
|
||||
|
||||
const CACHE_SIZE = 500
|
||||
const installed = new WeakSet<TreeSitterClient>()
|
||||
|
||||
export function installSyntaxHighlightCache() {
|
||||
const client = getTreeSitterClient()
|
||||
if (installed.has(client)) return
|
||||
installed.add(client)
|
||||
client.highlightOnce = cacheHighlights(client.highlightOnce.bind(client))
|
||||
}
|
||||
|
||||
export function cacheHighlights(highlight: TreeSitterClient["highlightOnce"], capacity = CACHE_SIZE) {
|
||||
const cache = new Map<string, ReturnType<TreeSitterClient["highlightOnce"]>>()
|
||||
|
||||
return (content: string, filetype: string) => {
|
||||
const key = `${filetype}\0${content}`
|
||||
const cached = cache.get(key)
|
||||
if (cached) {
|
||||
cache.delete(key)
|
||||
cache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const result = highlight(content, filetype)
|
||||
cache.set(key, result)
|
||||
if (cache.size > capacity) cache.delete(cache.keys().next().value!)
|
||||
|
||||
void result
|
||||
.then((value) => {
|
||||
if (value.error && cache.get(key) === result) cache.delete(key)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cache.get(key) === result) cache.delete(key)
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1193,6 +1193,85 @@ test.each(["manual", "select"] as const)(
|
||||
},
|
||||
)
|
||||
|
||||
test.each([80, 120].flatMap((width) => ["fence", "list", "write"].map((kind) => ({ width, kind }))))(
|
||||
"session code is highlighted on open and tab return: %j",
|
||||
async (input) => {
|
||||
await using state = await tmpdir()
|
||||
const session = {
|
||||
id: "ses_highlight",
|
||||
title: "Highlight fixture",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
agent: "build",
|
||||
model: { providerID: "fixture", id: "model" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
await using setup = await createAppFixture({
|
||||
width: input.width,
|
||||
state: state.path,
|
||||
config: { animations: false, tabs: { enabled: true }, session: { sidebar: "hide" } },
|
||||
args: { sessionID: session.id },
|
||||
fetch: (url) => {
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${session.id}/message`)
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_highlight",
|
||||
type: "assistant",
|
||||
agent: session.agent,
|
||||
model: session.model,
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
input.kind === "write"
|
||||
? {
|
||||
type: "tool",
|
||||
id: "call_highlight",
|
||||
name: "write",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: `${directory}/snippet.ts`, content: "const flashFixture: number = 42" },
|
||||
content: [{ type: "text", text: "Wrote file" }],
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: "text",
|
||||
text:
|
||||
input.kind === "list"
|
||||
? "- Example:\n\n ```typescript\n const flashFixture: number = 42\n ```"
|
||||
: "```typescript\nconst flashFixture: number = 42\n```",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
|
||||
},
|
||||
})
|
||||
await setup.ready
|
||||
const line = () =>
|
||||
setup.captureSpans().lines.find((line) => line.spans.some((span) => span.text.includes("flashFixture")))
|
||||
await setup.waitForFrame((frame) => frame.includes("flashFixture"))
|
||||
const first = line()
|
||||
await setup.waitForFrame(() => (line()?.spans.filter((span) => span.text.trim()).length ?? 0) > 4)
|
||||
const highlighted = line()
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressKey("n")
|
||||
await setup.waitForFrame((frame) => !frame.includes("flashFixture"))
|
||||
setup.mockInput.pressKey("1", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("flashFixture"))
|
||||
const returned = line()
|
||||
await setup.waitForFrame(() => (line()?.spans.filter((span) => span.text.trim()).length ?? 0) > 4)
|
||||
expect(returned).toEqual(highlighted)
|
||||
expect(first).toEqual(line())
|
||||
},
|
||||
)
|
||||
|
||||
async function createAppFixture(
|
||||
input: {
|
||||
width?: number
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
import { once } from "node:events"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import {
|
||||
BoxRenderable,
|
||||
CliRenderEvents,
|
||||
DiffRenderable,
|
||||
ImageRenderable,
|
||||
InputRenderable,
|
||||
MouseButton,
|
||||
type Renderable,
|
||||
ScrollBoxRenderable,
|
||||
@@ -315,6 +318,9 @@ test.each(["branch", "committed", "working"] as const)(
|
||||
expect(viewer.app.captureCharFrame()).toMatch(/●\s+v2/)
|
||||
expect(viewer.branchesRequests[0].searchParams.get("location[directory]")).toBe("/repo/session")
|
||||
expect(viewer.branchesRequests[0].searchParams.get("limit")).toBe("100")
|
||||
// The picker can paint before its deferred input focus.
|
||||
if (!viewer.app.renderer.currentFocusedEditor) await once(viewer.app.renderer, CliRenderEvents.FOCUSED_EDITOR)
|
||||
expect(viewer.app.renderer.currentFocusedEditor).toBeInstanceOf(InputRenderable)
|
||||
await viewer.app.mockInput.typeText("origin/release")
|
||||
await Bun.sleep(160)
|
||||
await viewer.app.waitFor(() => viewer.branchesRequests.at(-1)?.searchParams.get("search") === "origin/release")
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SyntaxStyle, TextRenderable, type MarkdownOptions, type RenderNodeContext } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import {
|
||||
CodeRenderable,
|
||||
MarkdownRenderable,
|
||||
SyntaxStyle,
|
||||
TextRenderable,
|
||||
type MarkdownOptions,
|
||||
type RenderNodeContext,
|
||||
} from "@opentui/core"
|
||||
import { createTestRenderer, MockTreeSitterClient } from "@opentui/core/testing"
|
||||
import { combineMarkdownRenderers } from "../src/plugin/context"
|
||||
|
||||
const code = (language: string) => ({ type: "code" as const, lang: language, text: "content", raw: "" })
|
||||
@@ -29,6 +36,132 @@ test("later Markdown renderer registrations take precedence", async () => {
|
||||
const combined = combineMarkdownRenderers([{ mermaid: () => first }, { mermaid: () => second }])!
|
||||
|
||||
expect(combined(code("mermaid"), context)).toBe(second)
|
||||
expect(combineMarkdownRenderers([])).toBeUndefined()
|
||||
expect(combineMarkdownRenderers([])).toBeFunction()
|
||||
renderer.destroy()
|
||||
})
|
||||
|
||||
test.each(["returned", "declined"])("reuses a plugin's %s default code block", async (fallback) => {
|
||||
const output = await createTestRenderer({ width: 80, height: 10 })
|
||||
const defaults: Array<ReturnType<RenderNodeContext["defaultRender"]>> = []
|
||||
const markdown = new MarkdownRenderable(output.renderer, {
|
||||
content: "```typescript\nconst fixture = 42\n```",
|
||||
syntaxStyle: context.syntaxStyle,
|
||||
renderNode: combineMarkdownRenderers([
|
||||
{
|
||||
typescript: (_token, context) => {
|
||||
defaults.push(context.defaultRender())
|
||||
return fallback === "returned" ? defaults.at(-1) : undefined
|
||||
},
|
||||
},
|
||||
]),
|
||||
})
|
||||
output.renderer.root.add(markdown)
|
||||
try {
|
||||
const code = markdown.getChildren()[0]
|
||||
expect(defaults).toHaveLength(1)
|
||||
expect(defaults[0]).toBe(code)
|
||||
if (!(code instanceof CodeRenderable)) throw new Error("Expected fenced code")
|
||||
expect(code.drawUnstyledText).toBe(false)
|
||||
await output.renderOnce()
|
||||
await code.highlightingDone
|
||||
await output.renderOnce()
|
||||
expect(output.captureCharFrame()).toContain("const fixture = 42")
|
||||
} finally {
|
||||
output.renderer.destroy()
|
||||
}
|
||||
expect(defaults[0]?.isDestroyed).toBe(true)
|
||||
})
|
||||
|
||||
test.each(["typescript", "text", "unknown-fixture-language", ""])(
|
||||
"renders %s fences without losing content",
|
||||
async (language) => {
|
||||
const output = await createTestRenderer({ width: 80, height: 10 })
|
||||
const markdown = new MarkdownRenderable(output.renderer, {
|
||||
content: `\`\`\`${language}\nconst fixture = 42\n\`\`\``,
|
||||
syntaxStyle: context.syntaxStyle,
|
||||
renderNode: combineMarkdownRenderers([]),
|
||||
})
|
||||
output.renderer.root.add(markdown)
|
||||
try {
|
||||
await output.renderOnce()
|
||||
const code = markdown.getChildren()[0]
|
||||
expect(code).toBeInstanceOf(CodeRenderable)
|
||||
if (!(code instanceof CodeRenderable)) throw new Error("Expected fenced code")
|
||||
await code.highlightingDone
|
||||
await output.renderOnce()
|
||||
expect(output.captureCharFrame()).toContain("const fixture = 42")
|
||||
} finally {
|
||||
output.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("keeps the fenced code node while streaming", async () => {
|
||||
const output = await createTestRenderer({ width: 80, height: 10 })
|
||||
const markdown = new MarkdownRenderable(output.renderer, {
|
||||
content: "```typescript\nconst fixture = 4",
|
||||
internalBlockMode: "top-level",
|
||||
streaming: true,
|
||||
syntaxStyle: context.syntaxStyle,
|
||||
renderNode: combineMarkdownRenderers([]),
|
||||
})
|
||||
output.renderer.root.add(markdown)
|
||||
try {
|
||||
await output.renderOnce()
|
||||
const code = markdown.getChildren()[0]
|
||||
if (!(code instanceof CodeRenderable)) throw new Error("Expected fenced code")
|
||||
await code.highlightingDone
|
||||
markdown.content = "```typescript\nconst fixture = 42"
|
||||
await output.renderOnce()
|
||||
expect(markdown.getChildren()[0] === code).toBe(true)
|
||||
await code.highlightingDone
|
||||
await output.renderOnce()
|
||||
expect(output.captureCharFrame()).toContain("const fixture = 42")
|
||||
markdown.content += "\n```"
|
||||
markdown.streaming = false
|
||||
await output.renderOnce()
|
||||
const completed = markdown.getChildren()[0]
|
||||
if (!(completed instanceof CodeRenderable)) throw new Error("Expected completed fenced code")
|
||||
await completed.highlightingDone
|
||||
await output.renderOnce()
|
||||
expect(output.captureCharFrame()).toContain("const fixture = 42")
|
||||
} finally {
|
||||
output.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["error", "rejection"])(
|
||||
"reserves code layout and shows a readable fallback on parser %s",
|
||||
async (failure) => {
|
||||
const output = await createTestRenderer({ width: 80, height: 10 })
|
||||
const pending = Promise.withResolvers<{ error: string }>()
|
||||
const client = new MockTreeSitterClient()
|
||||
client.highlightOnce = () => pending.promise
|
||||
const markdown = new MarkdownRenderable(output.renderer, {
|
||||
content: "```typescript\nconst fixture = 42\n```",
|
||||
syntaxStyle: context.syntaxStyle,
|
||||
treeSitterClient: client,
|
||||
renderNode: combineMarkdownRenderers([]),
|
||||
})
|
||||
output.renderer.root.add(markdown)
|
||||
try {
|
||||
await output.renderOnce()
|
||||
const code = markdown.getChildren()[0]
|
||||
if (!(code instanceof CodeRenderable)) throw new Error("Expected fenced code")
|
||||
expect(code.isHighlighting).toBe(true)
|
||||
const height = code.height
|
||||
expect(height).toBeGreaterThan(0)
|
||||
expect(output.captureCharFrame()).not.toContain("const fixture")
|
||||
if (failure === "error") pending.resolve({ error: "Parser unavailable" })
|
||||
if (failure === "rejection") pending.reject(new Error("Worker unavailable"))
|
||||
await code.highlightingDone
|
||||
await output.renderOnce()
|
||||
expect(output.captureCharFrame()).toContain("const fixture = 42")
|
||||
expect(code.height).toBe(height)
|
||||
} finally {
|
||||
pending.resolve({ error: "Parser unavailable" })
|
||||
output.renderer.destroy()
|
||||
await client.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { cacheHighlights } from "../../src/util/syntax-highlight-cache"
|
||||
|
||||
describe("syntax highlight cache", () => {
|
||||
test("reuses completed and in-flight highlights", async () => {
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(async () => {
|
||||
calls++
|
||||
return { highlights: [[0, 5, "keyword"]] }
|
||||
})
|
||||
|
||||
const first = highlight("const", "typescript")
|
||||
const second = highlight("const", "typescript")
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(await second).toEqual({ highlights: [[0, 5, "keyword"]] })
|
||||
expect(await highlight("const", "typescript")).toEqual({ highlights: [[0, 5, "keyword"]] })
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("evicts least recently used highlights", async () => {
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(async () => {
|
||||
calls++
|
||||
return { highlights: [] }
|
||||
}, 2)
|
||||
|
||||
await highlight("one", "text")
|
||||
await highlight("two", "text")
|
||||
await highlight("one", "text")
|
||||
await highlight("three", "text")
|
||||
await highlight("two", "text")
|
||||
|
||||
expect(calls).toBe(4)
|
||||
})
|
||||
|
||||
test("retries failed highlights", async () => {
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(async () => {
|
||||
calls++
|
||||
if (calls === 1) return { error: "parser unavailable" }
|
||||
return { highlights: [] }
|
||||
})
|
||||
|
||||
await highlight("const", "typescript")
|
||||
await highlight("const", "typescript")
|
||||
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test("an evicted failure does not delete its replacement", async () => {
|
||||
const pending = Promise.withResolvers<{ highlights: [] }>()
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(() => {
|
||||
calls++
|
||||
if (calls === 1) return pending.promise
|
||||
return Promise.resolve({ highlights: [] })
|
||||
}, 1)
|
||||
|
||||
const stale = highlight("one", "text")
|
||||
await highlight("two", "text")
|
||||
const current = highlight("one", "text")
|
||||
pending.reject(new Error("parser unavailable"))
|
||||
|
||||
await expect(stale).rejects.toThrow("parser unavailable")
|
||||
expect(highlight("one", "text")).toBe(current)
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
})
|
||||
+26
-18
@@ -7,27 +7,35 @@ export default $config({
|
||||
removal: input?.stage === "production" ? "retain" : "remove",
|
||||
protect: ["production"].includes(input?.stage),
|
||||
home: "cloudflare",
|
||||
providers: {
|
||||
aws: {
|
||||
version: "7.30.0",
|
||||
region: "us-east-1",
|
||||
profile: process.env.GITHUB_ACTIONS
|
||||
? undefined
|
||||
: input.stage === "production"
|
||||
? "opencode-production"
|
||||
: "opencode-dev",
|
||||
},
|
||||
stripe: {
|
||||
version: "0.0.28",
|
||||
apiKey: process.env.STRIPE_SECRET_KEY!,
|
||||
},
|
||||
random: "4.19.2",
|
||||
planetscale: "0.4.1",
|
||||
honeycomb: "0.49.0",
|
||||
},
|
||||
providers:
|
||||
input.stage === "beta"
|
||||
? {}
|
||||
: {
|
||||
aws: {
|
||||
version: "7.30.0",
|
||||
region: "us-east-1",
|
||||
profile: process.env.GITHUB_ACTIONS
|
||||
? undefined
|
||||
: input.stage === "production"
|
||||
? "opencode-production"
|
||||
: "opencode-dev",
|
||||
},
|
||||
stripe: {
|
||||
version: "0.0.28",
|
||||
apiKey: process.env.STRIPE_SECRET_KEY!,
|
||||
},
|
||||
random: "4.19.2",
|
||||
planetscale: "0.4.1",
|
||||
honeycomb: "0.49.0",
|
||||
},
|
||||
}
|
||||
},
|
||||
async run() {
|
||||
if ($app.stage === "beta") {
|
||||
const { createWebApp } = await import("./infra/webapp.js")
|
||||
return { WebAppUrl: createWebApp("beta.opencode.ai").url }
|
||||
}
|
||||
|
||||
const stage = await import("./infra/stage.js")
|
||||
await import("./infra/app.js")
|
||||
const lake = stage.deployAws ? await import("./infra/lake.js") : undefined
|
||||
|
||||
Reference in New Issue
Block a user