mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 05:56:19 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7255da4138 | ||
|
|
13960f7fd4 | ||
|
|
3c6b85acf7 | ||
|
|
50e77f66fd | ||
|
|
d484f070d1 | ||
|
|
8890294bf0 | ||
|
|
a1925de0c1 | ||
|
|
24e826d06b | ||
|
|
19625400c1 | ||
|
|
52cbe7b3ee | ||
|
|
6a2c3e91c7 | ||
|
|
4afd8e81be | ||
|
|
485bdc9c4e | ||
|
|
1a3aee39de | ||
|
|
2ddf257c6f | ||
|
|
ef50e0b6d8 | ||
|
|
583a1a2b6f | ||
|
|
68c1207b52 | ||
|
|
f77647ad12 | ||
|
|
c746ea3210 | ||
|
|
d323b34826 | ||
|
|
bf50194f99 | ||
|
|
4a977b2b31 | ||
|
|
b1e3a7b222 | ||
|
|
b12d43698e | ||
|
|
5cd2c22b27 | ||
|
|
c2ef80a287 | ||
|
|
4df3029536 |
@@ -46,6 +46,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Validate unknown values once at the boundary that owns them. Pass typed values inward instead of repeating `typeof value === "object"` and property-existence checks. Do not defensively revalidate values already guaranteed by a schema, constructor, or internal type.
|
||||
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
|
||||
- Avoid `try`/`catch` where possible
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
@@ -430,6 +431,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 +1680,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 +2776,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 +3534,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 +3814,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 +3886,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 +4208,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 +5814,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 +5962,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 +6118,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 +6156,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 +6302,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 +6384,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 +6394,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 +6440,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 +6468,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 +6524,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 +6570,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 +6656,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 +6798,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 +6922,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 +7146,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 +7252,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 +7270,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 +7406,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 +7418,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 +7468,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 +7500,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 +8180,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=="],
|
||||
|
||||
+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-fG6VYtNC0pce4VM9po7vVucPuJul42yuuijTjNSr7rk=",
|
||||
"aarch64-linux": "sha256-3TznrmNqdt25cOxia6vcdi/5qKaeyLPIsNXGYBSJNrs=",
|
||||
"aarch64-darwin": "sha256-8Kmagb5tfECSWZNsIJgrRP1d3X5tuEoWLEWkV3UENZo=",
|
||||
"x86_64-darwin": "sha256-mIV+mDwIGD02BNYZVi37sY4ls1T01N6z76eBtH0sKiA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,6 +831,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
const content: AnthropicUserBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
if (part.text.trim().length === 0) continue
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
continue
|
||||
}
|
||||
@@ -840,7 +841,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
if (content.length > 0) messages.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -848,6 +849,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
const content: AnthropicAssistantBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
if (part.text.trim().length === 0) continue
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
continue
|
||||
}
|
||||
@@ -891,7 +893,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
|
||||
)
|
||||
}
|
||||
messages.push({ role: "assistant", content })
|
||||
if (content.length > 0) messages.push({ role: "assistant", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1019,10 +1021,11 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
|
||||
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
||||
const systemParts = request.system.filter((part) => part.text.length > 0)
|
||||
const system =
|
||||
request.system.length === 0
|
||||
systemParts.length === 0
|
||||
? undefined
|
||||
: request.system.map((part) => ({
|
||||
: systemParts.map((part) => ({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
@@ -1375,7 +1378,17 @@ const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (st
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
const finished = Lifecycle.finish(lifecycle, events, {
|
||||
const closed = Object.entries(state.reasoningSignatures).reduce(
|
||||
(current, [index, signature]) =>
|
||||
Lifecycle.reasoningEnd(
|
||||
current,
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
providerMetadata(state.providerMetadataKey, { signature }),
|
||||
),
|
||||
lifecycle,
|
||||
)
|
||||
const finished = Lifecycle.finish(closed, events, {
|
||||
reason: state.pendingFinish?.reason ?? {
|
||||
normalized: "unknown",
|
||||
raw: undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -262,24 +262,28 @@ const providerMetadata = (key: string, metadata: Record<string, unknown>): Provi
|
||||
|
||||
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return (
|
||||
part.encrypted ??
|
||||
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
|
||||
)
|
||||
if (part.encrypted !== undefined) return part.encrypted
|
||||
if (ProviderShared.isRecord(metadata) && typeof metadata.signature === "string") return metadata.signature
|
||||
}
|
||||
|
||||
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
|
||||
? metadata.redactedData
|
||||
: undefined
|
||||
if (ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string") return metadata.redactedData
|
||||
}
|
||||
|
||||
const removeEmptyToolInputKeys = (input: unknown): unknown => {
|
||||
if (Array.isArray(input)) return input.map(removeEmptyToolInputKeys)
|
||||
if (!ProviderShared.isRecord(input)) return input
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).flatMap(([key, value]) => (key === "" ? [] : [[key, removeEmptyToolInputKeys(value)]])),
|
||||
)
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
toolUse: {
|
||||
toolUseId: part.id,
|
||||
name: part.name,
|
||||
input: part.input,
|
||||
input: removeEmptyToolInputKeys(part.input),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -414,7 +418,12 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
) => {
|
||||
const content = system
|
||||
.filter((part) => part.text.length > 0)
|
||||
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
return content.length === 0 ? undefined : content
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
@@ -422,38 +431,42 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const toolConfig =
|
||||
request.tools.length > 0
|
||||
? {
|
||||
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
|
||||
// Converse has no native "none". Keep definitions stable for prompt
|
||||
// caching and omit only the unsupported choice.
|
||||
toolChoice,
|
||||
}
|
||||
: undefined
|
||||
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||
const toolConfig = (() => {
|
||||
if (request.tools.length === 0) return undefined
|
||||
return {
|
||||
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
|
||||
// Converse has no native "none". Keep definitions stable for prompt
|
||||
// caching and omit only the unsupported choice.
|
||||
toolChoice,
|
||||
}
|
||||
})()
|
||||
const system = lowerSystem(breakpoints, request.system)
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
if (breakpoints.dropped > 0) {
|
||||
yield* Effect.logWarning(
|
||||
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
modelId: request.model.id,
|
||||
messages,
|
||||
system,
|
||||
inferenceConfig:
|
||||
const inferenceConfig = (() => {
|
||||
if (
|
||||
generation?.maxTokens === undefined &&
|
||||
generation?.temperature === undefined &&
|
||||
generation?.topP === undefined &&
|
||||
(generation?.stop === undefined || generation.stop.length === 0)
|
||||
? undefined
|
||||
: {
|
||||
maxTokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
topP: generation?.topP,
|
||||
stopSequences: generation?.stop,
|
||||
},
|
||||
)
|
||||
return undefined
|
||||
return {
|
||||
maxTokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
topP: generation?.topP,
|
||||
stopSequences: generation?.stop,
|
||||
}
|
||||
})()
|
||||
return {
|
||||
modelId: request.model.id,
|
||||
messages,
|
||||
system,
|
||||
inferenceConfig,
|
||||
toolConfig,
|
||||
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
|
||||
// as a model-specific field, so it goes through additionalModelRequestFields.
|
||||
@@ -469,7 +482,6 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
||||
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -504,6 +516,16 @@ interface ParserState {
|
||||
readonly hasToolCalls: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
readonly reasoningRedactedContent: Readonly<Record<number, ReadonlyArray<Uint8Array>>>
|
||||
}
|
||||
|
||||
const encodeRedactedContent = (chunks: ReadonlyArray<Uint8Array>) => {
|
||||
const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0))
|
||||
chunks.reduce((offset, chunk) => {
|
||||
bytes.set(chunk, offset)
|
||||
return offset + chunk.length
|
||||
}, 0)
|
||||
return Encoding.encodeBase64(bytes)
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: BedrockEvent) =>
|
||||
@@ -551,23 +573,46 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||
const events: LLMEvent[] = []
|
||||
const redactedData = reasoning.redactedContent ?? reasoning.data
|
||||
const metadata = reasoning.signature
|
||||
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
|
||||
: redactedData !== undefined
|
||||
? providerMetadata(state.providerMetadataKey, { redactedData })
|
||||
: undefined
|
||||
const lifecycle =
|
||||
reasoning.text !== undefined || metadata !== undefined
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
|
||||
: state.lifecycle
|
||||
const redactedChunks = yield* (() => {
|
||||
if (reasoning.redactedContent === undefined) return Effect.succeed(undefined)
|
||||
return Effect.fromResult(Encoding.decodeBase64(reasoning.redactedContent)).pipe(
|
||||
Effect.map((chunk) => [...(state.reasoningRedactedContent[index] ?? []), chunk]),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Bedrock Converse reasoningContent.redactedContent contains invalid base64 data",
|
||||
undefined,
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
})()
|
||||
const redactedData = redactedChunks === undefined ? reasoning.data : encodeRedactedContent(redactedChunks)
|
||||
const metadata = (() => {
|
||||
if (reasoning.signature) return providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
|
||||
if (redactedData !== undefined) return providerMetadata(state.providerMetadataKey, { redactedData })
|
||||
})()
|
||||
const lifecycle = (() => {
|
||||
if (reasoning.text === undefined && metadata === undefined) return state.lifecycle
|
||||
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
|
||||
})()
|
||||
const reasoningRedactedContent = (() => {
|
||||
if (redactedChunks !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
|
||||
if (reasoning.data === undefined) return state.reasoningRedactedContent
|
||||
return Object.fromEntries(
|
||||
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
|
||||
)
|
||||
})()
|
||||
const reasoningSignatures = (() => {
|
||||
if (!reasoning.signature) return state.reasoningSignatures
|
||||
return { ...state.reasoningSignatures, [index]: reasoning.signature }
|
||||
})()
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningSignatures: reasoning.signature
|
||||
? { ...state.reasoningSignatures, [index]: reasoning.signature }
|
||||
: state.reasoningSignatures,
|
||||
reasoningSignatures,
|
||||
reasoningRedactedContent,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
@@ -595,16 +640,24 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, index)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const lifecycle = resultEvents.length
|
||||
? Lifecycle.stepStart(state.lifecycle, events)
|
||||
: Lifecycle.reasoningEnd(
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
state.reasoningSignatures[index]
|
||||
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
|
||||
: undefined,
|
||||
)
|
||||
const lifecycle = (() => {
|
||||
if (resultEvents.length) return Lifecycle.stepStart(state.lifecycle, events)
|
||||
const metadata = (() => {
|
||||
const signature = state.reasoningSignatures[index]
|
||||
if (signature) return providerMetadata(state.providerMetadataKey, { signature })
|
||||
const redactedContent = state.reasoningRedactedContent[index]
|
||||
if (redactedContent)
|
||||
return providerMetadata(state.providerMetadataKey, {
|
||||
redactedData: encodeRedactedContent(redactedContent),
|
||||
})
|
||||
})()
|
||||
return Lifecycle.reasoningEnd(
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
metadata,
|
||||
)
|
||||
})()
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
{
|
||||
@@ -618,12 +671,24 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
reasoningSignatures: Object.fromEntries(
|
||||
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
|
||||
),
|
||||
reasoningRedactedContent: Object.fromEntries(
|
||||
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
|
||||
),
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
}
|
||||
|
||||
if (event.messageStop) {
|
||||
if (
|
||||
event.messageStop.stopReason === "malformed_model_output" ||
|
||||
event.messageStop.stopReason === "malformed_tool_use"
|
||||
)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
`Bedrock Converse stopped with ${event.messageStop.stopReason}`,
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -670,23 +735,22 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
|
||||
const framing = BedrockEventStream.framing(ADAPTER)
|
||||
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.pendingFinish
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
...state.pendingFinish.reason,
|
||||
normalized:
|
||||
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
|
||||
? "tool-calls"
|
||||
: state.pendingFinish.reason.normalized,
|
||||
},
|
||||
usage: state.pendingFinish.usage,
|
||||
})
|
||||
return events
|
||||
})()
|
||||
: []
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
if (!state.pendingFinish) return []
|
||||
const normalized = (() => {
|
||||
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
|
||||
return state.pendingFinish.reason.normalized
|
||||
})()
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
...state.pendingFinish.reason,
|
||||
normalized,
|
||||
},
|
||||
usage: state.pendingFinish.usage,
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Protocol And Bedrock Route
|
||||
@@ -711,6 +775,7 @@ export const protocol = Protocol.make({
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningSignatures: {},
|
||||
reasoningRedactedContent: {},
|
||||
}),
|
||||
step,
|
||||
onHalt: (state) => Effect.succeed(onHalt(state)),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { Effect, Encoding, Stream } from "effect"
|
||||
import { AIError, AIErrorReason } from "../schema/index.js"
|
||||
import { AIError, AIErrorReason, InvalidProviderOutputError } from "../schema/index.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
|
||||
@@ -22,6 +22,10 @@ interface FrameBufferState {
|
||||
|
||||
const initialFrameBuffer: FrameBufferState = { buffer: new Uint8Array(0), offset: 0 }
|
||||
|
||||
type FrameInput = { readonly _tag: "Chunk"; readonly bytes: Uint8Array } | { readonly _tag: "End" }
|
||||
|
||||
const endOfStream: FrameInput = { _tag: "End" }
|
||||
|
||||
const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferState => {
|
||||
const remaining = state.buffer.length - state.offset
|
||||
// Compact: drop the consumed prefix and append the new chunk in one alloc.
|
||||
@@ -33,9 +37,23 @@ const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferSta
|
||||
return { buffer: next, offset: 0 }
|
||||
}
|
||||
|
||||
const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8Array) =>
|
||||
const consumeFrames = (route: string) => (state: FrameBufferState, input: FrameInput) =>
|
||||
Effect.gen(function* () {
|
||||
let cursor = appendChunk(state, chunk)
|
||||
if (input._tag === "End") {
|
||||
const remaining = state.buffer.subarray(state.offset)
|
||||
if (remaining.length > 0)
|
||||
return yield* new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route,
|
||||
classification: "incomplete-stream",
|
||||
message: `Incomplete Bedrock Converse event-stream frame: ${remaining.length} buffered bytes remain at end of stream`,
|
||||
body: Encoding.encodeBase64(remaining),
|
||||
}),
|
||||
})
|
||||
return [state, []] as const
|
||||
}
|
||||
|
||||
let cursor = appendChunk(state, input.bytes)
|
||||
const out: object[] = []
|
||||
while (cursor.buffer.length - cursor.offset >= 4) {
|
||||
const view = cursor.buffer.subarray(cursor.offset)
|
||||
@@ -113,7 +131,12 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
export const framing = (route: string): Framing.Definition<object> => ({
|
||||
id: "aws-event-stream",
|
||||
body: (frame) => ("rawBody" in frame && typeof frame.rawBody === "string" ? frame.rawBody : undefined),
|
||||
frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
|
||||
frame: (bytes) =>
|
||||
bytes.pipe(
|
||||
Stream.map((bytes): FrameInput => ({ _tag: "Chunk", bytes })),
|
||||
Stream.concat(Stream.succeed(endOfStream)),
|
||||
Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route)),
|
||||
),
|
||||
})
|
||||
|
||||
export * as BedrockEventStream from "./bedrock-event-stream.js"
|
||||
|
||||
@@ -240,6 +240,10 @@ interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
readonly textSignature?: string
|
||||
readonly reasoningId?: string
|
||||
readonly textId?: string
|
||||
readonly nextReasoningId: number
|
||||
readonly nextTextId: number
|
||||
readonly seenCallIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
@@ -571,19 +575,23 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
let lifecycle = state.lifecycle
|
||||
if (state.reasoningSignature !== undefined)
|
||||
if (state.reasoningId !== undefined)
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
state.reasoningId,
|
||||
state.reasoningSignature === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
if (state.textSignature !== undefined)
|
||||
if (state.textId !== undefined)
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
state.textId,
|
||||
state.textSignature === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
)
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
@@ -632,6 +640,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
let lifecycle = nextState.lifecycle
|
||||
let reasoningSignature = nextState.reasoningSignature
|
||||
let textSignature = nextState.textSignature
|
||||
let reasoningId = nextState.reasoningId
|
||||
let textId = nextState.textId
|
||||
let nextReasoningId = nextState.nextReasoningId
|
||||
let nextTextId = nextState.nextTextId
|
||||
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||
const seenCallIds = new Set(nextState.seenCallIds)
|
||||
|
||||
@@ -657,27 +669,51 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
else if (signature !== undefined && "text" in part) textSignature = signature
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
if (part.thought) {
|
||||
if (textId !== undefined) {
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
textId,
|
||||
textSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature })
|
||||
: undefined,
|
||||
)
|
||||
textId = undefined
|
||||
textSignature = undefined
|
||||
}
|
||||
if (reasoningId === undefined) {
|
||||
reasoningId = `reasoning-${nextReasoningId}`
|
||||
nextReasoningId += 1
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningId,
|
||||
part.text,
|
||||
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
|
||||
)
|
||||
continue
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
if (reasoningId !== undefined) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
reasoningId,
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
reasoningId = undefined
|
||||
reasoningSignature = undefined
|
||||
}
|
||||
if (textId === undefined) {
|
||||
textId = `text-${nextTextId}`
|
||||
nextTextId += 1
|
||||
}
|
||||
lifecycle = Lifecycle.textDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
textId,
|
||||
part.text,
|
||||
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
|
||||
)
|
||||
@@ -695,14 +731,28 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
if (reasoningId !== undefined) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
reasoningId,
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
reasoningId = undefined
|
||||
reasoningSignature = undefined
|
||||
}
|
||||
if (textId !== undefined) {
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
textId,
|
||||
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
|
||||
)
|
||||
textId = undefined
|
||||
textSignature = undefined
|
||||
}
|
||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
@@ -725,6 +775,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
reasoningId,
|
||||
textId,
|
||||
nextReasoningId,
|
||||
nextTextId,
|
||||
seenCallIds,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
@@ -752,6 +806,8 @@ export const protocol = Protocol.make({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
nextReasoningId: 0,
|
||||
nextTextId: 0,
|
||||
}),
|
||||
step,
|
||||
onHalt: (state) => Effect.succeed(finish(state)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as AnthropicMessages from "./anthropic-messages.js"
|
||||
export * as BedrockConverse from "./bedrock-converse.js"
|
||||
export * as Gemini from "./gemini.js"
|
||||
export * as MistralChat from "./mistral-chat.js"
|
||||
export * as OpenAIChat from "./openai-chat.js"
|
||||
export * as OpenAIImages from "./openai-images.js"
|
||||
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReasonDetails,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "mistral-chat"
|
||||
const DONE = "[DONE]" as const
|
||||
const TOOL_ID = /^[A-Za-z0-9]{9}$/
|
||||
export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
|
||||
export const PATH = "/chat/completions"
|
||||
|
||||
const MistralTextContent = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
const MistralThinkingUnit = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type MistralThinkingUnit = Schema.Schema.Type<typeof MistralThinkingUnit>
|
||||
|
||||
const MistralThinkingContent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("thinking"),
|
||||
thinking: Schema.Array(MistralThinkingUnit),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type MistralThinkingContent = Schema.Schema.Type<typeof MistralThinkingContent>
|
||||
const isMistralThinkingContent = Schema.is(MistralThinkingContent)
|
||||
|
||||
const MistralUserContent = Schema.Union([
|
||||
MistralTextContent,
|
||||
Schema.Struct({ type: Schema.Literal("image_url"), image_url: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("document_url"), document_url: Schema.String }),
|
||||
])
|
||||
type MistralUserContent = Schema.Schema.Type<typeof MistralUserContent>
|
||||
|
||||
const MistralAssistantToolCall = Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal("function"),
|
||||
function: Schema.Struct({ name: Schema.String, arguments: Schema.String }),
|
||||
})
|
||||
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
|
||||
|
||||
const MistralMessage = Schema.Union([
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
|
||||
}),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("assistant"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(Schema.Union([MistralTextContent, MistralThinkingContent]))]),
|
||||
tool_calls: optionalArray(MistralAssistantToolCall),
|
||||
prefix: Schema.optional(Schema.Literal(true)),
|
||||
}),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("tool"),
|
||||
tool_call_id: Schema.String,
|
||||
name: Schema.String,
|
||||
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type MistralMessage = Schema.Schema.Type<typeof MistralMessage>
|
||||
|
||||
const MistralTool = Schema.Struct({
|
||||
type: Schema.Literal("function"),
|
||||
function: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
parameters: JsonObject,
|
||||
strict: Schema.Literal(false),
|
||||
}),
|
||||
})
|
||||
type MistralTool = Schema.Schema.Type<typeof MistralTool>
|
||||
|
||||
const MistralOptions = Schema.Struct({
|
||||
safePrompt: Schema.optional(Schema.Boolean),
|
||||
documentImageLimit: Schema.optional(Schema.Number),
|
||||
documentPageLimit: Schema.optional(Schema.Number),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
reasoningEffort: Schema.optional(Schema.String),
|
||||
promptMode: Schema.optional(Schema.Literal("reasoning")),
|
||||
promptCacheKey: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | (string & {})
|
||||
|
||||
export type ProviderOptionsInput = {
|
||||
readonly safePrompt?: boolean
|
||||
readonly documentImageLimit?: number
|
||||
readonly documentPageLimit?: number
|
||||
readonly parallelToolCalls?: boolean
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly promptMode?: "reasoning"
|
||||
readonly promptCacheKey?: string
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
const MistralBody = Schema.Struct({
|
||||
model: Schema.String,
|
||||
messages: Schema.Array(MistralMessage),
|
||||
tools: optionalArray(MistralTool),
|
||||
tool_choice: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literals(["auto", "none", "any"]),
|
||||
Schema.Struct({ type: Schema.Literal("function"), function: Schema.Struct({ name: Schema.String }) }),
|
||||
]),
|
||||
),
|
||||
stream: Schema.Literal(true),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
random_seed: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
frequency_penalty: Schema.optional(Schema.Number),
|
||||
presence_penalty: Schema.optional(Schema.Number),
|
||||
stop: optionalArray(Schema.String),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
safe_prompt: Schema.optional(Schema.Boolean),
|
||||
document_image_limit: Schema.optional(Schema.Number),
|
||||
document_page_limit: Schema.optional(Schema.Number),
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
reasoning_effort: Schema.optional(Schema.String),
|
||||
prompt_mode: Schema.optional(Schema.Literal("reasoning")),
|
||||
})
|
||||
export type MistralBody = Schema.Schema.Type<typeof MistralBody>
|
||||
|
||||
const MistralUsageDetails = Schema.StructWithRest(Schema.Struct({ cached_tokens: optionalNull(Schema.Number) }), [
|
||||
Schema.Record(Schema.String, Schema.Unknown),
|
||||
])
|
||||
|
||||
const MistralUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
num_cached_tokens: optionalNull(Schema.Number),
|
||||
prompt_token_details: optionalNull(MistralUsageDetails),
|
||||
prompt_tokens_details: optionalNull(MistralUsageDetails),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const MistralOutputContent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
text: optionalNull(Schema.String),
|
||||
thinking: optionalNull(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type MistralOutputContent = Schema.Schema.Type<typeof MistralOutputContent>
|
||||
|
||||
const MistralToolDelta = Schema.Struct({
|
||||
index: optionalNull(Schema.Number),
|
||||
id: optionalNull(Schema.String),
|
||||
function: optionalNull(
|
||||
Schema.Struct({
|
||||
name: optionalNull(Schema.String),
|
||||
arguments: optionalNull(Schema.Union([Schema.String, JsonObject])),
|
||||
}),
|
||||
),
|
||||
})
|
||||
type MistralToolDelta = Schema.Schema.Type<typeof MistralToolDelta>
|
||||
|
||||
const MistralChoice = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
delta: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
content: optionalNull(Schema.Union([Schema.String, Schema.Array(MistralOutputContent)])),
|
||||
tool_calls: optionalNull(Schema.Array(MistralToolDelta)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const MistralError = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
message: Schema.String,
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const MistralEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
choices: optionalNull(Schema.Array(MistralChoice)),
|
||||
usage: optionalNull(MistralUsage),
|
||||
error: optionalNull(MistralError),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
|
||||
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
|
||||
|
||||
const hashID = (value: string) => {
|
||||
const hash = (seed: number) => {
|
||||
let result = seed
|
||||
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
|
||||
return (result >>> 0).toString(36)
|
||||
}
|
||||
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
|
||||
}
|
||||
|
||||
const toolIDNormalizer = (request: LLMRequest) => {
|
||||
const ids = request.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
|
||||
)
|
||||
const used = new Set(ids.filter((id) => TOOL_ID.test(id)))
|
||||
const normalized = new Map<string, string>()
|
||||
return (id: string) => {
|
||||
if (TOOL_ID.test(id)) return id
|
||||
const previous = normalized.get(id)
|
||||
if (previous) return previous
|
||||
let attempt = 0
|
||||
let candidate = hashID(id)
|
||||
while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`)
|
||||
used.add(candidate)
|
||||
normalized.set(id, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl
|
||||
if (media.mime.startsWith("image/")) return { type: "image_url" as const, image_url: url }
|
||||
if (media.mime === "application/pdf") return { type: "document_url" as const, document_url: url }
|
||||
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message: LLMRequest["messages"][number]) {
|
||||
const content: MistralUserContent[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text })
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
content.push(yield* lowerMedia(part))
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Mistral Chat", "user", ["text", "media"])
|
||||
}
|
||||
if (content.every((part) => part.type === "text"))
|
||||
return { role: "user" as const, content: content.map((part) => part.text).join("") }
|
||||
return { role: "user" as const, content }
|
||||
})
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): MistralAssistantToolCall => ({
|
||||
id: normalizeID(part.id),
|
||||
type: "function",
|
||||
function: { name: part.name, arguments: ProviderShared.encodeJson(part.input) },
|
||||
})
|
||||
|
||||
const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
normalizeID: (id: string) => string,
|
||||
prefix: boolean,
|
||||
) {
|
||||
const structured = message.content.some(
|
||||
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
|
||||
)
|
||||
const content: Array<Schema.Schema.Type<typeof MistralTextContent> | MistralThinkingContent> = []
|
||||
const text: string[] = []
|
||||
const toolCalls: MistralAssistantToolCall[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
if (structured) content.push({ type: "text", text: part.text })
|
||||
else text.push(part.text)
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
const native = part.providerMetadata?.mistral?.thinking
|
||||
if (structured && isMistralThinkingContent(native)) content.push(native)
|
||||
else if (structured) content.push({ type: "text", text: part.text })
|
||||
else text.push(part.text)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
toolCalls.push(lowerToolCall(part, normalizeID))
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Mistral Chat", "assistant", ["text", "reasoning", "tool-call"])
|
||||
}
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
content: structured ? content : text.join(""),
|
||||
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||
...(prefix ? { prefix: true as const } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
normalizeID: (id: string) => string,
|
||||
) {
|
||||
const output: MistralMessage[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type !== "tool-result")
|
||||
return yield* ProviderShared.unsupportedContent("Mistral Chat", "tool", ["tool-result"])
|
||||
if (part.result.type !== "content") {
|
||||
output.push({
|
||||
role: "tool",
|
||||
tool_call_id: normalizeID(part.id),
|
||||
name: part.name,
|
||||
content: ProviderShared.toolResultText(part),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const content: MistralUserContent[] = []
|
||||
for (const item of part.result.value) {
|
||||
if (item.type === "text") {
|
||||
content.push({ type: "text", text: item.text })
|
||||
continue
|
||||
}
|
||||
content.push(yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }))
|
||||
}
|
||||
output.push({
|
||||
role: "tool",
|
||||
tool_call_id: normalizeID(part.id),
|
||||
name: part.name,
|
||||
content: content.some((item) => item.type !== "text")
|
||||
? content
|
||||
: content.map((item) => (item.type === "text" ? item.text : "")).join(""),
|
||||
})
|
||||
}
|
||||
return output
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const normalizeID = toolIDNormalizer(request)
|
||||
const messages: MistralMessage[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: update.text,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (message.role === "user") {
|
||||
messages.push(yield* lowerUser(message))
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
const hasToolCalls = message.content.some((part) => part.type === "tool-call")
|
||||
const hasNativeThinking = message.content.some(
|
||||
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
|
||||
)
|
||||
const text = message.content
|
||||
.flatMap((part) => (part.type === "text" || part.type === "reasoning" ? [part.text] : []))
|
||||
.join("")
|
||||
if (!hasToolCalls && !hasNativeThinking && text.trim() === "") continue
|
||||
messages.push(yield* lowerAssistant(message, normalizeID, !hasToolCalls && message === request.messages.at(-1)))
|
||||
continue
|
||||
}
|
||||
messages.push(...(yield* lowerToolResults(message, normalizeID)))
|
||||
}
|
||||
return messages
|
||||
})
|
||||
|
||||
const lowerTool = (tool: ToolDefinition): MistralTool => ({
|
||||
type: "function",
|
||||
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false },
|
||||
})
|
||||
|
||||
export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MistralOptions))(
|
||||
request.providerOptions ?? {},
|
||||
)
|
||||
const selected = request.toolChoice?.type === "tool" ? request.toolChoice.name : undefined
|
||||
if (request.toolChoice?.type === "tool" && !selected)
|
||||
return yield* ProviderShared.invalidRequest("Mistral Chat tool choice requires a tool name")
|
||||
if (options.reasoningEffort !== undefined && options.promptMode !== undefined)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Mistral Chat reasoningEffort and promptMode provider options are mutually exclusive",
|
||||
)
|
||||
const toolChoice = request.toolChoice
|
||||
? yield* ProviderShared.matchToolChoice("Mistral Chat", request.toolChoice, {
|
||||
auto: () => "auto" as const,
|
||||
none: () => "none" as const,
|
||||
required: () => "any" as const,
|
||||
tool: (name) => ({ type: "function" as const, function: { name } }),
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request),
|
||||
tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: request.generation?.maxTokens,
|
||||
random_seed: request.generation?.seed,
|
||||
temperature: request.generation?.temperature,
|
||||
top_p: request.generation?.topP,
|
||||
frequency_penalty: request.generation?.frequencyPenalty,
|
||||
presence_penalty: request.generation?.presencePenalty,
|
||||
stop: request.generation?.stop,
|
||||
prompt_cache_key: request.cache === "none" ? undefined : (options.promptCacheKey ?? request.promptCacheKey),
|
||||
safe_prompt: options.safePrompt,
|
||||
document_image_limit: options.documentImageLimit,
|
||||
document_page_limit: options.documentPageLimit,
|
||||
parallel_tool_calls:
|
||||
options.parallelToolCalls ?? (request.toolChoice?.disableParallelToolUse === true ? false : undefined),
|
||||
reasoning_effort: options.reasoningEffort,
|
||||
prompt_mode: options.promptMode,
|
||||
}
|
||||
})
|
||||
|
||||
type ToolKey = string | number
|
||||
interface PendingTool {
|
||||
readonly id: string
|
||||
readonly name?: string
|
||||
readonly input: string
|
||||
}
|
||||
|
||||
interface ActiveContent {
|
||||
readonly type: "text" | "reasoning"
|
||||
readonly id: string
|
||||
readonly thinking?: MistralThinkingContent
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
readonly tools: ToolStream.State<ToolKey>
|
||||
readonly pendingTools: Partial<Record<ToolKey, PendingTool>>
|
||||
readonly toolIDs: ReadonlyMap<string, string>
|
||||
readonly usedToolIDs: ReadonlySet<string>
|
||||
readonly completedTools: ReadonlyArray<LLMEvent>
|
||||
readonly latestToolKey?: ToolKey
|
||||
readonly generatedTools: number
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly active?: ActiveContent
|
||||
readonly nextContent: number
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
}
|
||||
|
||||
const mapUsage = (usage: MistralEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const reported =
|
||||
usage.num_cached_tokens ??
|
||||
usage.prompt_tokens_details?.cached_tokens ??
|
||||
usage.prompt_token_details?.cached_tokens ??
|
||||
undefined
|
||||
const cached = input === undefined || reported === undefined ? undefined : Math.max(0, Math.min(input, reported))
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
return new Usage({
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
nonCachedInputTokens: ProviderShared.subtractTokens(input, cached),
|
||||
cacheReadInputTokens: cached,
|
||||
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||
providerMetadata: { mistral: usage },
|
||||
})
|
||||
}
|
||||
|
||||
const mapFinishReason = (reason: string) => {
|
||||
switch (reason) {
|
||||
case "stop":
|
||||
return "stop" as const
|
||||
case "length":
|
||||
case "model_length":
|
||||
return "length" as const
|
||||
case "tool_calls":
|
||||
return "tool-calls" as const
|
||||
case "content_filter":
|
||||
return "content-filter" as const
|
||||
case "error":
|
||||
case "network_error":
|
||||
return "error" as const
|
||||
default:
|
||||
return "unknown" as const
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingUnits = (value: unknown): ReadonlyArray<MistralThinkingUnit> => {
|
||||
if (typeof value === "string") return [{ type: "text", text: value }]
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(Schema.is(MistralThinkingUnit))
|
||||
}
|
||||
|
||||
const thinkingText = (thinking: ReadonlyArray<MistralThinkingUnit>) =>
|
||||
thinking.flatMap((unit) => (typeof unit.text === "string" ? [unit.text] : [])).join("")
|
||||
|
||||
const thinkingMetadata = (thinking: MistralThinkingContent) => ({ mistral: { thinking } })
|
||||
|
||||
const closeActive = (state: ParserState, events: LLMEvent[]) => {
|
||||
if (!state.active) return state
|
||||
const lifecycle =
|
||||
state.active.type === "text"
|
||||
? Lifecycle.textEnd(state.lifecycle, events, state.active.id)
|
||||
: Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
state.active.id,
|
||||
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
|
||||
thinkingText(state.active.thinking?.thinking ?? []),
|
||||
)
|
||||
return { ...state, lifecycle, active: undefined }
|
||||
}
|
||||
|
||||
const appendText = (state: ParserState, events: LLMEvent[], text: string) => {
|
||||
if (text.length === 0) return state
|
||||
const current = state.active?.type === "text" ? state : closeActive(state, events)
|
||||
const active = current.active ?? { type: "text" as const, id: `text-${current.nextContent}` }
|
||||
return {
|
||||
...current,
|
||||
lifecycle: Lifecycle.textDelta(current.lifecycle, events, active.id, text),
|
||||
active,
|
||||
nextContent: current.active ? current.nextContent : current.nextContent + 1,
|
||||
}
|
||||
}
|
||||
|
||||
const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOutputContent) => {
|
||||
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
|
||||
const units = thinkingUnits(part.thinking)
|
||||
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
|
||||
const thinking = {
|
||||
...active.thinking,
|
||||
...part,
|
||||
type: "thinking" as const,
|
||||
thinking: [...(active.thinking?.thinking ?? []), ...units],
|
||||
}
|
||||
const text = thinkingText(units)
|
||||
return {
|
||||
...current,
|
||||
lifecycle:
|
||||
text.length > 0
|
||||
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
|
||||
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
|
||||
active: { ...active, thinking },
|
||||
nextContent: current.active ? current.nextContent : current.nextContent + 1,
|
||||
}
|
||||
}
|
||||
|
||||
const appendContent = (
|
||||
state: ParserState,
|
||||
events: LLMEvent[],
|
||||
content: string | ReadonlyArray<MistralOutputContent>,
|
||||
) => {
|
||||
if (typeof content === "string") return appendText(state, events, content)
|
||||
return content.reduce((current, part) => {
|
||||
if (part.type === "text") return appendText(current, events, part.text ?? "")
|
||||
if (part.type === "thinking") return appendThinking(current, events, part)
|
||||
return closeActive(current, events)
|
||||
}, state)
|
||||
}
|
||||
|
||||
const normalizeStreamToolID = (state: ParserState, source: string) => {
|
||||
if (TOOL_ID.test(source))
|
||||
return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } }
|
||||
const previous = state.toolIDs.get(source)
|
||||
if (previous) return { id: previous, state }
|
||||
let attempt = 0
|
||||
let id = hashID(source)
|
||||
while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`)
|
||||
return {
|
||||
id,
|
||||
state: {
|
||||
...state,
|
||||
toolIDs: new Map([...state.toolIDs, [source, id]]),
|
||||
usedToolIDs: new Set([...state.usedToolIDs, id]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const toolText = (tool: MistralToolDelta) => {
|
||||
const value = tool.function?.arguments
|
||||
if (typeof value === "string") return value
|
||||
return value === null || value === undefined ? "" : ProviderShared.encodeJson(value)
|
||||
}
|
||||
|
||||
const appendTools = Effect.fn("MistralChat.appendTools")(function* (
|
||||
initial: ParserState,
|
||||
events: LLMEvent[],
|
||||
deltas: ReadonlyArray<MistralToolDelta>,
|
||||
) {
|
||||
if (deltas.length === 0) return initial
|
||||
let state = closeActive(initial, events)
|
||||
for (const [position, delta] of deltas.entries()) {
|
||||
const wireID = delta.id?.trim() || undefined
|
||||
const providedID = wireID === "null" ? undefined : wireID
|
||||
const key =
|
||||
delta.index ??
|
||||
(providedID
|
||||
? `id:${providedID}`
|
||||
: deltas.length > 1
|
||||
? `position:${position}`
|
||||
: (state.latestToolKey ?? `missing:${state.generatedTools}`))
|
||||
const existing = state.tools[key]
|
||||
const pending = state.pendingTools[key]
|
||||
const source = providedID ?? `generated:${String(key)}`
|
||||
const normalized =
|
||||
existing || pending ? { id: existing?.id ?? pending?.id ?? "", state } : normalizeStreamToolID(state, source)
|
||||
state = normalized.state
|
||||
const name = existing?.name ?? pending?.name ?? (delta.function?.name?.trim() || undefined)
|
||||
const text = `${pending?.input ?? ""}${toolText(delta)}`
|
||||
if (!name) {
|
||||
state = {
|
||||
...state,
|
||||
pendingTools: { ...state.pendingTools, [key]: { id: normalized.id, input: text } },
|
||||
latestToolKey: key,
|
||||
generatedTools: state.generatedTools + (!providedID && !pending ? 1 : 0),
|
||||
}
|
||||
continue
|
||||
}
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
state.tools,
|
||||
key,
|
||||
{ id: normalized.id, name, text },
|
||||
"Mistral Chat tool call delta is missing a name",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
if (result.events.length > 0) state = { ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }
|
||||
events.push(...result.events)
|
||||
const pendingTools = { ...state.pendingTools }
|
||||
delete pendingTools[key]
|
||||
state = {
|
||||
...state,
|
||||
tools: result.tools,
|
||||
pendingTools,
|
||||
latestToolKey: key,
|
||||
generatedTools: state.generatedTools + (!providedID && !existing && !pending ? 1 : 0),
|
||||
}
|
||||
}
|
||||
return state
|
||||
})
|
||||
|
||||
const hasLateContent = (event: MistralEvent) => {
|
||||
const delta = event.choices?.[0]?.delta
|
||||
if (typeof delta?.content === "string" && delta.content.length > 0) return true
|
||||
if (Array.isArray(delta?.content) && delta.content.length > 0) return true
|
||||
return (delta?.tool_calls ?? []).some(
|
||||
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || tool.function?.arguments !== undefined,
|
||||
)
|
||||
}
|
||||
|
||||
const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event: MistralEvent) {
|
||||
if (event.error) {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return yield* new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
rawBody: body,
|
||||
}),
|
||||
})
|
||||
}
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
if (state.finishReason) {
|
||||
if (hasLateContent(event))
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Mistral Chat received content after the finish reason",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
return [{ ...state, usage }, events] as const
|
||||
}
|
||||
const choice = event.choices?.[0]
|
||||
const withContent = choice?.delta?.content == null ? state : appendContent(state, events, choice.delta.content)
|
||||
const withTools = yield* appendTools(withContent, events, choice?.delta?.tool_calls ?? [])
|
||||
if (!choice?.finish_reason) return [{ ...withTools, usage }, events] as const
|
||||
|
||||
const finishReason = {
|
||||
normalized: mapFinishReason(choice.finish_reason),
|
||||
raw: choice.finish_reason,
|
||||
}
|
||||
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
|
||||
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Mistral Chat tool call delta is missing a name",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
const finished =
|
||||
!incomplete && Object.keys(withTools.tools).length > 0
|
||||
? yield* ToolStream.finishAll(ADAPTER, withTools.tools)
|
||||
: undefined
|
||||
return [
|
||||
{
|
||||
...withTools,
|
||||
tools: finished?.tools ?? withTools.tools,
|
||||
completedTools: finished?.events ?? withTools.completedTools,
|
||||
usage,
|
||||
finishReason,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
})
|
||||
|
||||
const finishEvents = Effect.fn("MistralChat.finishEvents")(function* (state: ParserState) {
|
||||
if (!state.finishReason)
|
||||
return yield* new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "Mistral Chat stream ended without finish_reason",
|
||||
classification: "incomplete-stream",
|
||||
route: ADAPTER,
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const closed = closeActive(state, events)
|
||||
const lifecycle = closed.completedTools.length > 0 ? Lifecycle.stepStart(closed.lifecycle, events) : closed.lifecycle
|
||||
events.push(...closed.completedTools)
|
||||
const reason =
|
||||
state.finishReason.normalized === "stop" && closed.completedTools.some(LLMEvent.is.toolCall)
|
||||
? { ...state.finishReason, normalized: "tool-calls" as const }
|
||||
: state.finishReason
|
||||
Lifecycle.finish(lifecycle, events, { reason, usage: closed.usage })
|
||||
return events
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: { schema: MistralBody, from: fromRequest },
|
||||
stream: {
|
||||
event: MistralStreamEvent,
|
||||
initial: (): ParserState => ({
|
||||
tools: ToolStream.empty<ToolKey>(),
|
||||
pendingTools: {},
|
||||
toolIDs: new Map(),
|
||||
usedToolIDs: new Set(),
|
||||
completedTools: [],
|
||||
generatedTools: 0,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
nextContent: 0,
|
||||
}),
|
||||
step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)),
|
||||
terminal: (event) => event === DONE,
|
||||
onHalt: finishEvents,
|
||||
},
|
||||
})
|
||||
|
||||
export const framing = Framing.sseWithDone
|
||||
export const httpTransport = HttpTransport.sseJson.with<MistralBody>().with({ framing })
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "mistral",
|
||||
providerMetadataKey: "mistral",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||
auth: Auth.none,
|
||||
transport: httpTransport,
|
||||
})
|
||||
|
||||
export * as MistralChat from "./mistral-chat.js"
|
||||
@@ -577,10 +577,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension)),
|
||||
})
|
||||
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
|
||||
if (content.length > 0) input.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import type { MediaPart } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
@@ -57,6 +57,16 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
|
||||
},
|
||||
})
|
||||
|
||||
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.invalidRequest("Bedrock Converse media data must be valid base64", cause),
|
||||
),
|
||||
)
|
||||
return Encoding.encodeBase64(bytes)
|
||||
})
|
||||
|
||||
// Route by MIME. Known image/document formats lower into a typed block; anything
|
||||
// else fails with a clear error instead of silently degrading to a malformed
|
||||
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
|
||||
@@ -66,8 +76,7 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
|
||||
if (imageFormat) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
|
||||
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
|
||||
}
|
||||
if (mime.startsWith("image/"))
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||
@@ -75,8 +84,7 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
if (documentFormat) {
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return documentBlock(part.filename, documentFormat, media.base64)
|
||||
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
|
||||
}
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ export * as GoogleVertexChat from "./google-vertex-chat.js"
|
||||
export * as GoogleVertexMessages from "./google-vertex-messages.js"
|
||||
export * as GoogleVertexResponses from "./google-vertex-responses.js"
|
||||
export * as Groq from "./groq.js"
|
||||
export * as Mistral from "./mistral.js"
|
||||
export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { MistralChat } from "../protocols/mistral-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("mistral")
|
||||
|
||||
export type ProviderOptions = MistralChat.ProviderOptionsInput
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export const route = MistralChat.route
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? MistralChat.DEFAULT_BASE_URL },
|
||||
auth: AuthOptions.bearer(input, "MISTRAL_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<ProviderOptions>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Mistral from "./mistral.js"
|
||||
@@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
|
||||
import type { Protocol } from "./protocol.js"
|
||||
import { applyCachePolicy } from "../cache-policy.js"
|
||||
import { normalizeToolHistory } from "../tool-history.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
import * as ProviderShared from "../protocols/shared.js"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
@@ -169,17 +170,19 @@ export interface GenerateMethod {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
const resolveRequestOptions = (request: LLMRequest) => {
|
||||
const routeDefaults = request.model.route.defaults
|
||||
const modelDefaults = request.model.defaults
|
||||
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
|
||||
return LLMRequest.update(request, {
|
||||
const messages = normalizeToolHistory(request.messages)
|
||||
const normalized = messages === request.messages ? request : LLMRequest.update(request, { messages })
|
||||
const routeDefaults = normalized.model.route.defaults
|
||||
const modelDefaults = normalized.model.defaults
|
||||
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, normalized.generation)
|
||||
return LLMRequest.update(normalized, {
|
||||
generation: generation ?? new GenerationOptions({}),
|
||||
providerOptions: mergeProviderOptions(
|
||||
routeDefaults.providerOptions,
|
||||
modelDefaults?.providerOptions,
|
||||
request.providerOptions,
|
||||
normalized.providerOptions,
|
||||
),
|
||||
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
|
||||
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, normalized.http),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -446,7 +449,9 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const original = applyCachePolicy(resolveRequestOptions(request))
|
||||
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
|
||||
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
|
||||
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
|
||||
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Message, ToolResultPart, type ToolCallPart } from "./schema/messages.js"
|
||||
|
||||
const EMPTY_TOOL_OUTPUT = "(no tool output)"
|
||||
const MISSING_TOOL_RESULT = "Tool result missing"
|
||||
|
||||
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
|
||||
const normalized: Message[] = []
|
||||
const pending = new Map<string, ToolCallPart>()
|
||||
const appendMissingResults = () => {
|
||||
if (pending.size === 0) return
|
||||
normalized.push(missingToolResults(pending.values()))
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "user" || message.role === "assistant") appendMissingResults()
|
||||
|
||||
if (message.role === "tool") {
|
||||
const tool = normalizeToolMessage(message, pending)
|
||||
if (tool) normalized.push(tool)
|
||||
continue
|
||||
}
|
||||
|
||||
normalized.push(message)
|
||||
if (message.role !== "assistant") continue
|
||||
for (const part of message.content) {
|
||||
if (part.type === "tool-call" && part.providerExecuted !== true) pending.set(part.id, part)
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.length === messages.length && normalized.every((message, index) => message === messages[index])
|
||||
? messages
|
||||
: normalized
|
||||
}
|
||||
|
||||
function missingToolResults(calls: Iterable<ToolCallPart>) {
|
||||
return new Message({
|
||||
role: "tool",
|
||||
content: [...calls].map((call) =>
|
||||
ToolResultPart.make({ id: call.id, name: call.name, result: MISSING_TOOL_RESULT, resultType: "error" }),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPart>): Message | undefined {
|
||||
const content = message.content.map((part) => {
|
||||
if (part.type !== "tool-result" || part.providerExecuted === true) return part
|
||||
const call = pending.get(part.id)
|
||||
if (call) pending.delete(part.id)
|
||||
return normalizeToolResult(part, call?.name ?? part.name)
|
||||
})
|
||||
if (content.length === 0) return undefined
|
||||
if (content.every((part, index) => part === message.content[index])) return message
|
||||
return new Message({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content,
|
||||
metadata: message.metadata,
|
||||
native: message.native,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeToolResult(part: ToolResultPart, name: string): ToolResultPart {
|
||||
const named = part.name === name ? part : { ...part, name }
|
||||
if (named.result.type === "text" && named.result.value === "")
|
||||
return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
|
||||
if (named.result.type === "error" && named.result.value === "")
|
||||
return { ...named, result: { type: "error", value: EMPTY_TOOL_OUTPUT } }
|
||||
if (named.result.type !== "content") return named
|
||||
const value = named.result.value.filter((item) => item.type !== "text" || item.text !== "")
|
||||
if (value.length === 0) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
|
||||
if (value.length === named.result.value.length) return named
|
||||
return { ...named, result: { type: "content", value } }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolDefinition, mergeProviderOptions } from "../src/index.js"
|
||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
|
||||
import { Auth, LLMClient } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
@@ -77,6 +77,55 @@ describe("request option precedence", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the last tool definition for duplicate names", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: OpenAIChat.route.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Use a tool.",
|
||||
})
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "lookup", description: "old", inputSchema: { type: "object" } }),
|
||||
ToolDefinition.make({ name: "search", description: "search", inputSchema: { type: "object" } }),
|
||||
ToolDefinition.make({ name: "lookup", description: "new", inputSchema: { type: "object" } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "lookup", description: "new", parameters: { type: "object" }, strict: false },
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "search", description: "search", parameters: { type: "object" }, strict: false },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes tool history before protocol lowering", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route.model({ id: "gpt-4o-mini" }),
|
||||
messages: [
|
||||
Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })),
|
||||
Message.user("Continue."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "assistant", tool_calls: [{ id: "call_1", function: { name: "lookup" } }] },
|
||||
{ role: "tool", tool_call_id: "call_1", content: "Tool result missing" },
|
||||
{ role: "user", content: "Continue." },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies model HTTP defaults before request HTTP overlays", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "zai-glm-5-2",
|
||||
"tags": [
|
||||
"prefix:mistral-chat-glm",
|
||||
"provider:mistral",
|
||||
"protocol:mistral-chat",
|
||||
"hosted-model",
|
||||
"tool",
|
||||
"tool-call"
|
||||
],
|
||||
"name": "mistral-chat-glm/streams-an-indexed-tool-call",
|
||||
"recordedAt": "2026-08-30T17:38:02.921Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.mistral.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"zai-glm-5-2\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":256,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-8cc4d8f9f07b298a\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"Paris\\\"}\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"index\":0,\"content\":\"\"},\"finish_reason\":\"stop\",\"logprobs\":null}],\"usage\":{\"prompt_tokens\":171,\"total_tokens\":182,\"completion_tokens\":11,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "mistral-small-latest",
|
||||
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"],
|
||||
"name": "mistral-chat/drives-a-tool-loop",
|
||||
"recordedAt": "2026-08-30T17:18:49.552Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.mistral.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.mistral.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+47
File diff suppressed because one or more lines are too long
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "mistral-small-latest",
|
||||
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "text", "usage"],
|
||||
"name": "mistral-chat/streams-text-with-usage",
|
||||
"recordedAt": "2026-08-30T17:18:45.432Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.mistral.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"max_tokens\":40,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":22,\"total_tokens\":24,\"completion_tokens\":2,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { Mistral } from "../../src/providers.js"
|
||||
|
||||
const selected = Mistral.provider.model("mistral-small-latest")
|
||||
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "future-effort" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptMode: "reasoning" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { parallelToolCalls: false } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptCacheKey: "session-1" } })
|
||||
|
||||
LLM.request({
|
||||
model: selected,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Mistral reasoning effort must be a string.
|
||||
providerOptions: { reasoningEffort: 1 },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model: selected,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Mistral prompt mode only supports reasoning.
|
||||
providerOptions: { promptMode: "standard" },
|
||||
})
|
||||
@@ -74,6 +74,48 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty system text while preserving whitespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const empty = yield* compileRequest(LLMRequest.update(request, { system: [{ type: "text", text: "" }] }))
|
||||
const whitespace = yield* compileRequest(LLMRequest.update(request, { system: [{ type: "text", text: " " }] }))
|
||||
|
||||
expect(empty.body.system).toBeUndefined()
|
||||
expect(whitespace.body.system).toEqual([{ type: "text", text: " " }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters whitespace-only text and removes empty messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user(" \n\t"),
|
||||
Message.user([]),
|
||||
Message.user([
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: " Keep this spacing. " },
|
||||
{ type: "text", text: " \n\t" },
|
||||
]),
|
||||
Message.assistant(" \n\t"),
|
||||
Message.assistant([]),
|
||||
Message.assistant([{ type: "reasoning", text: "" }]),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "" },
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
]),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: " Keep this spacing. " }] },
|
||||
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers adaptive thinking settings with effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -903,6 +945,54 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a reasoning signature when message_stop closes the block", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = Route.make({
|
||||
id: "custom-anthropic-messages",
|
||||
provider: "custom-anthropic",
|
||||
protocol: AnthropicMessages.protocol,
|
||||
endpoint: Endpoint.path("/messages", { baseURL: "https://compatible.test/v1" }),
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
framing: AnthropicMessages.framing,
|
||||
}).model({ id: "custom-model" })
|
||||
const body = sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "thinking", thinking: "", signature: "" },
|
||||
},
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Reasoning." } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Think." })).pipe(
|
||||
Effect.provide(fixedResponse(body)),
|
||||
)
|
||||
|
||||
const reasoningEnds = response.events.filter((event) => event.type === "reasoning-end")
|
||||
expect(reasoningEnds).toHaveLength(1)
|
||||
expect(reasoningEnds[0]).toMatchObject({
|
||||
providerMetadata: { "custom-anthropic": { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Reasoning.",
|
||||
providerMetadata: { "custom-anthropic": { signature: "sig_1" } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "thinking", thinking: "Reasoning.", signature: "sig_1" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -936,6 +1026,7 @@ describe("Anthropic Messages route", () => {
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Hello!" },
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Encoding, Ref, Stream } from "effect"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ToolCallPart,
|
||||
@@ -83,6 +84,17 @@ const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>)
|
||||
const fixedBytes = (bytes: Uint8Array) =>
|
||||
fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
|
||||
|
||||
const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
|
||||
fixedResponse(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => controller.enqueue(chunk))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
|
||||
)
|
||||
|
||||
const model = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
apiKey: "test-bearer",
|
||||
@@ -113,6 +125,50 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty initial system blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const empty = yield* compileRequest(LLM.request({ model, system: "", prompt: "hello" }))
|
||||
const cachedEmpty = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [{ type: "text", text: "", cache: new CacheHint({ type: "ephemeral" }) }],
|
||||
prompt: "hello",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(empty.body.system).toBeUndefined()
|
||||
expect(cachedEmpty.body.system).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty system blocks while preserving order and cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
{ type: "text", text: "", cache },
|
||||
{ type: "text", text: "First." },
|
||||
{ type: "text", text: " " },
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "Second.", cache },
|
||||
],
|
||||
prompt: "hello",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.system).toEqual([
|
||||
{ text: "First." },
|
||||
{ text: " " },
|
||||
{ text: "Second." },
|
||||
{ cachePoint: { type: "default" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -255,6 +311,79 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
path: "file.ts",
|
||||
edits: [
|
||||
{ oldText: "a", newText: "b", "": "" },
|
||||
null,
|
||||
true,
|
||||
7,
|
||||
"text",
|
||||
["kept", { "": false, nested: { "": null, value: "ok" } }],
|
||||
],
|
||||
nested: { "": "drop", empty: {}, onlyEmpty: { "": 1 } },
|
||||
" ": "preserve whitespace key",
|
||||
"": "drop",
|
||||
}
|
||||
const original = structuredClone(input)
|
||||
const call = ToolCallPart.make({ id: "tool_1", name: "edit", input })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [Message.assistant([call])], cache: "none" }),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
toolUse: {
|
||||
toolUseId: "tool_1",
|
||||
name: "edit",
|
||||
input: {
|
||||
path: "file.ts",
|
||||
edits: [{ oldText: "a", newText: "b" }, null, true, 7, "text", ["kept", { nested: { value: "ok" } }]],
|
||||
nested: { empty: {}, onlyEmpty: {} },
|
||||
" ": "preserve whitespace key",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(input).toEqual(original)
|
||||
expect(call.input).toBe(input)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps empty tool inputs and empties inputs containing only empty keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_empty_key", name: "first", input: { "": { value: true } } }),
|
||||
ToolCallPart.make({ id: "tool_empty_object", name: "second", input: {} }),
|
||||
]),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ toolUse: { toolUseId: "tool_empty_key", name: "first", input: {} } },
|
||||
{ toolUse: { toolUseId: "tool_empty_object", name: "second", input: {} } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -385,19 +514,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 +1040,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 +1050,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 +1067,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 +1108,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([
|
||||
@@ -1172,6 +1429,20 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects image media that is not valid base64", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "image/png", data: "https://example.test/image.png" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1295,6 +1566,37 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects remote media URLs in tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{
|
||||
type: "file",
|
||||
uri: "https://example.test/report.pdf",
|
||||
mime: "application/pdf",
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported image media types", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
@@ -96,6 +96,27 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits user messages with no content", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.user([]), Message.user("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, ToolCallPart, ToolResultPart } from "../src/schema/messages.js"
|
||||
import { normalizeToolHistory } from "../src/tool-history.js"
|
||||
|
||||
const toolCall = (id: string, name = id) => ToolCallPart.make({ id, name, input: {} })
|
||||
const toolResult = (id: string, value: unknown, name = id, resultType?: "text" | "content" | "error") =>
|
||||
Message.tool(ToolResultPart.make({ id, name, result: value, resultType }))
|
||||
|
||||
describe("tool history normalization", () => {
|
||||
test("fills missing local results before the next step", () => {
|
||||
const normalized = normalizeToolHistory([
|
||||
Message.assistant([toolCall("first"), toolCall("second")]),
|
||||
toolResult("first", "done", "wrong", "text"),
|
||||
Message.user("Continue."),
|
||||
Message.assistant(toolCall("trailing")),
|
||||
])
|
||||
|
||||
expect(normalized.map((message) => message.role)).toEqual([
|
||||
"assistant",
|
||||
"tool",
|
||||
"tool",
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" })
|
||||
expect(normalized[2]?.content).toEqual([
|
||||
{ type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } },
|
||||
])
|
||||
expect(normalized[4]?.content).toEqual([toolCall("trailing")])
|
||||
})
|
||||
|
||||
test("normalizes empty results without changing whitespace or media", () => {
|
||||
const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" }
|
||||
const normalized = normalizeToolHistory([
|
||||
Message.assistant([
|
||||
toolCall("text"),
|
||||
toolCall("content"),
|
||||
toolCall("error"),
|
||||
toolCall("mixed"),
|
||||
toolCall("whitespace"),
|
||||
]),
|
||||
toolResult("text", "", "text", "text"),
|
||||
toolResult("content", [], "content", "content"),
|
||||
toolResult("error", "", "error", "error"),
|
||||
toolResult("mixed", [{ type: "text", text: "" }, media], "mixed", "content"),
|
||||
toolResult("whitespace", " ", "whitespace", "text"),
|
||||
])
|
||||
|
||||
expect(normalized.slice(1).map((message) => message.content[0])).toEqual([
|
||||
{ type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } },
|
||||
{ type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } },
|
||||
{ type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } },
|
||||
{ type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } },
|
||||
{ type: "tool-result", id: "whitespace", name: "whitespace", result: { type: "text", value: " " } },
|
||||
])
|
||||
})
|
||||
|
||||
test("leaves unmatched and provider-executed history unchanged", () => {
|
||||
const hostedCall = ToolCallPart.make({
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: {},
|
||||
providerExecuted: true,
|
||||
})
|
||||
const hostedResult = ToolResultPart.make({
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
result: "",
|
||||
resultType: "text",
|
||||
providerExecuted: true,
|
||||
})
|
||||
const hosted = Message.assistant([hostedCall, hostedResult])
|
||||
const orphan = toolResult("orphan", "ignored", "orphan", "text")
|
||||
|
||||
expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,34 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
|
||||
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
|
||||
const component = await mount(`opencode-composer-flow--${draft}`)
|
||||
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const text = await input.textContent()
|
||||
|
||||
for (let count = 0; count < 2; count++) {
|
||||
await input.press("ControlOrMeta+a")
|
||||
expect(
|
||||
await input.evaluate((editor) => {
|
||||
const selection = window.getSelection()
|
||||
return {
|
||||
text: selection?.toString(),
|
||||
inside: editor.contains(selection?.anchorNode ?? null) && editor.contains(selection?.focusNode ?? null),
|
||||
}
|
||||
}),
|
||||
).toEqual({ text, inside: true })
|
||||
}
|
||||
|
||||
await page.keyboard.type("Replacement draft")
|
||||
await expect(input).toHaveText("Replacement draft")
|
||||
await expect(component.getByRole("status")).toHaveText("Ready")
|
||||
if (draft === "mixed-attachments") {
|
||||
await expect(component.getByAltText("layout.png")).toBeVisible()
|
||||
await expect(component.getByText("Keep the normal flow flat", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const replace = Element.prototype.replaceChildren
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
@@ -65,11 +65,64 @@ for (const lines of [6000, 25000]) {
|
||||
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
|
||||
expect(await events.evaluate((events) => events.count)).toBe(1)
|
||||
await expect(input).toBeFocused()
|
||||
await expectCaretVisible(input)
|
||||
const scroll = page.locator('[data-component="composer-scroll"]')
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveCSS("scrollbar-width", "none")
|
||||
await expect(scroll.locator(".scroll-view__thumb")).toBeVisible()
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
|
||||
await expectCaretVisible(input)
|
||||
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
|
||||
const bounds = await scroll.boundingBox()
|
||||
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, bounds.y + 8 + thumb.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Home")
|
||||
await page.keyboard.press("ControlOrMeta+End")
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectCaretVisible(input: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
input.evaluate((element) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
|
||||
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`reveals a multiline paste in the middle at ${width}px in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 })
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const suffix = "\nExisting trailing content".repeat(100)
|
||||
await input.fill("Before " + suffix)
|
||||
await input.press("ControlOrMeta+Home")
|
||||
await input.press("ArrowRight")
|
||||
const text = "Pasted line /tmp/example.ts 123 \u0645\u0631\u062d\u0628\u0627\n".repeat(100) + "End of paste"
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "!efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const text of [
|
||||
"single line <b> &",
|
||||
"first\nsecond",
|
||||
|
||||
@@ -65,8 +65,8 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
|
||||
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
|
||||
id,
|
||||
source: { type: "package", package: id },
|
||||
status: "active",
|
||||
tui: false,
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("keeps history visible and recovers the composer by choosing another directory", async ({ page }) => {
|
||||
const directory = "/projects/deleted"
|
||||
const destination = "/projects/restored"
|
||||
const sessionID = "ses_missing_location"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
|
||||
const events: OpenCodeEvent[] = []
|
||||
const moves: unknown[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: destination,
|
||||
project: { ...fixture.project, worktree: destination },
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
events: () => events.splice(0),
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route("**/api/**", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
|
||||
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("status")).toContainText("Session location unavailable")
|
||||
await expect(page.getByRole("status")).toContainText(directory)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
const choose = page.getByRole("button", { name: "Choose directory", exact: true })
|
||||
await expect(choose).toBeEnabled()
|
||||
await choose.click()
|
||||
const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true })
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(choose).toBeFocused()
|
||||
expect(moves).toEqual([])
|
||||
|
||||
// The server can still reject a destination after it was selected in the picker.
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { _tag: "InvalidRequestError", message: "Destination is unavailable" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await choose.press("Enter")
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible()
|
||||
await expect(choose).toBeEnabled()
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
await expect(choose).toBeFocused()
|
||||
expect(moves).toEqual([{ directory: destination }])
|
||||
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
session.directory = destination
|
||||
events.push({
|
||||
id: "evt_location_recovered",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await choose.click()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toBeVisible()
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
|
||||
expect(moves).toEqual([{ directory: destination }, { directory: destination }])
|
||||
})
|
||||
|
||||
for (const create of [false, true]) {
|
||||
test(`recovers into ${create ? "a new" : "an existing"} worktree without resolving the deleted location`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const canonical = "/projects/repository"
|
||||
const directory = "/projects/deleted-worktree"
|
||||
const destination = create ? "/projects/new-worktree" : "/projects/existing-worktree"
|
||||
const sessionID = "ses_worktree_recovery"
|
||||
const events: OpenCodeEvent[] = []
|
||||
const requests: { operation: string; body: unknown }[] = []
|
||||
let listing = Promise.withResolvers<void>()
|
||||
let listingRequested = Promise.withResolvers<void>()
|
||||
const moving = Promise.withResolvers<void>()
|
||||
const moveRequested = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: canonical,
|
||||
project: { ...fixture.project, worktree: canonical },
|
||||
provider: fixture.provider,
|
||||
sessions: [{ id: sessionID, projectID: fixture.project.id, directory }],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_worktree", type: "user", text: "Recover my worktree", time: { created: 1 } }],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
if (url.searchParams.get("location[directory]") === directory)
|
||||
return route.fulfill({ status: 500, body: "", headers })
|
||||
if (url.pathname === `/api/worktree/${fixture.project.id}`) {
|
||||
if (route.request().method() === "GET") {
|
||||
listingRequested.resolve()
|
||||
await listing.promise
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ directory: canonical, strategy: null },
|
||||
{ directory: "/projects/existing-worktree", strategy: "git" },
|
||||
],
|
||||
headers,
|
||||
})
|
||||
}
|
||||
if (route.request().method() === "POST") {
|
||||
requests.push({ operation: "create", body: route.request().postDataJSON() })
|
||||
return route.fulfill({
|
||||
json: { directory: destination, name: "new-worktree", branch: "new-worktree" },
|
||||
headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (url.pathname === `/api/session/${sessionID}/move` && route.request().method() === "POST") {
|
||||
requests.push({ operation: "move", body: route.request().postDataJSON() })
|
||||
moveRequested.resolve()
|
||||
await moving.promise
|
||||
events.push({
|
||||
id: "evt_worktree_recovered",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
return route.fulfill({ status: 204, headers })
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await listingRequested.promise
|
||||
await expect(page.getByRole("menuitem", { name: "Loading", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menu")).toHaveCount(0)
|
||||
listing = Promise.withResolvers<void>()
|
||||
listingRequested = Promise.withResolvers<void>()
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await listingRequested.promise
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
await page.getByRole("menuitem", { name: create ? "New workspace" : "existing-worktree", exact: true }).click()
|
||||
await moveRequested.promise
|
||||
await expect(page.getByRole("button", { name: "Moving session…", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Choose worktree", exact: true })).toBeDisabled()
|
||||
moving.resolve()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
expect(requests).toEqual([
|
||||
...(create ? [{ operation: "create", body: { strategy: "git", from: canonical, directory: "/projects/" } }] : []),
|
||||
{ operation: "move", body: { directory: destination } },
|
||||
])
|
||||
})
|
||||
}
|
||||
@@ -83,7 +83,12 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
|
||||
json: {
|
||||
location: { directory },
|
||||
data: [
|
||||
{ id: "demo-plugin", source: { type: "package", package: "demo-plugin" }, status: "active", tui: false },
|
||||
{
|
||||
id: "demo-plugin",
|
||||
source: { type: "package", package: "demo-plugin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { AttachmentCard } from "@opencode-ai/session-ui/attachment-card"
|
||||
import { CommentCard } from "@opencode-ai/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode-ai/session-ui/message-file"
|
||||
@@ -53,6 +54,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
const state = props.controller.state
|
||||
const view = props.controller.view
|
||||
let editor: HTMLDivElement | undefined
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let localInput = false
|
||||
const updateCursor = () => {
|
||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||
@@ -145,7 +147,14 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class="relative min-h-[60px]">
|
||||
<ScrollView
|
||||
data-component="composer-scroll"
|
||||
class="min-h-[60px] max-h-[180px]"
|
||||
viewportRef={(element) => {
|
||||
viewport = element
|
||||
element.tabIndex = -1
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(element) => {
|
||||
editor = element
|
||||
@@ -162,7 +171,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
spellcheck={state.mode === "normal"}
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
class="relative z-10 block min-h-[60px] w-full whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
|
||||
style={{
|
||||
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,
|
||||
@@ -190,7 +199,20 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
}}
|
||||
onKeyUp={updateCursor}
|
||||
onPointerUp={updateCursor}
|
||||
onPaste={props.controller.onPaste}
|
||||
onPaste={(event) => {
|
||||
props.controller.onPaste(event)
|
||||
// Programmatic multiline insertion does not reliably reveal the caret.
|
||||
requestAnimationFrame(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return
|
||||
if (!editor.contains(selection.anchorNode)) return
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
if (!caret.height) return
|
||||
const bounds = viewport.getBoundingClientRect()
|
||||
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
|
||||
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
|
||||
})
|
||||
}}
|
||||
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
|
||||
/>
|
||||
<Show when={!props.controller.value()}>
|
||||
@@ -206,7 +228,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
: i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div
|
||||
|
||||
@@ -5,10 +5,20 @@ import { pluginLabels } from "./plugin"
|
||||
describe("pluginLabels", () => {
|
||||
test("omits built-in plugins", () => {
|
||||
const plugins: PluginInfo[] = [
|
||||
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
|
||||
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
|
||||
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
|
||||
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id: "package-plugin",
|
||||
source: { type: "package", package: "example" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
{
|
||||
id: "local-plugin",
|
||||
source: { type: "local", path: "/tmp/plugin.ts" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
{ id: "sdk-plugin", source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
|
||||
]
|
||||
|
||||
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -298,7 +298,7 @@ export function Titlebar(props: {
|
||||
id: "home.toggle",
|
||||
title: language.t("home.title"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+b",
|
||||
keybind: windows() ? "alt+home" : "mod+b",
|
||||
hidden: true,
|
||||
onSelect: toggleHome,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DESKTOP_MENU } from "@/shell/commands/desktop-menu"
|
||||
import { windowsMenuAccelerator } from "./windows-menu"
|
||||
|
||||
describe("Windows app menu", () => {
|
||||
@@ -8,7 +9,24 @@ describe("Windows app menu", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("leaves select all to the focused browser editor", () => {
|
||||
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "a", ctrlKey: true }))).toBeUndefined()
|
||||
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "A", ctrlKey: true }))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores the accelerator without its modifiers", () => {
|
||||
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N" }))).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each(["v", "c", "x", "a", "z", "y"])("leaves Ctrl+%s to the focused editor", (key) => {
|
||||
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key, ctrlKey: true }))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves the paste menu action and shortcut label", () => {
|
||||
expect(
|
||||
DESKTOP_MENU.flatMap((menu) => menu.items ?? []).find(
|
||||
(entry) => entry.type === "item" && entry.action === "edit.paste",
|
||||
),
|
||||
).toMatchObject({ action: "edit.paste", accelerator: { windows: "Ctrl+V" } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,8 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
const accelerators = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).flatMap((entry) => {
|
||||
if (entry.type === "separator" || !entry.action || !entry.accelerator?.windows) return []
|
||||
// Let the focused editor handle editing shortcuts without restoring stale menu focus.
|
||||
if (entry.action.startsWith("edit.")) return []
|
||||
return [{ action: entry.action, keybind: parseKeybind(entry.accelerator.windows) }]
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
@@ -7,7 +8,7 @@ import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
|
||||
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
@@ -19,7 +20,7 @@ export default Runtime.handler(
|
||||
const global = yield* Global.Service
|
||||
const info = yield* config.get()
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
)
|
||||
const output = format(
|
||||
response.data,
|
||||
@@ -48,11 +49,15 @@ export function format(
|
||||
const server = plugins
|
||||
.filter((plugin) => builtin || plugin.source.type !== "builtin")
|
||||
.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
.map((plugin) => `${name(plugin)} (${plugin.status})`)
|
||||
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
|
||||
const advertised = plugins.flatMap((plugin) =>
|
||||
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
|
||||
? [{ target: plugin.source.package, source: "advertised" as const }]
|
||||
: [],
|
||||
plugin.state.status !== "active" || !plugin.features.tui
|
||||
? []
|
||||
: plugin.source.type === "package"
|
||||
? [{ target: plugin.source.package, source: "advertised" as const }]
|
||||
: plugin.source.type === "local"
|
||||
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
|
||||
: [],
|
||||
)
|
||||
const targets = [...tui, ...advertised]
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
|
||||
@@ -6,18 +6,23 @@ test("formats server and TUI plugins in sections without builtins", () => {
|
||||
expect(
|
||||
format(
|
||||
[
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id: "acme.dual",
|
||||
source: { type: "package", package: "acme-plugin@1.0.0" },
|
||||
status: "active",
|
||||
tui: true,
|
||||
state: { status: "active" },
|
||||
features: { server: true, tui: true },
|
||||
},
|
||||
{
|
||||
source: { type: "package", package: "broken-plugin" },
|
||||
status: "failed",
|
||||
error: "broken",
|
||||
tui: false,
|
||||
state: { status: "failed", error: "broken" },
|
||||
features: { server: true },
|
||||
},
|
||||
{
|
||||
id: "local.dual",
|
||||
source: { type: "local", path: "/tmp/local/index.ts" },
|
||||
state: { status: "active" },
|
||||
features: { server: true, tui: true },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -28,6 +33,7 @@ test("formats server and TUI plugins in sections without builtins", () => {
|
||||
).toBe(
|
||||
[
|
||||
"TUI",
|
||||
"/tmp/local (advertised)",
|
||||
"/tmp/local.ts (discovered)",
|
||||
"acme-plugin@1.0.0 (advertised)",
|
||||
"tui-only (configured)",
|
||||
@@ -35,12 +41,24 @@ test("formats server and TUI plugins in sections without builtins", () => {
|
||||
"Server",
|
||||
"acme.dual (active)",
|
||||
"broken-plugin (failed)",
|
||||
"local.dual (active)",
|
||||
].join(EOL),
|
||||
)
|
||||
})
|
||||
|
||||
test("includes builtins when requested", () => {
|
||||
expect(
|
||||
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
|
||||
format(
|
||||
[
|
||||
{
|
||||
id: "opencode.agent",
|
||||
source: { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
],
|
||||
[],
|
||||
true,
|
||||
),
|
||||
).toBe(["Server", "opencode.agent (active)"].join(EOL))
|
||||
})
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
"solid-js": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"
|
||||
|
||||
export type { RpcApi, RpcClient } from "./rpc.js"
|
||||
|
||||
export type * from "./api/api.js"
|
||||
|
||||
export type WebSearchApi<E = never> = WebsearchApi<E>
|
||||
|
||||
@@ -1573,6 +1573,19 @@ export interface SkillApi<E = never> {
|
||||
readonly list: SkillListOperation<E>
|
||||
}
|
||||
|
||||
export type RpcCallInput = {
|
||||
readonly rpcID: string
|
||||
readonly method: string
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly input?: unknown | undefined
|
||||
}
|
||||
export type RpcCallOutput = { readonly output?: unknown }
|
||||
export type RpcCallOperation<E = never> = (input: RpcCallInput) => Effect.Effect<RpcCallOutput, E>
|
||||
|
||||
export interface RpcApi<E = never> {
|
||||
readonly call: RpcCallOperation<E>
|
||||
}
|
||||
|
||||
export type EventSubscribeOutput = OpenCodeEvent
|
||||
export type EventSubscribeOperation<E = never> = () => Stream.Stream<EventSubscribeOutput, E>
|
||||
|
||||
@@ -2073,6 +2086,7 @@ export interface AppApi<E = never> {
|
||||
readonly file: FileApi<E>
|
||||
readonly command: CommandApi<E>
|
||||
readonly skill: SkillApi<E>
|
||||
readonly rpc: RpcApi<E>
|
||||
readonly event: EventApi<E>
|
||||
readonly pty: PtyApi<E>
|
||||
readonly experimental: ExperimentalApi<E>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export * as OpenCode from "./client.js"
|
||||
|
||||
import { Cause, Context, Effect, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { SharedEvents } from "../shared-events.js"
|
||||
import { ClientError, OpenCode } from "./generated/index.js"
|
||||
import { RpcClientRuntime } from "./rpc.js"
|
||||
import type { RpcCallOptions } from "../promise/rpc.js"
|
||||
|
||||
const CurrentHeaders = Context.Reference<RpcCallOptions["headers"]>("@opencode-ai/client/effect/rpc/headers", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly baseUrl?: URL | string }) {
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
const raw = yield* OpenCode.make(options).pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.mapRequestEffect(httpClient, (request) =>
|
||||
Effect.map(CurrentHeaders, (headers) =>
|
||||
headers ? HttpClientRequest.setHeaders(request, new Headers(headers)) : request,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const context = yield* Effect.context()
|
||||
const native = raw.event.subscribe()
|
||||
// Async iterators throw a squashed cause; retain the native typed failures and defects intact.
|
||||
class EventFailure {
|
||||
constructor(readonly cause: Cause.Cause<Stream.Error<typeof native>>) {}
|
||||
}
|
||||
const shared = SharedEvents.make((signal) =>
|
||||
Stream.toAsyncIterableWith(
|
||||
native.pipe(
|
||||
Stream.interruptWhen(RpcClientRuntime.aborted(signal)),
|
||||
Stream.catchCause((cause) => Stream.fail(new EventFailure(cause))),
|
||||
),
|
||||
context,
|
||||
),
|
||||
)
|
||||
const subscribe = () =>
|
||||
Stream.fromAsyncIterable(shared.subscribe(), (error) => error).pipe(
|
||||
Stream.catch((error) =>
|
||||
Stream.failCause(error instanceof EventFailure ? error.cause : Cause.fail(new ClientError({ cause: error }))),
|
||||
),
|
||||
)
|
||||
return {
|
||||
...raw,
|
||||
event: { ...raw.event, subscribe },
|
||||
rpc: Object.assign(
|
||||
RpcClientRuntime.make(
|
||||
(input, options) => raw.rpc.call(input).pipe(Effect.provideService(CurrentHeaders, options?.headers)),
|
||||
subscribe,
|
||||
),
|
||||
raw.rpc,
|
||||
),
|
||||
}
|
||||
})
|
||||
@@ -185,6 +185,8 @@ import type {
|
||||
CommandListOutput,
|
||||
SkillListInput,
|
||||
SkillListOutput,
|
||||
RpcCallInput,
|
||||
RpcCallOutput,
|
||||
EventSubscribeOutput,
|
||||
PtyListInput,
|
||||
PtyListOutput,
|
||||
@@ -1166,6 +1168,17 @@ const EndpointSkillList = (raw: RawClient["server.skill"]) => (input?: SkillList
|
||||
|
||||
const adaptGroupSkill = (raw: RawClient["server.skill"]) => ({ list: EndpointSkillList(raw) })
|
||||
|
||||
const EndpointRpcCall = (raw: RawClient["server.rpc"]) => (input: RpcCallInput) =>
|
||||
preserveEffect<RpcCallOutput>()(
|
||||
raw["rpc.call"]({
|
||||
params: { rpcID: input["rpcID"], method: input["method"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { input: input["input"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupRpc = (raw: RawClient["server.rpc"]) => ({ call: EndpointRpcCall(raw) })
|
||||
|
||||
const EndpointEventSubscribe = (raw: RawClient["server.event"]) => () =>
|
||||
preserveStream<EventSubscribeOutput>()(
|
||||
Stream.unwrap(
|
||||
@@ -1564,6 +1577,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
file: adaptGroupFile(raw["server.fs"]),
|
||||
command: adaptGroupCommand(raw["server.command"]),
|
||||
skill: adaptGroupSkill(raw["server.skill"]),
|
||||
rpc: adaptGroupRpc(raw["server.rpc"]),
|
||||
event: adaptGroupEvent(raw["server.event"]),
|
||||
pty: adaptGroupPty(raw["server.pty"]),
|
||||
experimental: adaptGroupExperimental(raw["server.experimental"]),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
|
||||
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
||||
import type { Effect } from "effect"
|
||||
import type { OpenCode } from "./client.js"
|
||||
|
||||
export * from "./generated/index"
|
||||
export { OpenCode } from "./client.js"
|
||||
export type {
|
||||
AgentApi,
|
||||
AppApi,
|
||||
@@ -15,6 +17,8 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
RpcApi,
|
||||
RpcClient,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
@@ -48,4 +52,4 @@ export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
|
||||
export type OpenCodeClient = Effect.Success<ReturnType<typeof OpenCode.make>>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export * as RpcClientRuntime from "./rpc.js"
|
||||
|
||||
import type { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import type { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import type { RpcArguments, RpcCallOptions } from "../promise/rpc.js"
|
||||
import { RpcRuntime } from "../rpc-runtime.js"
|
||||
import type { RpcCallInput, RpcCallOutput } from "./api/api.js"
|
||||
|
||||
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
|
||||
type DecodeError<S> = S extends Schema.Top ? Schema.SchemaError : never
|
||||
|
||||
export type RpcClient<
|
||||
D extends Rpc.Definition,
|
||||
E = never,
|
||||
Options = RpcCallOptions,
|
||||
EventError = E,
|
||||
> = {
|
||||
readonly [Name in keyof D["methods"]]: (
|
||||
...args: RpcArguments<Rpc.Input<D["methods"][Name]["input"]>, Options>
|
||||
) => Effect.Effect<
|
||||
Rpc.Output<D["methods"][Name]["output"]>,
|
||||
Rpc.MethodError<D["methods"][Name]> | DecodeError<D["methods"][Name]["output"]> | E
|
||||
>
|
||||
} & {
|
||||
readonly events: {
|
||||
readonly subscribe: <Name extends keyof D["events"] & string>(
|
||||
name: Name,
|
||||
) => Stream.Stream<Rpc.EventPayload<D, Name>, DecodeError<D["events"][Name]["schema"]> | EventError>
|
||||
}
|
||||
}
|
||||
|
||||
export interface RpcApi<E = never, Options = RpcCallOptions, EventError = E> {
|
||||
<D extends Rpc.Definition>(definition: D): RpcClient<D, E, Options, EventError>
|
||||
}
|
||||
|
||||
export function make<CallError, EventError>(
|
||||
call: (input: RpcCallInput, options?: RpcCallOptions) => Effect.Effect<RpcCallOutput, CallError>,
|
||||
subscribe: () => Stream.Stream<OpenCodeEvent, EventError>,
|
||||
): RpcApi<Exclude<CallError, RpcError | RpcInternalError> | Rpc.SystemError, RpcCallOptions, EventError> {
|
||||
return <D extends Rpc.Definition>(definition: D) => {
|
||||
const methods = Object.fromEntries(
|
||||
Object.entries(definition.methods).map(([name, method]) => [
|
||||
name,
|
||||
(input?: unknown, options?: RpcCallOptions) => {
|
||||
const result = Effect.gen(function* () {
|
||||
const response = yield* call(
|
||||
{
|
||||
rpcID: definition.id,
|
||||
method: name,
|
||||
input,
|
||||
location: options?.location,
|
||||
},
|
||||
options,
|
||||
)
|
||||
return yield* RpcRuntime.read(method.output, response.output)
|
||||
}).pipe(Effect.catch((error) => RpcRuntime.readError(method, error)))
|
||||
const signal = options?.signal
|
||||
if (!signal) return result
|
||||
return Effect.suspend(() =>
|
||||
signal.aborted
|
||||
? Effect.interrupt
|
||||
: Effect.raceFirst(result, Effect.andThen(aborted(signal), Effect.interrupt)),
|
||||
)
|
||||
},
|
||||
]),
|
||||
)
|
||||
// SAFETY: Every runtime key comes from this definition, and each value is decoded through its corresponding schema.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
return Object.assign(methods, {
|
||||
events: {
|
||||
subscribe: (name: keyof D["events"] & string) => {
|
||||
const type = RpcRuntime.eventType(definition, name)
|
||||
if (!Object.hasOwn(definition.events, name)) return Stream.fail(new Error(`Unknown RPC event: ${type}`))
|
||||
const schema = definition.events[name]
|
||||
return subscribe().pipe(
|
||||
Stream.filter((event): event is RpcEvent => event.type === type),
|
||||
Stream.mapEffect((event) => RpcRuntime.event(definition, name, schema, event)),
|
||||
)
|
||||
},
|
||||
},
|
||||
}) as RpcClient<D, Exclude<CallError, RpcError | RpcInternalError> | Rpc.SystemError, RpcCallOptions, EventError>
|
||||
}
|
||||
}
|
||||
|
||||
export function aborted(signal: AbortSignal) {
|
||||
return Effect.callback<void>((resume) => {
|
||||
if (signal.aborted) return resume(Effect.void)
|
||||
const abort = () => resume(Effect.void)
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", abort))
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
type Client = ReturnType<typeof import("./generated/client.js").make>
|
||||
import type { OpenCode } from "./client.js"
|
||||
|
||||
type Client = ReturnType<typeof OpenCode.make>
|
||||
|
||||
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
|
||||
|
||||
export type AgentApi = Client["agent"]
|
||||
export type CommandApi = Client["command"]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export * as OpenCode from "./client.js"
|
||||
|
||||
import { SharedEvents } from "../shared-events.js"
|
||||
import { OpenCode } from "./generated/index.js"
|
||||
import type { ClientOptions } from "./generated/client.js"
|
||||
import { makeRpc } from "./rpc.js"
|
||||
|
||||
export type { ClientOptions, RequestOptions } from "./generated/client.js"
|
||||
|
||||
export function make(options: ClientOptions) {
|
||||
const raw = OpenCode.make(options)
|
||||
const events = SharedEvents.make((signal) => raw.event.subscribe({ signal }))
|
||||
return {
|
||||
...raw,
|
||||
rpc: Object.assign(makeRpc(raw, events), raw.rpc),
|
||||
event: events,
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,8 @@ import type {
|
||||
CommandListOutput,
|
||||
SkillListInput,
|
||||
SkillListOutput,
|
||||
RpcCallInput,
|
||||
RpcCallOutput,
|
||||
EventSubscribeOutput,
|
||||
PtyListInput,
|
||||
PtyListOutput,
|
||||
@@ -1594,6 +1596,21 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
rpc: {
|
||||
call: (input: RpcCallInput, requestOptions?: RequestOptions) =>
|
||||
request<RpcCallOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/rpc/${encodeURIComponent(input.rpcID)}/${encodeURIComponent(input.method)}`,
|
||||
query: { location: input["location"] },
|
||||
body: { input: input["input"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 500, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
event: {
|
||||
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventSubscribeOutput> =>
|
||||
sse<EventSubscribeOutput>(
|
||||
|
||||
@@ -16,6 +16,10 @@ export type PluginSource =
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
|
||||
|
||||
export type PluginState = { status: "active" } | { status: "failed"; error: string }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
export type MoneyUSD = number
|
||||
@@ -333,6 +337,8 @@ export type SkillInfo = {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type RpcOutput = { output?: any }
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject"
|
||||
|
||||
export type Pty = {
|
||||
@@ -442,9 +448,7 @@ export type ProviderRequest = {
|
||||
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type PluginInfo =
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
export type PluginInfo = { id?: string; source: PluginSource; features: PluginFeatures; state: PluginState }
|
||||
|
||||
export type SessionMessageLocationSwitched = {
|
||||
id: string
|
||||
@@ -459,6 +463,15 @@ export type SessionMessageLocationSwitched = {
|
||||
|
||||
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
|
||||
|
||||
export type V2EventRpc = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
type: `${"rpc."}${string}`
|
||||
location: LocationRef
|
||||
data: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type V2EventServerConnected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
@@ -2315,6 +2328,7 @@ export type V2Event =
|
||||
| VcsBranchUpdated
|
||||
| McpStatusChanged
|
||||
| McpResourcesChanged
|
||||
| V2EventRpc
|
||||
| V2EventServerConnected
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
@@ -2481,6 +2495,24 @@ export type PermissionNotFoundError = {
|
||||
export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError"
|
||||
|
||||
export type RpcError = {
|
||||
readonly _tag: "RpcError"
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly data?: unknown | undefined
|
||||
}
|
||||
export const isRpcError = (value: unknown): value is RpcError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcError"
|
||||
|
||||
export type RpcInternalError = {
|
||||
readonly _tag: "RpcInternalError"
|
||||
readonly type: "rpc.internal" | "rpc.invalid_output"
|
||||
readonly message: string
|
||||
readonly data?: unknown | undefined
|
||||
}
|
||||
export const isRpcInternalError = (value: unknown): value is RpcInternalError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcInternalError"
|
||||
|
||||
export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly ptyID: string; readonly message: string }
|
||||
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
|
||||
@@ -5669,6 +5701,17 @@ export type SkillListOutput = {
|
||||
data: Array<SkillInfo>
|
||||
}
|
||||
|
||||
export type RpcCallInput = {
|
||||
readonly rpcID: { readonly rpcID: string; readonly method: string }["rpcID"]
|
||||
readonly method: { readonly rpcID: string; readonly method: string }["method"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly input?: { readonly input: JsonValue }["input"]
|
||||
}
|
||||
|
||||
export type RpcCallOutput = RpcOutput
|
||||
|
||||
export type EventSubscribeOutput = V2Event
|
||||
|
||||
export type PtyListInput = {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { OpenCode } from "./client.js"
|
||||
|
||||
export * from "./generated/index.js"
|
||||
export { OpenCode } from "./client.js"
|
||||
export type {
|
||||
AgentApi,
|
||||
CatalogApi,
|
||||
@@ -10,9 +13,13 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
RpcApi,
|
||||
RpcCallOptions,
|
||||
RpcClient,
|
||||
RpcEventPayload,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types.js"
|
||||
export type OpenCodeClient = ReturnType<typeof import("./generated/client.js").make>
|
||||
export type OpenCodeClient = ReturnType<typeof OpenCode.make>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import type { make, RequestOptions } from "./generated/client.js"
|
||||
import { isRpcError, isRpcInternalError } from "./generated/types.js"
|
||||
import type { EventSubscribeOutput, LocationGetInput, RpcCallInput } from "./generated/types.js"
|
||||
|
||||
type RpcEvent = Extract<EventSubscribeOutput, { type: `rpc.${string}` }>
|
||||
|
||||
export interface RpcCallOptions extends RequestOptions {
|
||||
readonly location?: LocationGetInput["location"]
|
||||
}
|
||||
|
||||
export type RpcArguments<Input, Options> = unknown extends Input
|
||||
? [input: Input, options?: Options]
|
||||
: undefined extends Input
|
||||
? [input?: Input, options?: Options]
|
||||
: [input: Input, options?: Options]
|
||||
|
||||
export type RpcClient<D extends Rpc.PortableDefinition, Options = RpcCallOptions> = {
|
||||
readonly [Name in keyof D["methods"]]: (
|
||||
...args: RpcArguments<Rpc.Input<D["methods"][Name]["input"]>, Options>
|
||||
) => Promise<Rpc.Output<D["methods"][Name]["output"]>>
|
||||
} & {
|
||||
readonly events: {
|
||||
readonly subscribe: <Name extends keyof D["events"] & string>(
|
||||
name: Name,
|
||||
options?: Pick<RequestOptions, "signal">,
|
||||
) => AsyncIterable<RpcEventPayload<D, Name>>
|
||||
readonly on: <Name extends keyof D["events"] & string>(
|
||||
name: Name,
|
||||
handler: (event: RpcEventPayload<D, Name>) => Promise<void> | void,
|
||||
options?: Pick<RequestOptions, "signal">,
|
||||
) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
type RpcEventPayloadFor<
|
||||
D extends Rpc.PortableDefinition,
|
||||
Name extends keyof D["events"] & string,
|
||||
> = Omit<RpcEvent, "type" | "data"> & {
|
||||
type: `rpc.${D["id"]}.${Name}`
|
||||
data: Rpc.EventData<D["events"][Name]["schema"]>
|
||||
}
|
||||
|
||||
export type RpcEventPayload<
|
||||
D extends Rpc.PortableDefinition,
|
||||
Name extends keyof D["events"] & string = keyof D["events"] & string,
|
||||
> = { [K in Name]: RpcEventPayloadFor<D, K> }[Name]
|
||||
|
||||
export interface RpcApi<Options = RpcCallOptions> {
|
||||
<D extends Rpc.PortableDefinition>(definition: D): RpcClient<D, Options>
|
||||
}
|
||||
|
||||
export function makeRpc(
|
||||
raw: ReturnType<typeof make>,
|
||||
events: { subscribe(options?: Pick<RequestOptions, "signal">): AsyncIterable<EventSubscribeOutput> },
|
||||
): RpcApi {
|
||||
return (definition) => {
|
||||
const subscribe = (
|
||||
name: string,
|
||||
options?: Pick<RequestOptions, "signal">,
|
||||
): AsyncIterable<RpcEventPayload<Rpc.PortableDefinition>> => {
|
||||
if (!Object.hasOwn(definition.events, name)) throw new Error(`Unknown RPC event: ${definition.id}.${name}`)
|
||||
const type = eventType(definition, name)
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
const controller = new AbortController()
|
||||
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
|
||||
const iterator = (async function* () {
|
||||
try {
|
||||
for await (const published of events.subscribe({ signal })) {
|
||||
if (signal.aborted) return
|
||||
if (published.type !== type) continue
|
||||
// SAFETY: The exact RPC type was selected above; Promise contracts require no client-side transform.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
yield published as RpcEventPayload<Rpc.PortableDefinition>
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) throw error
|
||||
} finally {
|
||||
controller.abort()
|
||||
}
|
||||
})()
|
||||
return {
|
||||
next: () => iterator.next(),
|
||||
return: () => {
|
||||
// Interrupt a pending source read before closing the generator.
|
||||
controller.abort()
|
||||
return iterator.return()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
// SAFETY: Every runtime key comes from this definition's method and event maps, which define RpcClient's mapped keys.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
return Object.assign(
|
||||
Object.fromEntries(
|
||||
Object.keys(definition.methods).map((name) => [
|
||||
name,
|
||||
async (input: unknown, options?: RpcCallOptions) => {
|
||||
try {
|
||||
const result = await raw.rpc.call(
|
||||
{
|
||||
rpcID: definition.id,
|
||||
method: name,
|
||||
// SAFETY: The method schema defines the accepted input; this assertion bridges it to the generic JSON transport.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
input: input as RpcCallInput["input"],
|
||||
location: options?.location,
|
||||
},
|
||||
{ signal: options?.signal, headers: options?.headers },
|
||||
)
|
||||
return result.output
|
||||
} catch (error) {
|
||||
if (!isRpcError(error) && !isRpcInternalError(error)) throw error
|
||||
throw error.data === undefined
|
||||
? { type: error.type, message: error.message }
|
||||
: { type: error.type, message: error.message, data: error.data }
|
||||
}
|
||||
},
|
||||
]),
|
||||
),
|
||||
{
|
||||
events: {
|
||||
subscribe,
|
||||
on: (
|
||||
name: string,
|
||||
handler: (event: RpcEventPayload<Rpc.PortableDefinition>) => Promise<void> | void,
|
||||
options?: Pick<RequestOptions, "signal">,
|
||||
) => {
|
||||
const controller = new AbortController()
|
||||
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
|
||||
const source = subscribe(name, { signal })
|
||||
void (async () => {
|
||||
for await (const event of source) await handler(event)
|
||||
})().catch((error: unknown) => console.error(error))
|
||||
return () => controller.abort()
|
||||
},
|
||||
},
|
||||
},
|
||||
) as RpcClient<typeof definition>
|
||||
}
|
||||
}
|
||||
|
||||
function eventType(definition: Rpc.PortableDefinition, name: string) {
|
||||
return `rpc.${definition.id}.${name}` as const
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export * as RpcRuntime from "./rpc-runtime.js"
|
||||
|
||||
import type { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
|
||||
|
||||
export function read(schema: Rpc.Method["output"], value: unknown) {
|
||||
// Standard Schema results have already been parsed by the server.
|
||||
return Schema.isSchema(schema) ? Schema.decodeUnknownEffect(schema)(value) : Effect.succeed(value)
|
||||
}
|
||||
|
||||
export function readError(method: Rpc.Method, error: unknown): Effect.Effect<never, unknown> {
|
||||
if (!(error instanceof RpcError) && !(error instanceof RpcInternalError)) return Effect.fail(error)
|
||||
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
|
||||
return Effect.fail(
|
||||
error.data === undefined
|
||||
? { type: error.type, message: error.message }
|
||||
: { type: error.type, message: error.message, data: error.data },
|
||||
)
|
||||
}
|
||||
return read(method.errors[error.type], error.data).pipe(
|
||||
Effect.catch((cause) => Effect.die(cause)),
|
||||
Effect.flatMap((data) =>
|
||||
Effect.fail(
|
||||
data === undefined
|
||||
? { type: error.type, message: error.message }
|
||||
: { type: error.type, message: error.message, data },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const event = Effect.fn("Client.Rpc.event")(function* <
|
||||
D extends Rpc.Definition,
|
||||
Name extends keyof D["events"] & string,
|
||||
>(
|
||||
definition: D,
|
||||
name: Name,
|
||||
schema: Rpc.EventDefinition,
|
||||
event: RpcEvent,
|
||||
): Effect.fn.Return<Rpc.EventPayload<D, Name>, unknown> {
|
||||
const data = yield* read(schema.schema, event.data)
|
||||
// SAFETY: The event type was selected by the caller and data was decoded with this event's schema.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
...event,
|
||||
type: eventType(definition, name),
|
||||
data,
|
||||
} as Rpc.EventPayload<D, Name>
|
||||
})
|
||||
|
||||
export function eventType<const D extends Rpc.Definition, const Name extends keyof D["events"] & string>(
|
||||
definition: D,
|
||||
name: Name,
|
||||
): `rpc.${D["id"]}.${Name}` {
|
||||
return `rpc.${definition.id}.${name}`
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
export * as SharedEvents from "./shared-events.js"
|
||||
|
||||
export function make<A extends { readonly type: string }>(connect: (signal: AbortSignal) => AsyncIterable<A>) {
|
||||
type Completion = { readonly error: unknown } | Record<string, never>
|
||||
type Subscriber = {
|
||||
push: (value: A) => Promise<void>
|
||||
finish: (completion: Completion) => void
|
||||
}
|
||||
type Connection = {
|
||||
controller: AbortController
|
||||
subscribers: Set<Subscriber>
|
||||
connected?: A
|
||||
}
|
||||
|
||||
let current: Connection | undefined
|
||||
const delivered = Promise.resolve()
|
||||
|
||||
function stop(connection: Connection) {
|
||||
connection.connected = undefined
|
||||
connection.controller.abort()
|
||||
if (current === connection) current = undefined
|
||||
}
|
||||
|
||||
async function run(connection: Connection) {
|
||||
let iterator: AsyncIterator<A> | undefined
|
||||
let completion: Completion = {}
|
||||
try {
|
||||
if (connection.controller.signal.aborted) return
|
||||
iterator = connect(connection.controller.signal)[Symbol.asyncIterator]()
|
||||
while (!connection.controller.signal.aborted) {
|
||||
const item = await iterator.next()
|
||||
if (item.done || connection.controller.signal.aborted) break
|
||||
if (item.value.type === "server.connected") connection.connected = item.value
|
||||
await Promise.all(Array.from(connection.subscribers, (subscriber) => subscriber.push(item.value)))
|
||||
}
|
||||
} catch (error) {
|
||||
completion = { error }
|
||||
} finally {
|
||||
stop(connection)
|
||||
try {
|
||||
await iterator?.return?.()
|
||||
} catch (error) {
|
||||
if (!("error" in completion)) completion = { error }
|
||||
}
|
||||
connection.subscribers.forEach((subscriber) => subscriber.finish(completion))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(options?: { readonly signal?: AbortSignal }): AsyncIterable<A> {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
const pending: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>[] = []
|
||||
let started = false
|
||||
let completion: Completion | undefined
|
||||
let connection: Connection | undefined
|
||||
let offered: { readonly value: A; readonly accepted: ReturnType<typeof Promise.withResolvers<void>> } | undefined
|
||||
|
||||
function finish(result: Completion) {
|
||||
completion = result
|
||||
offered?.accepted.resolve()
|
||||
offered = undefined
|
||||
options?.signal?.removeEventListener("abort", abort)
|
||||
if (connection?.subscribers.delete(subscriber) && !connection.subscribers.size) stop(connection)
|
||||
pending.splice(0).forEach((request) => {
|
||||
if ("error" in result) request.reject(result.error)
|
||||
else request.resolve({ done: true, value: undefined })
|
||||
})
|
||||
}
|
||||
|
||||
function abort() {
|
||||
finish({})
|
||||
}
|
||||
|
||||
const subscriber: Subscriber = {
|
||||
finish,
|
||||
push(value) {
|
||||
if (completion) return delivered
|
||||
const request = pending.shift()
|
||||
if (request) {
|
||||
request.resolve({ done: false, value })
|
||||
return delivered
|
||||
}
|
||||
const accepted = Promise.withResolvers<void>()
|
||||
offered = { value, accepted }
|
||||
return accepted.promise
|
||||
},
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (completion) return
|
||||
const fresh = !current
|
||||
connection = current ?? {
|
||||
controller: new AbortController(),
|
||||
subscribers: new Set<Subscriber>(),
|
||||
}
|
||||
current = connection
|
||||
connection.subscribers.add(subscriber)
|
||||
if (connection.connected) void subscriber.push(connection.connected)
|
||||
if (fresh) void run(connection)
|
||||
}
|
||||
|
||||
return {
|
||||
next(): Promise<IteratorResult<A>> {
|
||||
if (offered) {
|
||||
const current = offered
|
||||
offered = undefined
|
||||
current.accepted.resolve()
|
||||
return Promise.resolve({ done: false, value: current.value })
|
||||
}
|
||||
if (completion) {
|
||||
if ("error" in completion) return Promise.reject(completion.error)
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}
|
||||
if (options?.signal?.aborted) {
|
||||
abort()
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}
|
||||
const request = Promise.withResolvers<IteratorResult<A>>()
|
||||
pending.push(request)
|
||||
if (!started) {
|
||||
started = true
|
||||
options?.signal?.addEventListener("abort", abort, { once: true })
|
||||
start()
|
||||
}
|
||||
return request.promise
|
||||
},
|
||||
return(): Promise<IteratorResult<A>> {
|
||||
finish({})
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
|
||||
const event = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
if ("durable" in event.value)
|
||||
if ("durable" in event.value && event.value.durable)
|
||||
options.log?.debug?.("event", {
|
||||
type: event.value.type,
|
||||
aggregateID: event.value.durable.aggregateID,
|
||||
|
||||
@@ -51,6 +51,7 @@ import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
|
||||
export type CreateDataInput = {
|
||||
readonly api: () => OpenCodeClient
|
||||
@@ -58,7 +59,7 @@ export type CreateDataInput = {
|
||||
readonly event: {
|
||||
readonly on: <Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
|
||||
handler: (event: OpenCodeEventMap[Type]) => void,
|
||||
) => () => void
|
||||
readonly listen: (handler: (event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) => () => void
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
|
||||
sessionID: "ses_test",
|
||||
key: "review-notes",
|
||||
})
|
||||
const emptyRpcOutput: Awaited<ReturnType<typeof promiseClient.rpc.call>> = {}
|
||||
|
||||
void [
|
||||
effectSession,
|
||||
@@ -54,6 +55,7 @@ void [
|
||||
promiseList,
|
||||
promisePut,
|
||||
promiseRemove,
|
||||
emptyRpcOutput,
|
||||
exactVersion,
|
||||
compatibleVersion,
|
||||
]
|
||||
|
||||
@@ -14,34 +14,34 @@ describe("public import boundaries", () => {
|
||||
test("isolates each public entrypoint", async () => {
|
||||
const root = await bundleInputs("@opencode-ai/client", "browser")
|
||||
|
||||
expect(within(root, effect)).toEqual([])
|
||||
expect(within(root, schema)).toEqual([])
|
||||
expect(within(root, protocol)).toEqual([])
|
||||
expect(within(root, core)).toEqual([])
|
||||
expect(within(root, server)).toEqual([])
|
||||
expect(within(root.all, effect)).toEqual([])
|
||||
expect(within(root.all, schema)).toEqual([])
|
||||
expect(within(root.all, protocol)).toEqual([])
|
||||
expect(within(root.all, core)).toEqual([])
|
||||
expect(within(root.all, server)).toEqual([])
|
||||
|
||||
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
|
||||
|
||||
expect(within(network, effect).length).toBeGreaterThan(0)
|
||||
expect(within(network, schema).length).toBeGreaterThan(0)
|
||||
expect(within(network, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(network, core)).toEqual([])
|
||||
expect(within(network, server)).toEqual([])
|
||||
expect(within(network.eager, effect).length).toBeGreaterThan(0)
|
||||
expect(within(network.eager, schema).length).toBeGreaterThan(0)
|
||||
expect(within(network.eager, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(network.all, core)).toEqual([])
|
||||
expect(within(network.all, server)).toEqual([])
|
||||
|
||||
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
|
||||
|
||||
expect(within(promiseService, effect)).toEqual([])
|
||||
expect(within(promiseService, schema)).toEqual([])
|
||||
expect(within(promiseService, protocol)).toEqual([])
|
||||
expect(within(promiseService, core)).toEqual([])
|
||||
expect(within(promiseService, server)).toEqual([])
|
||||
expect(within(promiseService.all, effect)).toEqual([])
|
||||
expect(within(promiseService.all, schema)).toEqual([])
|
||||
expect(within(promiseService.all, protocol)).toEqual([])
|
||||
expect(within(promiseService.all, core)).toEqual([])
|
||||
expect(within(promiseService.all, server)).toEqual([])
|
||||
|
||||
const effectService = await bundleInputs("@opencode-ai/client/effect/service", "bun")
|
||||
|
||||
expect(within(effectService, effect).length).toBeGreaterThan(0)
|
||||
expect(within(effectService, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(effectService, core)).toEqual([])
|
||||
expect(within(effectService, server)).toEqual([])
|
||||
expect(within(effectService.eager, effect).length).toBeGreaterThan(0)
|
||||
expect(within(effectService.eager, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(effectService.all, core)).toEqual([])
|
||||
expect(within(effectService.all, server)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,8 +70,21 @@ async function bundleInputs(specifier: string, target: "browser" | "bun") {
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||
const metadata = await Bun.file(metafile).json()
|
||||
return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
|
||||
const metadata: {
|
||||
inputs: Record<string, { imports: Array<{ path: string; kind: string; external?: boolean }> }>
|
||||
} = await Bun.file(metafile).json()
|
||||
const inputs = new Map(Object.entries(metadata.inputs).map(([file, input]) => [resolve(directory, file), input]))
|
||||
const eager = new Set<string>()
|
||||
const visit = (file: string) => {
|
||||
if (eager.has(file)) return
|
||||
eager.add(file)
|
||||
inputs
|
||||
.get(file)
|
||||
?.imports.filter((input) => !input.external && input.kind !== "dynamic-import")
|
||||
.forEach((input) => visit(resolve(directory, input.path)))
|
||||
}
|
||||
visit(entrypoint)
|
||||
return { all: Array.from(inputs.keys()), eager: Array.from(eager) }
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"file",
|
||||
"command",
|
||||
"skill",
|
||||
"rpc",
|
||||
"event",
|
||||
"pty",
|
||||
"experimental",
|
||||
@@ -677,6 +678,51 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("native event signals cancel only their listener and close transport after the last listener", async () => {
|
||||
const opened = Promise.withResolvers<Request>()
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
headers: { authorization: "Bearer events" },
|
||||
fetch: async (input, init) => {
|
||||
const request = new Request(input, init)
|
||||
opened.resolve(request)
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
request.signal.addEventListener("abort", () => controller.error(request.signal.reason), { once: true })
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
const first = new AbortController()
|
||||
const second = new AbortController()
|
||||
const one = client.event.subscribe({ signal: first.signal })[Symbol.asyncIterator]().next()
|
||||
const two = client.event.subscribe({ signal: second.signal })[Symbol.asyncIterator]().next()
|
||||
const request = await opened.promise
|
||||
expect(request.headers.get("authorization")).toBe("Bearer events")
|
||||
first.abort()
|
||||
expect((await one).done).toBe(true)
|
||||
expect(request.signal.aborted).toBe(false)
|
||||
second.abort()
|
||||
expect((await two).done).toBe(true)
|
||||
expect(request.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("native pre-aborted event signals do not open a transport", async () => {
|
||||
let requests = 0
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () => {
|
||||
requests++
|
||||
return new Response(null)
|
||||
},
|
||||
})
|
||||
expect((await client.event.subscribe({ signal: AbortSignal.abort() })[Symbol.asyncIterator]().next()).done).toBe(true)
|
||||
expect(requests).toBe(0)
|
||||
})
|
||||
|
||||
test("event.subscribe accepts a fragmented SSE event below the size limit", async () => {
|
||||
const event = { id: "evt_large", type: "test.large", data: { output: "x".repeat(12 * 1024 * 1024) } }
|
||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import { Cause, Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { OpenCode } from "../src/effect/index"
|
||||
|
||||
const definition = Rpc.define({
|
||||
id: "example",
|
||||
methods: {
|
||||
count: {
|
||||
input: Schema.Struct({ count: Schema.FiniteFromString }),
|
||||
output: Schema.FiniteFromString,
|
||||
errors: { too_large: Schema.Struct({ limit: Schema.FiniteFromString }) },
|
||||
},
|
||||
echo: { input: Schema.Json, output: Schema.Json },
|
||||
empty: { input: Schema.Undefined, output: Schema.Undefined },
|
||||
raw: { input: { type: "string" }, output: { type: "number" } },
|
||||
},
|
||||
events: {
|
||||
progress: { schema: Schema.Struct({ count: Schema.FiniteFromString }) },
|
||||
message: { schema: Schema.Struct({ text: Schema.String }) },
|
||||
},
|
||||
})
|
||||
|
||||
const connected = { id: "evt_connected", type: "server.connected", data: {} }
|
||||
|
||||
function rpcEvent(count: unknown, directory = "/project/one", rpcID = "example", name = "progress") {
|
||||
return {
|
||||
id: "evt_progress",
|
||||
created: 123,
|
||||
type: `rpc.${rpcID}.${name}`,
|
||||
location: { directory },
|
||||
metadata: { origin: "test" },
|
||||
data: { count },
|
||||
}
|
||||
}
|
||||
|
||||
function eventSource() {
|
||||
const requests: HttpClientRequest.HttpClientRequest[] = []
|
||||
const opened = Promise.withResolvers<{
|
||||
controller: ReadableStreamDefaultController<Uint8Array>
|
||||
signal: AbortSignal
|
||||
}>()
|
||||
const cancelled = Promise.withResolvers<void>()
|
||||
return {
|
||||
requests,
|
||||
opened: opened.promise,
|
||||
cancelled: cancelled.promise,
|
||||
async push(event: unknown) {
|
||||
const source = await opened.promise
|
||||
source.controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
},
|
||||
httpClient: HttpClient.make((request, _url, signal) => {
|
||||
requests.push(request)
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
opened.resolve({ controller, signal })
|
||||
},
|
||||
cancel() {
|
||||
cancelled.resolve()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
test("Effect RPC calls retain encoded inputs, decode outputs, and preserve raw native RPC calls", async () => {
|
||||
const requests: Array<{ url: string; body: unknown }> = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
|
||||
requests.push({ url: request.url, body })
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({
|
||||
output: request.url.endsWith("/count") ? "42" : request.url.endsWith("/raw") ? 7 : body.input,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: new URL("http://localhost:3000") })
|
||||
const rpc = client.rpc(definition)
|
||||
const count = yield* rpc.count({ count: "2" })
|
||||
const primitives = yield* Effect.forEach([null, false, 0, "hello", [1, "two"]], (value) => rpc.echo(value))
|
||||
const empty = yield* rpc.empty()
|
||||
const raw = yield* rpc.raw("input")
|
||||
const native = yield* client.rpc.call({ rpcID: "example", method: "count", input: null })
|
||||
expect(Object.keys(rpc.events)).toEqual(["subscribe"])
|
||||
return { count, primitives, empty, raw, native }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(result).toEqual({
|
||||
count: 42,
|
||||
primitives: [null, false, 0, "hello", [1, "two"]],
|
||||
empty: undefined,
|
||||
raw: 7,
|
||||
native: { output: "42" },
|
||||
})
|
||||
expect(requests[0]).toEqual({ url: "http://localhost:3000/api/rpc/example/count", body: { input: { count: "2" } } })
|
||||
expect(requests.find((request) => request.url.endsWith("/empty"))?.body).toEqual({})
|
||||
})
|
||||
|
||||
test("Effect RPC trusts server-side Standard Schema transforms for outputs and events", async () => {
|
||||
const validations: unknown[] = []
|
||||
const standard = {
|
||||
"~standard": {
|
||||
version: 1 as const,
|
||||
vendor: "fixture",
|
||||
validate(value: unknown) {
|
||||
validations.push(value)
|
||||
return { value: String(value) + " transformed" }
|
||||
},
|
||||
},
|
||||
}
|
||||
const service = Rpc.define({
|
||||
id: "standard",
|
||||
methods: { transform: { input: standard, output: standard } },
|
||||
events: {
|
||||
transformed: {
|
||||
schema: {
|
||||
"~standard": {
|
||||
version: 1 as const,
|
||||
vendor: "fixture",
|
||||
validate(value: unknown) {
|
||||
validations.push(value)
|
||||
return { value: { text: String(value) + " transformed" } }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
request.url.endsWith("/api/event")
|
||||
? new Response(
|
||||
`data: ${JSON.stringify({ ...rpcEvent(1), type: "rpc.standard.transformed", data: { text: "done" } })}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
: Response.json({ output: "done" }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const rpc = client.rpc(service)
|
||||
return {
|
||||
output: yield* rpc.transform("input"),
|
||||
events: yield* Stream.runCollect(rpc.events.subscribe("transformed")),
|
||||
}
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(result.output).toBe("done")
|
||||
expect(result.events[0].data).toEqual({ text: "done" })
|
||||
expect(validations).toEqual([])
|
||||
})
|
||||
|
||||
test("Effect RPC validates decoded outputs in the failure channel", async () => {
|
||||
const requests: string[] = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
requests.push(request.url)
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ output: "not a number" })))
|
||||
})
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* Effect.flip(client.rpc(definition).count({ count: "1" }))
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(Schema.isSchemaError(error)).toBe(true)
|
||||
expect(requests).toEqual(["http://localhost:3000/api/rpc/example/count"])
|
||||
})
|
||||
|
||||
test("Effect RPC decodes declared errors and removes the generic transport wrapper", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{ _tag: "RpcError", type: "too_large", message: "Too large", data: { limit: "3" } },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error).toEqual({ type: "too_large", message: "Too large", data: { limit: 3 } })
|
||||
})
|
||||
|
||||
test("Effect RPC removes the internal transport wrapper", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" },
|
||||
{ status: 500 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error).toEqual({ type: "rpc.internal", message: "Failed" })
|
||||
})
|
||||
|
||||
test("Effect RPC isolates per-call location and headers while preserving configured defaults and native behavior", async () => {
|
||||
const requests: Array<{ url: URL; headers: HttpClientRequest.HttpClientRequest["headers"] }> = []
|
||||
const release = Promise.withResolvers<void>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
const httpClient = HttpClient.make((request, url) => {
|
||||
requests.push({ url, headers: request.headers })
|
||||
if (requests.length === 1) started.resolve()
|
||||
return Effect.promise(() => release.promise).pipe(
|
||||
Effect.as(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
url.pathname.endsWith("/health")
|
||||
? Response.json({ healthy: true, version: "test", pid: 1 })
|
||||
: Response.json({ output: "3" }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}).pipe(HttpClient.mapRequest(HttpClientRequest.setHeaders({ authorization: "Bearer base", "x-default": "base" })))
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
|
||||
)
|
||||
const rpc = client.rpc(definition)
|
||||
const first = Effect.runPromise(
|
||||
rpc.count(
|
||||
{ count: "1" },
|
||||
{ location: { directory: "/project/one", workspace: "one" }, headers: { "x-call": "one" } },
|
||||
),
|
||||
)
|
||||
await started.promise
|
||||
const second = Effect.runPromise(
|
||||
rpc.count(
|
||||
{ count: "2" },
|
||||
{ location: { directory: "/project/two" }, headers: new Headers({ "x-call": "two", "x-default": "override" }) },
|
||||
),
|
||||
)
|
||||
const native = Effect.runPromise(client.health.get())
|
||||
release.resolve()
|
||||
expect(await Promise.all([first, second])).toEqual([3, 3])
|
||||
expect(await native).toEqual({ healthy: true, version: "test", pid: 1 })
|
||||
expect(requests.map((request) => request.headers.authorization)).toEqual([
|
||||
"Bearer base",
|
||||
"Bearer base",
|
||||
"Bearer base",
|
||||
])
|
||||
expect(requests.map((request) => request.headers["x-call"])).toEqual(["one", "two", undefined])
|
||||
expect(requests.map((request) => request.headers["x-default"])).toEqual(["base", "override", "base"])
|
||||
expect(requests.map((request) => request.url.searchParams.get("location[directory]"))).toEqual([
|
||||
"/project/one",
|
||||
"/project/two",
|
||||
null,
|
||||
])
|
||||
expect(requests.map((request) => request.url.searchParams.get("location[workspace]"))).toEqual(["one", null, null])
|
||||
})
|
||||
|
||||
test("RPC signals and consumer interruption abort only their own HTTP calls", async () => {
|
||||
const started: Array<ReturnType<typeof Promise.withResolvers<AbortSignal>>> = [
|
||||
Promise.withResolvers<AbortSignal>(),
|
||||
Promise.withResolvers<AbortSignal>(),
|
||||
]
|
||||
const signals: AbortSignal[] = []
|
||||
const finalized: number[] = []
|
||||
const httpClient = HttpClient.make((_request, _url, signal) => {
|
||||
const index = signals.length
|
||||
signals.push(signal)
|
||||
started[index].resolve(signal)
|
||||
return Effect.never.pipe(Effect.ensuring(Effect.sync(() => finalized.push(index))))
|
||||
})
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
|
||||
)
|
||||
const rpc = client.rpc(definition)
|
||||
const abort = new AbortController()
|
||||
const first = Effect.runFork(rpc.count({ count: "1" }, { signal: abort.signal }))
|
||||
const second = Effect.runFork(rpc.count({ count: "2" }))
|
||||
await Promise.all(started.map((entry) => entry.promise))
|
||||
abort.abort()
|
||||
const exit = await Effect.runPromise(Fiber.await(first))
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
expect(signals.map((signal) => signal.aborted)).toEqual([true, false])
|
||||
expect(finalized).toEqual([0])
|
||||
await Effect.runPromise(Fiber.interrupt(second))
|
||||
expect(signals[1].aborted).toBe(true)
|
||||
expect(finalized).toEqual([0, 1])
|
||||
|
||||
const preAborted = await Effect.runPromiseExit(rpc.count({ count: "3" }, { signal: abort.signal }))
|
||||
expect(Exit.isFailure(preAborted) && Cause.hasInterruptsOnly(preAborted.cause)).toBe(true)
|
||||
expect(signals).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("native and RPC Effect streams share one lazy source, cache connected, and filter across all locations", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const rpc = client.rpc(definition)
|
||||
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
|
||||
const progress = Stream.toAsyncIterable(rpc.events.subscribe("progress"))[Symbol.asyncIterator]()
|
||||
expect(source.requests).toHaveLength(0)
|
||||
const marker = native.next()
|
||||
await source.push(connected)
|
||||
expect((await marker).value).toEqual(connected)
|
||||
|
||||
const first = progress.next()
|
||||
const late = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
|
||||
expect((await late.next()).value).toEqual(connected)
|
||||
await native.return?.()
|
||||
await late.return?.()
|
||||
await source.push(rpcEvent("ignored", "/project/one", "other"))
|
||||
await source.push(rpcEvent("ignored", "/project/one", "example", "message"))
|
||||
await source.push(rpcEvent("1"))
|
||||
expect((await first).value).toEqual({
|
||||
id: "evt_progress",
|
||||
created: 123,
|
||||
type: "rpc.example.progress",
|
||||
metadata: { origin: "test" },
|
||||
data: { count: 1 },
|
||||
location: { directory: "/project/one" },
|
||||
})
|
||||
const second = progress.next()
|
||||
await source.push(rpcEvent("2", "/project/two"))
|
||||
expect((await second).value).toEqual(
|
||||
expect.objectContaining({ data: { count: 2 }, location: { directory: "/project/two" } }),
|
||||
)
|
||||
expect(source.requests).toHaveLength(1)
|
||||
|
||||
expect((await source.opened).signal.aborted).toBe(false)
|
||||
const third = progress.next()
|
||||
await source.push(rpcEvent("3"))
|
||||
expect((await third).value.data).toEqual({ count: 3 })
|
||||
const pending = progress.next()
|
||||
await progress.return?.()
|
||||
expect((await pending).done).toBe(true)
|
||||
await source.cancelled
|
||||
expect((await source.opened).signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("interrupting a native Effect stream leaves an active RPC consumer running", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Effect.runFork(Stream.runCollect(client.event.subscribe()))
|
||||
const progress = Stream.toAsyncIterable(client.rpc(definition).events.subscribe("progress"))[Symbol.asyncIterator]()
|
||||
const first = progress.next()
|
||||
await source.push(rpcEvent("1"))
|
||||
expect((await first).value.data).toEqual({ count: 1 })
|
||||
await Effect.runPromise(Fiber.interrupt(native))
|
||||
expect((await source.opened).signal.aborted).toBe(false)
|
||||
const second = progress.next()
|
||||
await source.push(rpcEvent("2"))
|
||||
expect((await second).value.data).toEqual({ count: 2 })
|
||||
await progress.return?.()
|
||||
await source.cancelled
|
||||
})
|
||||
|
||||
test("shared Effect streams preserve EOF without reconnecting", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Effect.runPromise(Stream.runCollect(client.event.subscribe()))
|
||||
const progress = Effect.runPromise(Stream.runCollect(client.rpc(definition).events.subscribe("progress")))
|
||||
await source.push(connected)
|
||||
await source.push(rpcEvent("1"))
|
||||
const connection = await source.opened
|
||||
connection.controller.close()
|
||||
expect((await native).map((event) => event.type)).toEqual(["server.connected", "rpc.example.progress"])
|
||||
expect((await progress).map((event) => event.data)).toEqual([{ count: 1 }])
|
||||
expect(source.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("native protocol failures reach both native and RPC streams as ClientError", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
|
||||
const progress = Effect.runPromise(
|
||||
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
|
||||
)
|
||||
await source.push({ type: "server.connected" })
|
||||
expect((await native)._tag).toBe("ClientError")
|
||||
expect(await progress).toBe(await native)
|
||||
expect(source.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("HTTP source failures reach every Effect consumer", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
|
||||
const progress = Effect.runPromise(
|
||||
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
|
||||
)
|
||||
await source.push(connected)
|
||||
const connection = await source.opened
|
||||
connection.controller.error(new Error("connection lost"))
|
||||
expect((await native)._tag).toBe("ClientError")
|
||||
expect(await progress).toBe(await native)
|
||||
expect(source.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("RPC payload decoding fails only the matching consumer, not the native event stream", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
|
||||
const raw = native.next()
|
||||
const progress = Effect.runPromise(
|
||||
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
|
||||
)
|
||||
await source.push(rpcEvent("not a number"))
|
||||
expect((await raw).value.type).toBe("rpc.example.progress")
|
||||
expect(Schema.isSchemaError(await progress)).toBe(true)
|
||||
expect((await source.opened).signal.aborted).toBe(false)
|
||||
const next = native.next()
|
||||
await source.push(connected)
|
||||
expect((await next).value.type).toBe("server.connected")
|
||||
await native.return?.()
|
||||
await source.cancelled
|
||||
})
|
||||
|
||||
test("shared event source runs with the Effect context captured by make", async () => {
|
||||
const Token = Context.Reference("test/rpc-effect/token", { defaultValue: () => "missing" })
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
const token = yield* Token
|
||||
expect(token).toBe("captured")
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, httpClient),
|
||||
Effect.provideService(Token, "captured"),
|
||||
),
|
||||
)
|
||||
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
|
||||
})
|
||||
|
||||
test("Effect RPC rejects inherited event names without opening the source", async () => {
|
||||
const requests: string[] = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
requests.push(request.url)
|
||||
return Effect.die(new Error("Unexpected request"))
|
||||
})
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const broad: Rpc.Definition = definition
|
||||
return yield* client.rpc(broad).events.subscribe("toString").pipe(Stream.runDrain, Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error).toEqual(new Error("Unknown RPC event: rpc.example.toString"))
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import { z } from "zod"
|
||||
import { OpenCode } from "../src/promise/index"
|
||||
|
||||
const cleanup = new Set<() => void>()
|
||||
afterEach(() => {
|
||||
cleanup.forEach((close) => close())
|
||||
cleanup.clear()
|
||||
})
|
||||
|
||||
const Echo = Rpc.define({
|
||||
id: "acme/jobs",
|
||||
methods: {
|
||||
echo: {
|
||||
input: z.string(),
|
||||
output: z.string(),
|
||||
errors: { rejected: z.object({ reason: z.string() }) },
|
||||
},
|
||||
raw: { input: z.unknown(), output: z.unknown() },
|
||||
ping: { input: z.undefined(), output: z.undefined() },
|
||||
},
|
||||
events: {
|
||||
updated: { schema: z.object({ count: z.number() }) },
|
||||
},
|
||||
})
|
||||
const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} }
|
||||
const rpcEvent = (data: unknown, directory = "/first", rpcID = Echo.id, name = "updated") => ({
|
||||
id: "evt_rpc",
|
||||
created: 10,
|
||||
type: `rpc.${rpcID}.${name}`,
|
||||
location: { directory },
|
||||
metadata: { source: "test" },
|
||||
data,
|
||||
})
|
||||
function http(fetch: (request: Request) => Response | Promise<Response>) {
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })
|
||||
cleanup.add(() => server.stop(true))
|
||||
return OpenCode.make({ baseUrl: server.url.href, headers: { authorization: "Bearer default", "x-base": "base" } })
|
||||
}
|
||||
|
||||
function events() {
|
||||
const requests: Request[] = []
|
||||
const opened = Promise.withResolvers<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const cancelled = Promise.withResolvers<void>()
|
||||
const encoder = new TextEncoder()
|
||||
let stopped = false
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
headers: { authorization: "Bearer events" },
|
||||
fetch: async (input, init) => {
|
||||
const request = new Request(input, init)
|
||||
requests.push(request)
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const abort = () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
controller.error(request.signal.reason)
|
||||
cancelled.resolve()
|
||||
}
|
||||
request.signal.addEventListener("abort", abort, { once: true })
|
||||
cleanup.add(abort)
|
||||
opened.resolve(controller)
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(connected)}\n\n`))
|
||||
},
|
||||
cancel() {
|
||||
stopped = true
|
||||
cancelled.resolve()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
|
||||
},
|
||||
})
|
||||
return {
|
||||
client,
|
||||
requests,
|
||||
cancelled: cancelled.promise,
|
||||
async send(value: unknown) {
|
||||
return (await opened.promise).enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
|
||||
},
|
||||
async end() {
|
||||
stopped = true
|
||||
return (await opened.promise).close()
|
||||
},
|
||||
async fail(error: Error) {
|
||||
stopped = true
|
||||
return (await opened.promise).error(error)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("rpc is callable, retains raw call, and routes method location, headers, and JSON body", async () => {
|
||||
const requests: Array<{ url: string; method: string; headers: Headers; body: unknown }> = []
|
||||
const client = http(async (request) => {
|
||||
const body = await request.json()
|
||||
requests.push({ url: request.url, method: request.method, headers: request.headers, body })
|
||||
return Response.json({ output: body.input })
|
||||
})
|
||||
expect(typeof client.rpc).toBe("function")
|
||||
expect(typeof client.rpc.call).toBe("function")
|
||||
expect(
|
||||
await client.rpc(Echo).echo("hello", {
|
||||
location: { directory: "/project with spaces", workspace: "wrk_test" },
|
||||
headers: { authorization: "Bearer override", "x-call": "call" },
|
||||
}),
|
||||
).toBe("hello")
|
||||
const url = new URL(requests[0].url)
|
||||
expect(url.pathname).toBe("/api/rpc/acme%2Fjobs/echo")
|
||||
expect(url.searchParams.get("location[directory]")).toBe("/project with spaces")
|
||||
expect(url.searchParams.get("location[workspace]")).toBe("wrk_test")
|
||||
expect(requests[0].body).toEqual({ input: "hello" })
|
||||
expect(requests[0].method).toBe("POST")
|
||||
expect(requests[0].headers.get("authorization")).toBe("Bearer override")
|
||||
expect(requests[0].headers.get("x-base")).toBe("base")
|
||||
expect(requests[0].headers.get("x-call")).toBe("call")
|
||||
expect(await client.rpc.call({ rpcID: Echo.id, method: "echo", input: "raw" })).toEqual({ output: "raw" })
|
||||
expect(new URL(requests[1].url).search).toBe("")
|
||||
expect(requests[1].headers.get("authorization")).toBe("Bearer default")
|
||||
})
|
||||
|
||||
test("no-input RPC methods and absent output use empty wrappers", async () => {
|
||||
const client = http(async (request) => {
|
||||
expect(await request.json()).toEqual({})
|
||||
return Response.json({})
|
||||
})
|
||||
expect(await client.rpc(Echo).ping()).toBeUndefined()
|
||||
expect(await client.rpc(Echo).ping(undefined, { location: { directory: "/project" } })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("RPC Standard Schema results are already parsed and are not transformed again", async () => {
|
||||
const calls = { input: 0, output: 0 }
|
||||
const input: StandardSchemaV1<string, number> = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (value) => {
|
||||
calls.input++
|
||||
return { value: Number(value) }
|
||||
},
|
||||
},
|
||||
}
|
||||
const output: StandardSchemaV1<number, string> = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (value) => {
|
||||
calls.output++
|
||||
return { value: String(value) }
|
||||
},
|
||||
},
|
||||
}
|
||||
const eventOutput: StandardSchemaV1<{ count: number }, { text: string }> = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (value) => {
|
||||
if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "number")
|
||||
return { issues: [{ message: "Expected count" }] }
|
||||
return { value: { text: String(value.count) } }
|
||||
},
|
||||
},
|
||||
}
|
||||
const definition = Rpc.define({
|
||||
id: "standard",
|
||||
methods: { count: { input, output } },
|
||||
events: { counted: { schema: eventOutput } },
|
||||
})
|
||||
const client = http(async (request) => {
|
||||
expect(await request.json()).toEqual({ input: "41" })
|
||||
return Response.json({ output: "42" })
|
||||
})
|
||||
expect(await client.rpc(definition).count("41")).toBe("42")
|
||||
const source = events()
|
||||
const iterator = source.client.rpc(definition).events.subscribe("counted")[Symbol.asyncIterator]()
|
||||
const next = iterator.next()
|
||||
await source.send(rpcEvent({ text: "42" }, "/project", definition.id, "counted"))
|
||||
expect((await next).value?.data).toEqual({ text: "42" })
|
||||
await iterator.return?.()
|
||||
expect(calls).toEqual({ input: 0, output: 0 })
|
||||
})
|
||||
|
||||
test("RPC method signals cancel an in-flight HTTP request", async () => {
|
||||
const received = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const client = http(() => {
|
||||
received.resolve()
|
||||
return response.promise
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const result = client
|
||||
.rpc(Echo)
|
||||
.echo("hello", { signal: controller.signal })
|
||||
.catch((error: unknown) => error)
|
||||
await received.promise
|
||||
controller.abort()
|
||||
expect(await result).toMatchObject({ name: "ClientError", reason: "Transport" })
|
||||
response.resolve(Response.json({ output: "late" }))
|
||||
})
|
||||
|
||||
test("RPC pre-aborted methods do not issue HTTP requests", async () => {
|
||||
let requests = 0
|
||||
const client = http(() => {
|
||||
requests++
|
||||
return Response.json({ output: "hello" })
|
||||
})
|
||||
await expect(client.rpc(Echo).echo("hello", { signal: AbortSignal.abort() })).rejects.toBeDefined()
|
||||
expect(requests).toBe(0)
|
||||
})
|
||||
|
||||
test("RPC declared HTTP failures propagate", async () => {
|
||||
await expect(
|
||||
http(() => Response.json({ _tag: "UnauthorizedError", message: "Denied" }, { status: 401 }))
|
||||
.rpc(Echo)
|
||||
.echo("hello"),
|
||||
).rejects.toMatchObject({ _tag: "UnauthorizedError", message: "Denied" })
|
||||
})
|
||||
|
||||
test("RPC method failures remove the generic transport wrapper", async () => {
|
||||
const response = { _tag: "RpcError", type: "rejected", message: "Rejected", data: { reason: "busy" } }
|
||||
const client = http(() => Response.json(response, { status: 400 }))
|
||||
const error = await client.rpc(Echo).echo("hello").catch((error: unknown) => error)
|
||||
|
||||
expect(error).toEqual({ type: "rejected", message: "Rejected", data: { reason: "busy" } })
|
||||
await expect(client.rpc.call({ rpcID: Echo.id, method: "echo", input: "hello" })).rejects.toEqual(response)
|
||||
})
|
||||
|
||||
test("RPC transport failures remove the generic transport wrapper", async () => {
|
||||
const response = { _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" }
|
||||
await expect(http(() => Response.json(response, { status: 500 })).rpc(Echo).echo("hello")).rejects.toEqual({
|
||||
type: "rpc.internal",
|
||||
message: "Failed",
|
||||
})
|
||||
})
|
||||
|
||||
test("native events and multiple RPC clients share one lazy source across locations", async () => {
|
||||
const source = events()
|
||||
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
|
||||
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
const second = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
const otherDefinition = Rpc.define({ ...Echo, id: "other" })
|
||||
const other = source.client.rpc(otherDefinition).events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
expect(source.requests).toHaveLength(0)
|
||||
const firstNext = first.next()
|
||||
const secondNext = second.next()
|
||||
const otherNext = other.next()
|
||||
expect(await native.next()).toEqual({ done: false, value: connected })
|
||||
expect(source.requests).toHaveLength(1)
|
||||
expect(source.requests[0].headers.get("authorization")).toBe("Bearer events")
|
||||
const late = source.client.event.subscribe()[Symbol.asyncIterator]()
|
||||
expect(await late.next()).toEqual({ done: false, value: connected })
|
||||
await Promise.all([native.return?.(), late.return?.()])
|
||||
await source.send(rpcEvent({ ignored: true }, "/first", Echo.id, "unknown"))
|
||||
await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.id))
|
||||
expect((await otherNext).value).toMatchObject({
|
||||
type: "rpc.other.updated",
|
||||
location: { directory: "/other" },
|
||||
data: { count: 9 },
|
||||
})
|
||||
await other.return?.()
|
||||
await source.send(rpcEvent({ count: 42 }))
|
||||
const expected = {
|
||||
id: "evt_rpc",
|
||||
created: 10,
|
||||
type: `rpc.${Echo.id}.updated`,
|
||||
location: { directory: "/first" },
|
||||
metadata: { source: "test" },
|
||||
data: { count: 42 },
|
||||
}
|
||||
expect(await firstNext).toEqual({ done: false, value: expected })
|
||||
expect(await secondNext).toEqual({ done: false, value: expected })
|
||||
const next = first.next()
|
||||
await source.send(rpcEvent({ count: 43 }, "/second"))
|
||||
expect((await next).value).toMatchObject({ location: { directory: "/second" }, data: { count: 43 } })
|
||||
await Promise.all([first.return?.(), second.return?.()])
|
||||
await source.cancelled
|
||||
expect(source.requests[0].signal.aborted).toBe(true)
|
||||
expect(source.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("RPC iterator return and abort cancel only their pending subscribers", async () => {
|
||||
const source = events()
|
||||
const controller = new AbortController()
|
||||
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
const secondEvents = source.client.rpc(Echo).events.subscribe("updated", { signal: controller.signal })
|
||||
const second = secondEvents[Symbol.asyncIterator]()
|
||||
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
|
||||
const firstNext = first.next()
|
||||
const secondNext = second.next()
|
||||
await native.next()
|
||||
expect((await first.return?.())?.done).toBe(true)
|
||||
expect((await firstNext).done).toBe(true)
|
||||
expect(source.requests[0].signal.aborted).toBe(false)
|
||||
controller.abort()
|
||||
expect((await secondNext).done).toBe(true)
|
||||
expect(source.requests[0].signal.aborted).toBe(false)
|
||||
const nativeNext = native.next()
|
||||
const event = rpcEvent({ count: 42 })
|
||||
await source.send(event)
|
||||
expect(await nativeNext).toEqual({ done: false, value: event })
|
||||
await native.return?.()
|
||||
await source.cancelled
|
||||
})
|
||||
|
||||
test("RPC callback subscriptions unsubscribe independently", async () => {
|
||||
const source = events()
|
||||
const received = Promise.withResolvers<unknown>()
|
||||
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
|
||||
await native.next()
|
||||
const unsubscribe = source.client.rpc(Echo).events.on("updated", received.resolve)
|
||||
await source.send(rpcEvent({ count: 42 }))
|
||||
expect(await received.promise).toMatchObject({ data: { count: 42 }, type: `rpc.${Echo.id}.updated` })
|
||||
unsubscribe()
|
||||
unsubscribe()
|
||||
expect(source.requests[0].signal.aborted).toBe(false)
|
||||
await native.return?.()
|
||||
await source.cancelled
|
||||
})
|
||||
|
||||
test("RPC async callback failures stop only that listener and are not unhandled", async () => {
|
||||
const source = events()
|
||||
const client = source.client.rpc(Echo)
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const failed: number[] = []
|
||||
cleanup.add(release.resolve)
|
||||
cleanup.add(
|
||||
client.events.on("updated", async (event) => {
|
||||
failed.push(event.data.count)
|
||||
started.resolve()
|
||||
await release.promise
|
||||
throw new Error("Expected async RPC callback failure")
|
||||
}),
|
||||
)
|
||||
const healthy = client.events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
const first = healthy.next()
|
||||
await source.send(rpcEvent({ count: 1 }))
|
||||
await started.promise
|
||||
expect((await first).value.data.count).toBe(1)
|
||||
const second = healthy.next()
|
||||
await source.send(rpcEvent({ count: 2 }))
|
||||
expect((await second).value.data.count).toBe(2)
|
||||
expect(failed).toEqual([1])
|
||||
release.resolve()
|
||||
await healthy.return?.()
|
||||
await source.cancelled
|
||||
expect(failed).toEqual([1])
|
||||
})
|
||||
|
||||
test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", async () => {
|
||||
const source = events()
|
||||
const broad: Rpc.PortableDefinition = Echo
|
||||
expect(() => source.client.rpc(broad).events.subscribe("unknown")).toThrow("Unknown RPC event")
|
||||
expect(() => source.client.rpc(broad).events.subscribe("toString")).toThrow("Unknown RPC event")
|
||||
expect(() => source.client.rpc(broad).events.on("unknown", () => {})).toThrow("Unknown RPC event")
|
||||
const aborted = source.client.rpc(Echo).events.subscribe("updated", { signal: AbortSignal.abort() })
|
||||
const iterator = aborted[Symbol.asyncIterator]()
|
||||
expect((await iterator.next()).done).toBe(true)
|
||||
expect(source.requests).toHaveLength(0)
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SharedEvents } from "../src/shared-events"
|
||||
|
||||
type Event = { readonly type: string; readonly value?: number }
|
||||
|
||||
function source(cleanup?: Promise<void>) {
|
||||
const connections: {
|
||||
signal: AbortSignal
|
||||
push: (event: Event) => void
|
||||
close: () => void
|
||||
fail: (error: unknown) => void
|
||||
closing: Promise<void>
|
||||
closed: Promise<void>
|
||||
}[] = []
|
||||
const opened: ReturnType<typeof Promise.withResolvers<void>>[] = []
|
||||
|
||||
return {
|
||||
connections,
|
||||
async at(index: number) {
|
||||
if (!connections[index]) await (opened[index] ??= Promise.withResolvers<void>()).promise
|
||||
return connections[index]
|
||||
},
|
||||
connect(signal: AbortSignal): AsyncIterable<Event> {
|
||||
let controller!: ReadableStreamDefaultController<Event>
|
||||
let ended = false
|
||||
const closing = Promise.withResolvers<void>()
|
||||
const closed = Promise.withResolvers<void>()
|
||||
const stream = new ReadableStream<Event>({
|
||||
start(value) {
|
||||
controller = value
|
||||
},
|
||||
})
|
||||
const close = () => {
|
||||
if (ended) return
|
||||
ended = true
|
||||
controller.close()
|
||||
}
|
||||
signal.addEventListener("abort", close, { once: true })
|
||||
connections.push({
|
||||
signal,
|
||||
push: (event) => controller.enqueue(event),
|
||||
close,
|
||||
fail(error) {
|
||||
ended = true
|
||||
controller.error(error)
|
||||
},
|
||||
closing: closing.promise,
|
||||
closed: closed.promise,
|
||||
})
|
||||
opened[connections.length - 1]?.resolve()
|
||||
|
||||
return (async function* () {
|
||||
try {
|
||||
yield* stream
|
||||
} finally {
|
||||
signal.removeEventListener("abort", close)
|
||||
closing.resolve()
|
||||
await cleanup
|
||||
closed.resolve()
|
||||
}
|
||||
})()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("creation, subscription, and idle iterators are lazy", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const iterable = shared.subscribe()
|
||||
const idle = iterable[Symbol.asyncIterator]()
|
||||
expect(events.connections).toHaveLength(0)
|
||||
expect(await idle.return!()).toEqual({ done: true, value: undefined })
|
||||
expect(await idle.next()).toEqual({ done: true, value: undefined })
|
||||
expect(events.connections).toHaveLength(0)
|
||||
|
||||
const active = iterable[Symbol.asyncIterator]()
|
||||
const next = active.next()
|
||||
expect(events.connections).toHaveLength(1)
|
||||
events.connections[0].push({ type: "server.connected" })
|
||||
expect(await next).toEqual({ done: false, value: { type: "server.connected" } })
|
||||
await active.return!()
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("pre-aborted subscribers do not open a source", async () => {
|
||||
const events = source()
|
||||
const controller = new AbortController()
|
||||
const iterator = SharedEvents.make(events.connect).subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
controller.abort()
|
||||
expect(await iterator.next()).toEqual({ done: true, value: undefined })
|
||||
expect(events.connections).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple consumers share one source and receive live native and RPC events", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const first = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const second = shared.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
for (const event of [{ type: "server.connected" }, { type: "session.updated" }, { type: "rpc.example.updated", value: 1 }]) {
|
||||
const reads = [first.next(), second.next()]
|
||||
events.connections[0].push(event)
|
||||
expect(await Promise.all(reads)).toEqual([
|
||||
{ done: false, value: event },
|
||||
{ done: false, value: event },
|
||||
])
|
||||
}
|
||||
expect(events.connections).toHaveLength(1)
|
||||
await first.return!()
|
||||
expect(events.connections[0].signal.aborted).toBe(false)
|
||||
const next = second.next()
|
||||
events.connections[0].push({ type: "rpc.example.updated", value: 2 })
|
||||
expect((await next).value).toEqual({ type: "rpc.example.updated", value: 2 })
|
||||
await second.return!()
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("late consumers receive the latest connection marker but no business event replay", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const first = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const idle = shared.subscribe()[Symbol.asyncIterator]()
|
||||
for (const event of [
|
||||
{ type: "server.connected", value: 1 },
|
||||
{ type: "server.connected", value: 2 },
|
||||
{ type: "rpc.example.updated", value: 3 },
|
||||
]) {
|
||||
const next = first.next()
|
||||
events.connections[0].push(event)
|
||||
await next
|
||||
}
|
||||
|
||||
expect(await idle.next()).toEqual({ done: false, value: { type: "server.connected", value: 2 } })
|
||||
const next = idle.next()
|
||||
events.connections[0].push({ type: "rpc.example.updated", value: 4 })
|
||||
expect(await next).toEqual({ done: false, value: { type: "rpc.example.updated", value: 4 } })
|
||||
expect(events.connections).toHaveLength(1)
|
||||
await first.return!()
|
||||
await idle.return!()
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("abort removes only its subscriber; last return closes the native source and resolves pending reads", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const controller = new AbortController()
|
||||
const first = shared.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
const second = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const firstRead = first.next()
|
||||
const secondReads = [second.next(), second.next()]
|
||||
controller.abort()
|
||||
expect(await firstRead).toEqual({ done: true, value: undefined })
|
||||
expect(await first.next()).toEqual({ done: true, value: undefined })
|
||||
expect(events.connections[0].signal.aborted).toBe(false)
|
||||
|
||||
await second.return!()
|
||||
expect(await Promise.all(secondReads)).toEqual([
|
||||
{ done: true, value: undefined },
|
||||
{ done: true, value: undefined },
|
||||
])
|
||||
expect(events.connections[0].signal.aborted).toBe(true)
|
||||
await events.connections[0].closed
|
||||
expect(await second.next()).toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
test("breaking a native for-await loop closes the last source", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const consumed = (async () => {
|
||||
for await (const event of shared.subscribe()) {
|
||||
expect(event.type).toBe("server.connected")
|
||||
break
|
||||
}
|
||||
})()
|
||||
events.connections[0].push({ type: "server.connected" })
|
||||
await consumed
|
||||
expect(events.connections[0].signal.aborted).toBe(true)
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("rapid resubscription opens a replacement while old cleanup finishes", async () => {
|
||||
const cleanup = Promise.withResolvers<void>()
|
||||
const events = source(cleanup.promise)
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const first = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const firstRead = first.next()
|
||||
events.connections[0].push({ type: "server.connected", value: 1 })
|
||||
await firstRead
|
||||
await first.return!()
|
||||
await events.connections[0].closing
|
||||
|
||||
const second = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const third = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const secondRead = second.next()
|
||||
const thirdRead = third.next()
|
||||
const controller = new AbortController()
|
||||
const cancelled = shared.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
const cancelledRead = cancelled.next()
|
||||
controller.abort()
|
||||
expect(await cancelledRead).toEqual({ done: true, value: undefined })
|
||||
expect(events.connections).toHaveLength(2)
|
||||
|
||||
const replacement = await events.at(1)
|
||||
replacement.push({ type: "server.connected", value: 2 })
|
||||
expect(await Promise.all([secondRead, thirdRead])).toEqual([
|
||||
{ done: false, value: { type: "server.connected", value: 2 } },
|
||||
{ done: false, value: { type: "server.connected", value: 2 } },
|
||||
])
|
||||
cleanup.resolve()
|
||||
await events.connections[0].closed
|
||||
await second.return!()
|
||||
await third.return!()
|
||||
await replacement.closed
|
||||
})
|
||||
|
||||
test("source EOF finishes all consumers and permits a fresh subscription without retry", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const first = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const second = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const reads = [first.next(), second.next()]
|
||||
events.connections[0].push({ type: "server.connected", value: 1 })
|
||||
await Promise.all(reads)
|
||||
const nextReads = [first.next(), second.next()]
|
||||
events.connections[0].push({ type: "rpc.example.updated", value: 2 })
|
||||
expect(await Promise.all(nextReads)).toEqual([
|
||||
{ done: false, value: { type: "rpc.example.updated", value: 2 } },
|
||||
{ done: false, value: { type: "rpc.example.updated", value: 2 } },
|
||||
])
|
||||
events.connections[0].close()
|
||||
await events.connections[0].closed
|
||||
expect(await first.next()).toEqual({ done: true, value: undefined })
|
||||
expect(await second.next()).toEqual({ done: true, value: undefined })
|
||||
expect(events.connections).toHaveLength(1)
|
||||
|
||||
const fresh = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const next = fresh.next()
|
||||
const replacement = await events.at(1)
|
||||
replacement.push({ type: "server.connected", value: 3 })
|
||||
expect(await next).toEqual({ done: false, value: { type: "server.connected", value: 3 } })
|
||||
await fresh.return!()
|
||||
await replacement.closed
|
||||
})
|
||||
|
||||
test("source failures preserve error identity for every consumer and permit a new subscription", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const first = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const second = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const failure = { reason: "actual source failure" }
|
||||
const reads = Promise.allSettled([first.next(), second.next()])
|
||||
events.connections[0].fail(failure)
|
||||
expect(await reads).toEqual([
|
||||
{ status: "rejected", reason: failure },
|
||||
{ status: "rejected", reason: failure },
|
||||
])
|
||||
await expect(first.next()).rejects.toBe(failure)
|
||||
expect(events.connections).toHaveLength(1)
|
||||
|
||||
const fresh = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const next = fresh.next()
|
||||
const replacement = await events.at(1)
|
||||
replacement.push({ type: "server.connected" })
|
||||
expect(await next).toEqual({ done: false, value: { type: "server.connected" } })
|
||||
await fresh.return!()
|
||||
await replacement.closed
|
||||
})
|
||||
|
||||
test("synchronous source creation failures reject subscribers without automatic retry", async () => {
|
||||
const failure = new Error("connect failed")
|
||||
const attempts: AbortSignal[] = []
|
||||
const shared = SharedEvents.make<Event>((signal) => {
|
||||
attempts.push(signal)
|
||||
throw failure
|
||||
})
|
||||
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(attempts[0].aborted).toBe(true)
|
||||
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
|
||||
expect(attempts).toHaveLength(2)
|
||||
})
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -41,7 +41,7 @@ export const layer = Layer.effect(
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// Configured local plugin entrypoints can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
@@ -55,9 +55,6 @@ export const layer = Layer.effect(
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
@@ -144,8 +141,22 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
const resolved = yield* Effect.forEach(configured, (operation) =>
|
||||
Effect.gen(function* () {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Option.some(operation)
|
||||
if (yield* fs.isFile(operation.target)) {
|
||||
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
}
|
||||
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
|
||||
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
|
||||
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
|
||||
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
}),
|
||||
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { Rpc } from "./rpc.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
@@ -62,6 +63,7 @@ const nodes = [
|
||||
Agent.node,
|
||||
Command.node,
|
||||
Reference.node,
|
||||
Rpc.node,
|
||||
WebSearch.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
|
||||
@@ -338,6 +338,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@ai-sdk/openai-compatible" ||
|
||||
name === "@ai-sdk/google" ||
|
||||
name === "@ai-sdk/groq" ||
|
||||
name === "@ai-sdk/mistral" ||
|
||||
name === "@ai-sdk/togetherai" ||
|
||||
name === "@ai-sdk/xai" ||
|
||||
name === "@openrouter/ai-sdk-provider" ||
|
||||
@@ -351,6 +352,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/groq" ||
|
||||
name === "@opencode-ai/ai/providers/mistral" ||
|
||||
name === "@opencode-ai/ai/providers/togetherai" ||
|
||||
name === "@opencode-ai/ai/providers/xai" ||
|
||||
name === "@opencode-ai/ai/providers/openrouter" ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info, Source, State } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -19,6 +19,7 @@ import { PluginHost } from "./plugin/host.js"
|
||||
import { PluginRuntime } from "./plugin/runtime.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { Rpc } from "./rpc.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import { State } from "./state.js"
|
||||
import { Tool } from "./tool.js"
|
||||
@@ -30,14 +31,17 @@ import { Permission } from "./permission.js"
|
||||
export interface Interface {
|
||||
readonly activate: (
|
||||
plugins: readonly Versioned[],
|
||||
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
|
||||
failures?: readonly Failure[],
|
||||
) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Plugin.Info[]>
|
||||
}
|
||||
|
||||
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
|
||||
|
||||
export type Versioned = PluginDefinition & {
|
||||
readonly version: string
|
||||
readonly source?: Plugin.Source
|
||||
readonly features?: Plugin.Features
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
@@ -80,7 +84,7 @@ const layer = Layer.effect(
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
plugins: readonly Versioned[],
|
||||
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
|
||||
failures: readonly Failure[] = [],
|
||||
) {
|
||||
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
|
||||
const ids = new Set<Plugin.ID>()
|
||||
@@ -122,9 +126,8 @@ const layer = Layer.effect(
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
status: "failed",
|
||||
error: loaded.error,
|
||||
tui: definition.tui ?? false,
|
||||
state: { status: "failed", error: loaded.error },
|
||||
features: { server: true, ...definition.features },
|
||||
})
|
||||
|
||||
if (!previous) continue
|
||||
@@ -175,8 +178,8 @@ function activeInfo(plugin: Versioned): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
status: "active",
|
||||
tui: plugin.tui ?? false,
|
||||
state: { status: "active" },
|
||||
features: { server: true, ...plugin.features },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +198,7 @@ export const node = makeLocationNode({
|
||||
Mcp.node,
|
||||
Location.node,
|
||||
Reference.node,
|
||||
Rpc.node,
|
||||
Skill.node,
|
||||
Tool.node,
|
||||
Vcs.node,
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as PluginHost from "./host.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import type { Event } from "@opencode-ai/schema/event"
|
||||
import { ServerConfig } from "@opencode-ai/schema/mcp"
|
||||
import { App } from "../app.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
@@ -20,6 +21,7 @@ import { Mcp } from "../mcp/index.js"
|
||||
import { PluginRuntime } from "./runtime.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Reference } from "../reference.js"
|
||||
import { Rpc } from "../rpc.js"
|
||||
import { AbsolutePath, type DeepMutable } from "../schema.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import { Tool } from "../tool.js"
|
||||
@@ -32,6 +34,12 @@ import { PluginHooks } from "./hooks.js"
|
||||
import type { Interface } from "../plugin.js"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
type RpcEvent = Event.Payload & {
|
||||
readonly type: `rpc.${string}`
|
||||
readonly location: Location.Ref
|
||||
readonly data: Readonly<Record<string, unknown>>
|
||||
}
|
||||
const isRpcEvent = (event: Event.Payload): event is RpcEvent => event.type.startsWith("rpc.")
|
||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, pluginID: string = "test") {
|
||||
const app = yield* App.Metadata
|
||||
const agents = yield* Agent.Service
|
||||
@@ -44,6 +52,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
const mcp = yield* Mcp.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const skill = yield* Skill.Service
|
||||
const tools = yield* Tool.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
@@ -75,6 +84,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
app,
|
||||
location: locationInfo(),
|
||||
options: {},
|
||||
rpc: Object.assign(rpc.client, { register: rpc.register }),
|
||||
agent: {
|
||||
get: (input) => {
|
||||
const ref = locationRef(input)
|
||||
@@ -191,7 +201,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
transform: commands.transform,
|
||||
},
|
||||
event: {
|
||||
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||
subscribe: () =>
|
||||
bus
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter(
|
||||
(event): event is EventManifest.ServerEvent | RpcEvent => EventManifest.isServer(event) || isRpcEvent(event),
|
||||
),
|
||||
),
|
||||
},
|
||||
experimental: {
|
||||
terminal: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -14,18 +15,15 @@ const Discovery = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
markers: Schema.Array(Schema.String),
|
||||
})
|
||||
|
||||
const Definition = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
tui: Schema.optional(Schema.Boolean),
|
||||
vcs: Schema.optional(Discovery),
|
||||
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
tui: Schema.optional(Schema.Boolean),
|
||||
vcs: Schema.optional(Discovery),
|
||||
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
|
||||
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
|
||||
@@ -38,9 +36,11 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
const local = path.isAbsolute(operation.target)
|
||||
const installed = local
|
||||
? { entrypoint: pathToFileURL(operation.target).href }
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const entrypoint = installed.entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
@@ -49,9 +49,20 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const mod = yield* Effect.promise(() => importModule(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
const features = local
|
||||
? yield* localFeatures(operation.target)
|
||||
: yield* Effect.all({
|
||||
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
|
||||
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
|
||||
}).pipe(
|
||||
Effect.map((resolved) => ({
|
||||
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
|
||||
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
|
||||
})),
|
||||
)
|
||||
return {
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
features,
|
||||
vcs: plugin.vcs,
|
||||
version: JSON.stringify(operation),
|
||||
source: path.isAbsolute(operation.target)
|
||||
@@ -60,3 +71,20 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Versioned
|
||||
})
|
||||
|
||||
function localFeatures(entrypoint: string) {
|
||||
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
|
||||
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
|
||||
Effect.map((entries) => {
|
||||
const names = new Set(entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name))
|
||||
const has = (name: string) =>
|
||||
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) =>
|
||||
names.has(`${name}.${extension}`),
|
||||
)
|
||||
return {
|
||||
...(has("tui") ? { tui: true as const } : {}),
|
||||
...(has("rpc") ? { rpc: true as const } : {}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
export * as PluginSourceDirectory from "./source-directory.js"
|
||||
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect, Option, Predicate, Schema } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import path from "path"
|
||||
|
||||
export const names = ["plugin", "plugins"] as const
|
||||
|
||||
const Package = Schema.Struct({
|
||||
exports: Schema.optional(Schema.Unknown),
|
||||
module: Schema.optional(Schema.Unknown),
|
||||
main: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
const decodePackage = Schema.decodeUnknownOption(Package)
|
||||
|
||||
export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
directory: string,
|
||||
@@ -29,30 +22,21 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
|
||||
Effect.gen(function* () {
|
||||
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
|
||||
if (entry.type === "file" && source) return Option.some(entry.target)
|
||||
if (entry.type === "directory") return yield* packageEntry(fs, entry.target)
|
||||
if (entry.type === "directory") return yield* entrypoint(fs, entry.target)
|
||||
if (entry.type !== "symlink") return Option.none<string>()
|
||||
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return yield* packageEntry(fs, entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return yield* entrypoint(fs, entry.target)
|
||||
return Option.none<string>()
|
||||
}),
|
||||
)
|
||||
return targets.flatMap(Option.toArray)
|
||||
})
|
||||
|
||||
function packageEntry(fs: FSUtil.Interface, directory: string) {
|
||||
export function entrypoint(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* fs.resolve(directory)
|
||||
const manifest = yield* fs
|
||||
.readJson(path.join(directory, "package.json"))
|
||||
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
|
||||
const configured = Option.isSome(manifest)
|
||||
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
|
||||
: []
|
||||
return yield* Effect.findFirst(
|
||||
[...configured, "index.ts", "index.js"]
|
||||
.filter((entry) => !path.isAbsolute(entry))
|
||||
.map((entry) => path.resolve(directory, entry))
|
||||
.filter((entry) => FSUtil.contains(directory, entry)),
|
||||
["index.ts", "index.js"].map((entry) => path.join(directory, entry)),
|
||||
(entry) =>
|
||||
fs
|
||||
.isFile(entry)
|
||||
|
||||
@@ -25,7 +25,10 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
const definitions = [...pre, ...post]
|
||||
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
||||
const packages = new Map<string, Plugin.Versioned>()
|
||||
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
|
||||
const failures = new Map<
|
||||
string,
|
||||
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
|
||||
>()
|
||||
const plugins = () => [...definitions, ...packages.values()]
|
||||
|
||||
for (const operation of operations) {
|
||||
@@ -58,9 +61,8 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
if ("error" in plugin) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
status: "failed",
|
||||
error: plugin.error,
|
||||
tui: false,
|
||||
state: { status: "failed", error: plugin.error },
|
||||
features: { server: true },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
export * as Rpc from "./rpc.js"
|
||||
export { define } from "@opencode-ai/schema/rpc"
|
||||
export type { Definition, EventPayload, Failure } from "@opencode-ai/schema/rpc"
|
||||
|
||||
import type { RpcClient, RpcDomain, RpcHandlers } from "@opencode-ai/plugin/effect/rpc"
|
||||
import type { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, JsonSchema, Layer, Schema, SchemaRepresentation, Stream } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
import { optional, statics } from "./schema.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly register: RpcDomain["register"]
|
||||
readonly client: <D extends Rpc.Definition>(definition: D) => RpcClient<D, Rpc.SystemError, never, unknown>
|
||||
readonly call: (rpcID: string, method: string, input: unknown) => Effect.Effect<unknown, Rpc.Failure>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Rpc") {}
|
||||
|
||||
class DeclaredError extends Error {
|
||||
constructor(
|
||||
readonly type: string,
|
||||
message: string,
|
||||
readonly data?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const ref = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID })
|
||||
const callContext = {
|
||||
error: (type: string, message: string, data?: unknown) => new DeclaredError(type, message, data),
|
||||
}
|
||||
const registrations = new Map<
|
||||
string,
|
||||
Array<{
|
||||
readonly definition: Rpc.Definition
|
||||
readonly handlers: Readonly<Record<string, Function>>
|
||||
}>
|
||||
>()
|
||||
const definitions = new WeakMap<
|
||||
Rpc.Definition,
|
||||
ReadonlyMap<string, { readonly event: Rpc.EventDefinition; readonly definition: Event.Definition }>
|
||||
>()
|
||||
const eventsFor = (definition: Rpc.Definition) => {
|
||||
const existing = definitions.get(definition)
|
||||
if (existing) return existing
|
||||
const events = new Map(
|
||||
Object.entries(definition.events).map(([name, event]) => [
|
||||
name,
|
||||
{ event, definition: eventDefinition(definition, name) },
|
||||
]),
|
||||
)
|
||||
definitions.set(definition, events)
|
||||
return events
|
||||
}
|
||||
|
||||
const register = Effect.fn("Rpc.register")(function* <const D extends Rpc.Definition>(
|
||||
definition: D,
|
||||
handlers: RpcHandlers<NoInfer<D>>,
|
||||
) {
|
||||
const entry = { definition, handlers }
|
||||
const dispose = Effect.sync(() => {
|
||||
const remaining = (registrations.get(definition.id) ?? []).filter((candidate) => candidate !== entry)
|
||||
if (remaining.length === 0) {
|
||||
registrations.delete(definition.id)
|
||||
return
|
||||
}
|
||||
registrations.set(definition.id, remaining)
|
||||
})
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
registrations.set(definition.id, [...(registrations.get(definition.id) ?? []), entry]),
|
||||
),
|
||||
() => dispose,
|
||||
)
|
||||
|
||||
const events = eventsFor(definition)
|
||||
return {
|
||||
dispose,
|
||||
events: {
|
||||
emit: Effect.fn("Rpc.emit")(function* (...args: Rpc.EventInput<D>) {
|
||||
const registered = events.get(args[0])
|
||||
if (!registered)
|
||||
return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.id}.${args[0]}`))
|
||||
const event = registered.event
|
||||
// SAFETY: The public event-schema contract guarantees an object encoded/output type.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
const data = (yield* encode(event.schema, args[1])) as Readonly<Record<string, unknown>>
|
||||
return yield* bus
|
||||
.publish(registered.definition, data, {
|
||||
location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }),
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const call = Effect.fn("Rpc.call")(function* (rpcID: string, name: string, input: unknown) {
|
||||
const entry = registrations.get(rpcID)?.at(-1)
|
||||
if (!entry)
|
||||
return yield* Effect.fail(failure("rpc.unavailable", `RPC is unavailable: ${rpcID}`))
|
||||
if (!Object.hasOwn(entry.definition.methods, name) || !Object.hasOwn(entry.handlers, name))
|
||||
return yield* Effect.fail(failure("rpc.method_not_found", `Unknown RPC method: ${rpcID}.${name}`))
|
||||
const method = entry.definition.methods[name]
|
||||
const handler = entry.handlers[name]
|
||||
const parsed = yield* parse(method.input, input).pipe(
|
||||
Effect.mapError((error) => failure("rpc.invalid_input", errorMessage(error, "Invalid RPC input"))),
|
||||
)
|
||||
const result = yield* Effect.suspend(() => {
|
||||
// The heterogeneous registry erases handlers after their selected schema validates input.
|
||||
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
|
||||
return execution
|
||||
}).pipe(Effect.catch((error) => encodeError(method, error)))
|
||||
return yield* encode(method.output, result).pipe(
|
||||
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
|
||||
)
|
||||
})
|
||||
|
||||
const client = <D extends Rpc.Definition>(definition: D): RpcClient<D, Rpc.SystemError, never, unknown> => {
|
||||
const events = eventsFor(definition)
|
||||
const methods = Object.fromEntries(
|
||||
Object.entries(definition.methods).map(([name, method]) => [
|
||||
name,
|
||||
(input: unknown) =>
|
||||
call(definition.id, name, input).pipe(
|
||||
Effect.catch((error) => decodeError(method, error)),
|
||||
Effect.flatMap((value) => read(method.output, value).pipe(Effect.catch((cause) => Effect.die(cause)))),
|
||||
),
|
||||
]),
|
||||
)
|
||||
// SAFETY: Every runtime key comes from this definition, and each method delegates through its corresponding schema.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
...methods,
|
||||
events: {
|
||||
subscribe: <Name extends keyof D["events"] & string>(name: Name) => {
|
||||
const registered = events.get(name)
|
||||
if (!registered) return Stream.fail(new Error(`Unknown RPC event: ${definition.id}.${name}`))
|
||||
return bus.subscribe(registered.definition).pipe(
|
||||
Stream.provideService(Location.Service, location),
|
||||
Stream.mapEffect((payload) => logicalEvent(definition, name, payload, ref)),
|
||||
)
|
||||
},
|
||||
},
|
||||
} as RpcClient<D, Rpc.SystemError, never, unknown>
|
||||
}
|
||||
|
||||
return Service.of({ register, call, client })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Location.node] })
|
||||
|
||||
const fields = {
|
||||
id: Event.ID,
|
||||
created: Schema.Finite,
|
||||
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
location: optional(Location.Ref),
|
||||
}
|
||||
const EventData = Schema.Record(Schema.String, Schema.Unknown)
|
||||
const jsonSchemas = new WeakMap<JsonSchema.JsonSchema, Schema.Codec<unknown>>()
|
||||
|
||||
function eventType<const D extends Rpc.Definition, const Name extends keyof D["events"] & string>(
|
||||
definition: D,
|
||||
name: Name,
|
||||
): `rpc.${D["id"]}.${Name}` {
|
||||
return `rpc.${definition.id}.${name}`
|
||||
}
|
||||
|
||||
function eventDefinition(definition: Rpc.Definition, name: string): Event.Definition {
|
||||
const type = eventType(definition, name)
|
||||
const data = EventData
|
||||
return Schema.Struct({ ...fields, type: Schema.Literal(type), data }).pipe(
|
||||
statics(() => ({ type, durability: "ephemeral" as const, durable: undefined, data })),
|
||||
) satisfies Event.EphemeralDefinition<string, typeof data>
|
||||
}
|
||||
|
||||
function parse(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
|
||||
if (Schema.isSchema(schema)) return Schema.decodeUnknownEffect(schema)(value)
|
||||
if (isStandardSchema(schema)) {
|
||||
return Effect.gen(function* () {
|
||||
const parsed = yield* Effect.try({ try: () => schema["~standard"].validate(value), catch: (cause) => cause })
|
||||
const result =
|
||||
parsed instanceof Promise ? yield* Effect.tryPromise({ try: () => parsed, catch: (cause) => cause }) : parsed
|
||||
if (result.issues) return yield* Effect.fail(new Error(result.issues.map((issue) => issue.message).join("\n")))
|
||||
return result.value
|
||||
})
|
||||
}
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const existing = jsonSchemas.get(schema)
|
||||
if (existing) return existing
|
||||
const codec = Schema.make<Schema.Codec<unknown>>(
|
||||
SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema)).ast,
|
||||
)
|
||||
jsonSchemas.set(schema, codec)
|
||||
return codec
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
}).pipe(Effect.flatMap((codec) => Schema.decodeUnknownEffect(codec)(value)))
|
||||
}
|
||||
|
||||
function encode(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
|
||||
return Schema.isSchema(schema) ? Schema.encodeUnknownEffect(schema)(value) : parse(schema, value)
|
||||
}
|
||||
|
||||
function encodeError(method: Rpc.Method, error: unknown): Effect.Effect<never, Rpc.Failure> {
|
||||
if (!(error instanceof DeclaredError)) return Effect.die(error)
|
||||
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
|
||||
return Effect.die(new Error(`Undeclared RPC error: ${error.type}`))
|
||||
}
|
||||
return encode(method.errors[error.type], error.data).pipe(
|
||||
Effect.catch((cause) => Effect.die(cause)),
|
||||
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
|
||||
)
|
||||
}
|
||||
|
||||
function decodeError(method: Rpc.Method, error: Rpc.Failure): Effect.Effect<never, Rpc.Failure> {
|
||||
if (!method.errors || !Object.hasOwn(method.errors, error.type)) return Effect.fail(error)
|
||||
return read(method.errors[error.type], error.data).pipe(
|
||||
Effect.catch((cause) => Effect.die(cause)),
|
||||
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
|
||||
)
|
||||
}
|
||||
|
||||
function failure(type: string, message: string, data?: unknown): Rpc.Failure {
|
||||
return data === undefined ? { type, message } : { type, message, data }
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "string") return error
|
||||
return fallback
|
||||
}
|
||||
|
||||
function isStandardSchema(schema: Tool.ValueSchema): schema is Extract<Tool.ValueSchema, StandardSchemaV1> {
|
||||
return "~standard" in schema
|
||||
}
|
||||
|
||||
function read(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
|
||||
// Standard Schema results were already parsed by the publisher; don't apply transforms twice.
|
||||
return Schema.isSchema(schema) ? Schema.decodeUnknownEffect(schema)(value) : Effect.succeed(value)
|
||||
}
|
||||
|
||||
const logicalEvent = Effect.fn("Rpc.logicalEvent")(function* <
|
||||
D extends Rpc.Definition,
|
||||
Name extends keyof D["events"] & string,
|
||||
>(
|
||||
definition: D,
|
||||
name: Name,
|
||||
payload: Event.Payload,
|
||||
ref: Location.Ref,
|
||||
): Effect.fn.Return<Rpc.EventPayload<D, Name>, unknown> {
|
||||
const event = definition.events[name]
|
||||
const data = yield* read(event.schema, payload.data)
|
||||
// SAFETY: The private Bus definition owns the envelope and location.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
...payload,
|
||||
type: eventType(definition, name),
|
||||
data,
|
||||
location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }),
|
||||
} as Rpc.EventPayload<D, Name>
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
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, desc, eq } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
@@ -50,8 +50,6 @@ 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"
|
||||
@@ -202,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>
|
||||
@@ -247,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) {
|
||||
@@ -435,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),
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { RelativePath } from "@opencode-ai/schema/schema"
|
||||
import type { Snapshot } from "@opencode-ai/schema/snapshot"
|
||||
import { Clock, Effect, Iterable } from "effect"
|
||||
import { Effect, Fiber, Iterable } from "effect"
|
||||
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
@@ -130,7 +130,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
readonly ordinal: number
|
||||
readonly values: string[]
|
||||
pending: string
|
||||
publishedAt?: number
|
||||
timer?: Fiber.Fiber<void>
|
||||
state?: Record<string, unknown>
|
||||
}
|
||||
const chunks = new Map<string, Fragment>()
|
||||
@@ -143,36 +143,46 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
chunks.set(id, { ordinal, values: [], pending: "", state })
|
||||
return Effect.succeed(ordinal)
|
||||
})
|
||||
const publishDelta = Effect.fnUntraced(function* (id: string, force = false) {
|
||||
const publishDelta = Effect.fnUntraced(function* (id: string) {
|
||||
if (!delta) return undefined
|
||||
const current = chunks.get(id)
|
||||
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
|
||||
if (!current.pending) return undefined
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (!force && current.publishedAt === undefined) {
|
||||
current.publishedAt = now
|
||||
return undefined
|
||||
}
|
||||
if (!force && current.publishedAt !== undefined && now - current.publishedAt < deltaBatchInterval)
|
||||
return undefined
|
||||
yield* delta(id, current.pending, current.ordinal)
|
||||
const value = current.pending
|
||||
// New chunks can arrive while the timer is publishing this batch.
|
||||
current.pending = ""
|
||||
current.publishedAt = now
|
||||
yield* delta(id, value, current.ordinal)
|
||||
return undefined
|
||||
})
|
||||
}, Effect.uninterruptible)
|
||||
const append = Effect.fnUntraced(function* (id: string, value: string, state?: Record<string, unknown>) {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
|
||||
current.values.push(value)
|
||||
if (delta) current.pending += value
|
||||
if (state !== undefined) current.state = { ...current.state, ...state }
|
||||
yield* publishDelta(id)
|
||||
if (current.pending && !current.timer) {
|
||||
// Own the trailing flush in the provider fiber, even if no more chunks arrive.
|
||||
current.timer = yield* Effect.gen(function* () {
|
||||
while (current.pending) {
|
||||
yield* Effect.sleep(deltaBatchInterval)
|
||||
yield* publishDelta(id)
|
||||
}
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
current.timer = undefined
|
||||
}),
|
||||
),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
return current.ordinal
|
||||
})
|
||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
||||
yield* publishDelta(id, true)
|
||||
if (current.timer) yield* Fiber.interrupt(current.timer)
|
||||
yield* publishDelta(id)
|
||||
yield* ended(
|
||||
id,
|
||||
value ?? current.values.join(""),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,29 +53,58 @@ export const Plugin = {
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
const authorize = (target: LocationMutation.Target, authorizeExternal = true) =>
|
||||
Effect.gen(function* () {
|
||||
if (target.externalDirectory && authorizeExternal)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
})
|
||||
const resource = target.resource
|
||||
const absolute = AbsolutePath.make(target.absolute)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe(
|
||||
const read = (target: LocationMutation.Target) =>
|
||||
reader.read(AbsolutePath.make(target.absolute), target.resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
|
||||
const requested = yield* mutation.resolve({ path: input.path })
|
||||
yield* authorize(requested)
|
||||
const result = yield* read(requested).pipe(
|
||||
Effect.map((content) => ({ content, target: requested, path: input.path })),
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof Environment.NotFound,
|
||||
() => missing(input.path, target.absolute),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const alternate = yield* alternatePath(requested.absolute).pipe(
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!alternate) return yield* missing(input.path, requested.absolute)
|
||||
const target = yield* mutation.resolve({ path: alternate, kind: "file" })
|
||||
// The candidate is a sibling under the external directory already approved above.
|
||||
yield* authorize(target, false)
|
||||
const content = yield* read(target).pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof Environment.NotFound,
|
||||
() => missing(input.path, requested.absolute),
|
||||
),
|
||||
)
|
||||
if (content.type === "list-page") return yield* missing(input.path, requested.absolute)
|
||||
return {
|
||||
content,
|
||||
target,
|
||||
path: join(dirname(input.path), basename(alternate)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
@@ -84,14 +113,14 @@ export const Plugin = {
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
const resolved = yield* fs.resolve(target.absolute)
|
||||
if (result.target.externalDirectory !== undefined) return
|
||||
const resolved = yield* fs.resolve(result.target.absolute)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: content.type === "list-page" ? resolved : dirname(resolved),
|
||||
start: result.content.type === "list-page" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
@@ -104,17 +133,17 @@ export const Plugin = {
|
||||
Effect.catchDefect(() => Effect.void),
|
||||
)
|
||||
if (
|
||||
content.type === "file" &&
|
||||
content.encoding === "base64" &&
|
||||
!ReadToolFileSystem.MEDIA_MIMES.has(content.mime)
|
||||
result.content.type === "file" &&
|
||||
result.content.encoding === "base64" &&
|
||||
!ReadToolFileSystem.MEDIA_MIMES.has(result.content.mime)
|
||||
)
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
return content
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource: result.target.resource }))
|
||||
return { output: result.content, path: result.path }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelContent(input.path, input.offset, output),
|
||||
metadata: { truncated: output.type === "file" ? false : output.truncated },
|
||||
Effect.map((result) => ({
|
||||
output: result.output,
|
||||
content: toModelContent(result.path, input.offset, result.output),
|
||||
metadata: { truncated: result.output.type === "file" ? false : result.output.truncated },
|
||||
})),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof ToolFailure) return error
|
||||
@@ -133,6 +162,15 @@ export const Plugin = {
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const alternatePath = Effect.fn("ReadTool.alternatePath")(function* (absolute: string) {
|
||||
const base = basename(absolute).replace(/[\u00a0\u202f]/g, " ")
|
||||
const matches = (yield* reader.list(AbsolutePath.make(dirname(absolute)))).filter(
|
||||
(entry) => entry.type === "file" && entry.name.replace(/[\u00a0\u202f]/g, " ") === base,
|
||||
)
|
||||
if (matches.length !== 1) return
|
||||
return join(dirname(absolute), matches[0].name)
|
||||
})
|
||||
|
||||
const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
|
||||
const base = basename(input).toLowerCase()
|
||||
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
|
||||
|
||||
@@ -101,6 +101,7 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (path: AbsolutePath) => ReturnType<Files["list"]>
|
||||
readonly read: (
|
||||
path: AbsolutePath,
|
||||
resource: string,
|
||||
@@ -378,7 +379,10 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const environment = yield* Environment.Service
|
||||
return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) })
|
||||
return Service.of({
|
||||
list: environment.files.list,
|
||||
read: (path, resource, page) => read(environment.files, path, resource, page),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("PluginSupervisor config", () => {
|
||||
plugins: [
|
||||
"-*",
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
|
||||
options: { description: "Loaded from config" },
|
||||
},
|
||||
],
|
||||
@@ -121,17 +121,17 @@ describe("PluginSupervisor config", () => {
|
||||
id: Plugin.ID.make("config-promise-plugin"),
|
||||
source: {
|
||||
type: "local",
|
||||
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise/index.ts"),
|
||||
},
|
||||
status: "active",
|
||||
tui: true,
|
||||
state: { status: "active" },
|
||||
features: { server: true, tui: true },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("disables configured plugins by exported ID", () => {
|
||||
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
|
||||
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise")
|
||||
return withLocation(
|
||||
{ plugins: [plugin, "-config-promise-plugin"] },
|
||||
Effect.gen(function* () {
|
||||
@@ -145,7 +145,7 @@ describe("PluginSupervisor config", () => {
|
||||
})
|
||||
|
||||
it.live("does not disable configured plugins by package target", () => {
|
||||
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
|
||||
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise")
|
||||
return withLocation(
|
||||
{ plugins: [plugin, `-${plugin}`] },
|
||||
Effect.gen(function* () {
|
||||
@@ -162,7 +162,7 @@ describe("PluginSupervisor config", () => {
|
||||
plugins: [
|
||||
"-*",
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"),
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect"),
|
||||
options: { description: "Effect plugin from config" },
|
||||
},
|
||||
],
|
||||
@@ -191,9 +191,9 @@ describe("PluginSupervisor config", () => {
|
||||
plugins: [
|
||||
"-*",
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid"),
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
|
||||
options: { description: "Loaded after invalid plugins" },
|
||||
},
|
||||
],
|
||||
@@ -207,13 +207,13 @@ describe("PluginSupervisor config", () => {
|
||||
})
|
||||
expect(output).toEqual([
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts"),
|
||||
])
|
||||
expect(
|
||||
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
|
||||
(yield* plugins.list()).filter((plugin) => plugin.state.status === "failed").map((plugin) => plugin.source),
|
||||
).toEqual([
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts") },
|
||||
])
|
||||
}),
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
@@ -233,35 +233,23 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads auto-discovered plugin package entrypoints in order", () =>
|
||||
it.live("loads conventional auto-discovered plugin entrypoints", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("package-exports")
|
||||
expect(ids).toContain("package-module")
|
||||
expect(ids).toContain("package-main")
|
||||
expect(ids).toContain("package-index")
|
||||
expect(ids).toContain("package-index-ts")
|
||||
expect(ids).toContain("package-index-js")
|
||||
expect(ids).not.toContain("package-custom-entry")
|
||||
}),
|
||||
false,
|
||||
async (directory) => {
|
||||
await Promise.all([
|
||||
writeDiscoveredPackage(directory, "exports", { exports: "./entry.ts" }, { "entry.ts": "package-exports" }),
|
||||
writeDiscoveredPackage(
|
||||
directory,
|
||||
"module",
|
||||
{ exports: "./missing.js", module: "./entry.js" },
|
||||
{ "entry.js": "package-module" },
|
||||
),
|
||||
writeDiscoveredPackage(
|
||||
directory,
|
||||
"main",
|
||||
{ exports: { import: "./missing.js" }, module: "./missing.js", main: "./entry.js" },
|
||||
{ "entry.js": "package-main" },
|
||||
),
|
||||
writeDiscoveredPackage(directory, "index", undefined, { "index.js": "package-index" }),
|
||||
writeDiscoveredPackage(directory, "ts", { "index.ts": "package-index-ts" }),
|
||||
writeDiscoveredPackage(directory, "js", { "index.js": "package-index-js" }),
|
||||
writeDiscoveredPackage(directory, "custom", { "entry.ts": "package-custom-entry" }),
|
||||
])
|
||||
},
|
||||
),
|
||||
@@ -282,21 +270,11 @@ describe("PluginSupervisor config", () => {
|
||||
async (directory) => {
|
||||
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
|
||||
await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint"))
|
||||
await writeDiscoveredPackage(
|
||||
directory,
|
||||
"contained",
|
||||
{ exports: "../../escape.js" },
|
||||
{ "index.js": "contained-fallback" },
|
||||
)
|
||||
await writeDiscoveredPackage(
|
||||
directory,
|
||||
"symlink",
|
||||
{ exports: "./entry.js" },
|
||||
{ "index.js": "symlink-fallback" },
|
||||
)
|
||||
await writeDiscoveredPackage(directory, "contained", { "index.js": "contained-fallback" })
|
||||
await writeDiscoveredPackage(directory, "symlink", { "index.js": "symlink-fallback" })
|
||||
await fs.symlink(
|
||||
path.join(directory, ".opencode", "escape.js"),
|
||||
path.join(directory, ".opencode", "plugins", "symlink", "entry.js"),
|
||||
path.join(directory, ".opencode", "plugins", "symlink", "index.ts"),
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -307,7 +285,7 @@ describe("PluginSupervisor config", () => {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise")] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -365,15 +343,15 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads a configured plugin when its source file changes", () =>
|
||||
it.live("reloads a configured plugin when its entrypoint changes", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-*", "./external/mutable.ts"] },
|
||||
{ plugins: ["-*", "./external"] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* Agent.Service
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const file = path.join(location.directory, "external", "mutable.ts")
|
||||
const file = path.join(location.directory, "external", "index.ts")
|
||||
|
||||
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
|
||||
|
||||
@@ -395,11 +373,22 @@ describe("PluginSupervisor config", () => {
|
||||
// configured-entrypoint watch can observe the edit.
|
||||
const external = path.join(directory, "external")
|
||||
await fs.mkdir(external, { recursive: true })
|
||||
await fs.writeFile(path.join(external, "mutable.ts"), mutablePlugin("first"))
|
||||
await fs.writeFile(path.join(external, "index.ts"), mutablePlugin("first"))
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("skips configured local files", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).not.toContain("config-promise-plugin")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies explicit removals after auto-discovery", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-*"] },
|
||||
@@ -419,8 +408,8 @@ describe("PluginSupervisor config", () => {
|
||||
yield* withLocation(
|
||||
{
|
||||
plugins: [
|
||||
path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/variant-source"),
|
||||
],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
@@ -448,7 +437,7 @@ describe("PluginSupervisor config", () => {
|
||||
it.live("allows variant generation to be disabled", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
|
||||
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source"), "-opencode.variant"],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
@@ -592,13 +581,9 @@ function discoveredPlugin(id: string) {
|
||||
async function writeDiscoveredPackage(
|
||||
directory: string,
|
||||
name: string,
|
||||
manifest: Record<string, unknown> | undefined,
|
||||
files: Record<string, string>,
|
||||
) {
|
||||
const plugin = path.join(directory, ".opencode", "plugins", name)
|
||||
await fs.mkdir(plugin, { recursive: true })
|
||||
await Promise.all([
|
||||
...(manifest ? [fs.writeFile(path.join(plugin, "package.json"), JSON.stringify(manifest))] : []),
|
||||
...Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
|
||||
])
|
||||
await Promise.all(Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))))
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ describe("LocationServiceMap", () => {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }),
|
||||
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect")] }),
|
||||
),
|
||||
)
|
||||
yield* Fiber.join(updated)
|
||||
@@ -561,21 +561,20 @@ describe("LocationServiceMap", () => {
|
||||
fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
|
||||
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing")],
|
||||
}),
|
||||
),
|
||||
)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
|
||||
if ((yield* registry.list()).some((plugin) => plugin.state.status === "failed")) break
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
expect(yield* registry.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("failing-plugin"),
|
||||
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("plugin failed"),
|
||||
tui: false,
|
||||
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing/index.ts") },
|
||||
state: { status: "failed", error: expect.stringContaining("plugin failed") },
|
||||
features: { server: true },
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -268,9 +268,8 @@ describe("Plugin", () => {
|
||||
[
|
||||
{
|
||||
source: { type: "package", package: "broken" },
|
||||
status: "failed",
|
||||
error: "failed to resolve",
|
||||
tui: false,
|
||||
state: { status: "failed", error: "failed to resolve" },
|
||||
features: { server: true },
|
||||
},
|
||||
],
|
||||
)
|
||||
@@ -331,7 +330,27 @@ describe("Plugin", () => {
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: active, source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports activated and discovered plugin features", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.activate([
|
||||
{ id: "rpc-plugin", version: "1", features: { rpc: true }, effect: () => Effect.void },
|
||||
])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("rpc-plugin"),
|
||||
source: { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true, rpc: true },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -361,13 +380,17 @@ describe("Plugin", () => {
|
||||
|
||||
yield* plugins.activate([versioned(good), versioned(bad)])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
{
|
||||
id: Plugin.ID.make("good"),
|
||||
source: { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
{
|
||||
id: Plugin.ID.make("bad"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("materialization failed"),
|
||||
tui: false,
|
||||
state: { status: "failed", error: expect.stringContaining("materialization failed") },
|
||||
features: { server: true },
|
||||
},
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
|
||||
@@ -375,8 +398,18 @@ describe("Plugin", () => {
|
||||
fail = false
|
||||
yield* plugins.activate([versioned(good), versioned(bad, "2")])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
{
|
||||
id: Plugin.ID.make("good"),
|
||||
source: { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
{
|
||||
id: Plugin.ID.make("bad"),
|
||||
source: { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -413,7 +446,12 @@ describe("Plugin", () => {
|
||||
])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("partial-tools"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
{
|
||||
id: Plugin.ID.make("partial-tools"),
|
||||
source: { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("setup continued")
|
||||
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
@@ -457,9 +495,8 @@ describe("Plugin", () => {
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
state: { status: "failed", error: expect.stringContaining("replacement failed") },
|
||||
features: { server: true },
|
||||
},
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
|
||||
@@ -497,9 +534,8 @@ describe("Plugin", () => {
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
state: { status: "failed", error: expect.stringContaining("replacement failed") },
|
||||
features: { server: true },
|
||||
},
|
||||
])
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||
|
||||
@@ -22,6 +22,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
@@ -79,6 +80,7 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
Reference.node,
|
||||
Rpc.node,
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
Tool.node,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../config-effect-plugin"
|
||||
@@ -2,7 +2,6 @@ import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "config-promise-plugin",
|
||||
tui: true,
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("configured", (agent) => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../config-promise-plugin"
|
||||
@@ -0,0 +1 @@
|
||||
export default { id: "config-promise-plugin.tui", setup() {} }
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../failing-plugin"
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../invalid-plugin"
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../variant-source-plugin"
|
||||
@@ -29,6 +29,14 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
},
|
||||
}),
|
||||
options: {},
|
||||
rpc:
|
||||
overrides.rpc ??
|
||||
Object.assign(
|
||||
() => {
|
||||
throw new Error("unused rpc.client")
|
||||
},
|
||||
{ register: () => Effect.die("unused rpc.register") },
|
||||
),
|
||||
agent: overrides.agent ?? {
|
||||
get: () => Effect.die("unused agent.get"),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
|
||||
@@ -17,7 +17,11 @@ test("loads cached plugin packages without requesting a refresh", async () => {
|
||||
calls.push(options)
|
||||
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
|
||||
}),
|
||||
resolve: () => Effect.die(new Error("Unexpected resolve")),
|
||||
resolve: (_pkg, options) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(options)
|
||||
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
|
||||
}),
|
||||
which: () => Effect.die(new Error("Unexpected which")),
|
||||
}),
|
||||
),
|
||||
@@ -25,5 +29,6 @@ test("loads cached plugin packages without requesting a refresh", async () => {
|
||||
)
|
||||
|
||||
expect(plugin.id).toBe("config-effect-plugin")
|
||||
expect(calls).toEqual([{ subpaths: ["server", ""] }])
|
||||
expect(plugin.features).toEqual({ tui: true, rpc: true })
|
||||
expect(calls).toEqual([{ subpaths: ["server", ""] }, { subpaths: ["tui"] }, { subpaths: ["rpc"] }])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import { Effect, Exit, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const Echo = Rpc.define({
|
||||
id: "shared-echo",
|
||||
methods: {
|
||||
echo: { input: Schema.String, output: Schema.String },
|
||||
fail: {
|
||||
input: Schema.String,
|
||||
output: Schema.String,
|
||||
errors: { missing: Schema.Struct({ attempts: Schema.FiniteFromString }) },
|
||||
},
|
||||
},
|
||||
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
|
||||
})
|
||||
|
||||
it.effect("Effect plugins register, call, and publish RPCs independently of plugin identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const events: string[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type !== "rpc.shared-echo.updated") return
|
||||
expect(event.location).toEqual({ directory: location.directory })
|
||||
if (typeof event.data === "object" && event.data && "text" in event.data && typeof event.data.text === "string")
|
||||
events.push(event.data.text)
|
||||
}),
|
||||
)
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "implementer",
|
||||
version: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const registration = yield* ctx.rpc.register(Echo, {
|
||||
echo: (value) => Effect.succeed(`${value}!`),
|
||||
fail: (value, context) => Effect.fail(context.error("missing", "Missing", { attempts: Number(value) })),
|
||||
})
|
||||
yield* registration.events.emit("updated", { text: "ready" })
|
||||
}).pipe(Effect.orDie),
|
||||
},
|
||||
{
|
||||
id: "consumer",
|
||||
version: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
|
||||
expect(yield* ctx.rpc(Echo).fail("2").pipe(Effect.flip)).toEqual({
|
||||
type: "missing",
|
||||
message: "Missing",
|
||||
data: { attempts: 2 },
|
||||
})
|
||||
}).pipe(Effect.orDie),
|
||||
},
|
||||
])
|
||||
expect(events).toEqual(["ready"])
|
||||
expect(yield* rpc.client(Echo).echo("hello")).toBe("hello!")
|
||||
yield* plugins.activate([])
|
||||
expect(Exit.isFailure(yield* rpc.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
|
||||
yield* unsubscribe
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("failed plugin setup removes RPC overrides and restores the previous implementation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "implementer",
|
||||
version: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.rpc
|
||||
.register(Echo, {
|
||||
echo: () => Effect.succeed("original"),
|
||||
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
|
||||
})
|
||||
.pipe(Effect.asVoid, Effect.orDie),
|
||||
},
|
||||
])
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "implementer",
|
||||
version: "2",
|
||||
effect: (ctx) =>
|
||||
ctx.rpc
|
||||
.register(Echo, {
|
||||
echo: () => Effect.succeed("replacement"),
|
||||
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
|
||||
})
|
||||
.pipe(Effect.andThen(Effect.die(new Error("setup failed"))), Effect.orDie),
|
||||
},
|
||||
])
|
||||
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { RpcEventPayload } from "@opencode-ai/plugin/promise/rpc"
|
||||
import { Rpc } from "@opencode-ai/plugin/rpc"
|
||||
import { Effect, Logger } from "effect"
|
||||
import { z } from "zod"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("Promise plugin RPC", () => {
|
||||
it.live("adapts calls, schema transforms, failures, and registration disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const service = Rpc.define({
|
||||
id: "promise-rpc-calls",
|
||||
methods: {
|
||||
standard: { input: z.string().transform(Number), output: z.number().transform(String) },
|
||||
ping: { input: z.undefined(), output: z.null() },
|
||||
errorShapedOutput: {
|
||||
input: z.undefined(),
|
||||
output: z.object({ type: z.string(), message: z.string(), data: z.object({ value: z.number() }) }),
|
||||
},
|
||||
returned: {
|
||||
input: z.undefined(),
|
||||
output: z.null(),
|
||||
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
|
||||
},
|
||||
thrown: {
|
||||
input: z.undefined(),
|
||||
output: z.null(),
|
||||
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
|
||||
},
|
||||
defect: { input: z.undefined(), output: z.null() },
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-rpc-calls-plugin",
|
||||
setup: async (ctx) => {
|
||||
const registration = await ctx.rpc.register(service, {
|
||||
standard: async (input) => {
|
||||
expect(input).toBe(42)
|
||||
return input + 1
|
||||
},
|
||||
ping: async () => null,
|
||||
errorShapedOutput: async () => ({ type: "ordinary", message: "Success", data: { value: 1 } }),
|
||||
returned: async (_input, context) =>
|
||||
context.error("rejected", "returned failure", { attempts: "1" }),
|
||||
thrown: async (_input, context) => {
|
||||
throw context.error("rejected", "thrown failure", { attempts: "2" })
|
||||
},
|
||||
defect: async () => {
|
||||
throw new Error("handler defect")
|
||||
},
|
||||
})
|
||||
const client = ctx.rpc(service)
|
||||
expect(await client.standard("42")).toBe("43")
|
||||
expect(await client.ping()).toBeNull()
|
||||
expect(await client.errorShapedOutput()).toEqual({
|
||||
type: "ordinary",
|
||||
message: "Success",
|
||||
data: { value: 1 },
|
||||
})
|
||||
await expect(client.returned()).rejects.toEqual({
|
||||
type: "rejected",
|
||||
message: "returned failure",
|
||||
data: { attempts: 1 },
|
||||
})
|
||||
await expect(client.thrown()).rejects.toEqual({
|
||||
type: "rejected",
|
||||
message: "thrown failure",
|
||||
data: { attempts: 2 },
|
||||
})
|
||||
await expect(client.defect()).rejects.toThrow("handler defect")
|
||||
await registration.dispose()
|
||||
await registration.dispose()
|
||||
await expect(client.ping()).rejects.toBeDefined()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, version: "1" }])
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cancels only the selected call and passes its AbortSignal to Promise handlers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const service = Rpc.define({
|
||||
id: "promise-rpc-cancel",
|
||||
methods: { wait: { input: z.string(), output: z.string() } },
|
||||
events: {},
|
||||
})
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-rpc-cancel-plugin",
|
||||
setup: async (ctx) => {
|
||||
const started = Promise.withResolvers<void>()
|
||||
const cancelled = Promise.withResolvers<void>()
|
||||
const signals = new Map<string, AbortSignal>()
|
||||
await ctx.rpc.register(service, {
|
||||
wait: async (input, call) => {
|
||||
signals.set(input, call.signal)
|
||||
if (input === "complete") return input
|
||||
started.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
call.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
cancelled.resolve()
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
return input
|
||||
},
|
||||
})
|
||||
const client = ctx.rpc(service)
|
||||
const controller = new AbortController()
|
||||
const pending = client.wait("cancel", { signal: controller.signal })
|
||||
const rejected = pending.then(
|
||||
() => false,
|
||||
() => true,
|
||||
)
|
||||
await started.promise
|
||||
expect(await client.wait("complete")).toBe("complete")
|
||||
controller.abort()
|
||||
expect(await rejected).toBe(true)
|
||||
await cancelled.promise
|
||||
expect(signals.get("cancel")?.aborted).toBe(true)
|
||||
expect(signals.get("complete")?.aborted).toBe(false)
|
||||
expect(await client.wait("complete")).toBe("complete")
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, version: "1" }])
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("awaits async callbacks and logs failures without stopping other plugin listeners", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const service = Rpc.define({
|
||||
id: "promise-rpc-async-listeners",
|
||||
methods: {},
|
||||
events: { updated: { schema: z.object({ value: z.number() }) } },
|
||||
})
|
||||
const error = new Error("Expected async plugin callback failure")
|
||||
const reported = Promise.withResolvers<void>()
|
||||
const logger = Logger.make((entry) => {
|
||||
if (Array.isArray(entry.message) && entry.message.includes(error)) reported.resolve()
|
||||
})
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-rpc-async-listeners-plugin",
|
||||
setup: async (ctx) => {
|
||||
const registration = await ctx.rpc.register(service, {})
|
||||
const client = ctx.rpc(service)
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
const third = Promise.withResolvers<void>()
|
||||
const failed: number[] = []
|
||||
const healthy: number[] = []
|
||||
client.events.on("updated", async (event) => {
|
||||
failed.push(event.data.value)
|
||||
started.resolve()
|
||||
await release.promise
|
||||
throw error
|
||||
})
|
||||
client.events.on("updated", (event) => {
|
||||
healthy.push(event.data.value)
|
||||
if (event.data.value === 2) second.resolve()
|
||||
if (event.data.value === 3) third.resolve()
|
||||
})
|
||||
await registration.events.emit("updated", { value: 1 })
|
||||
await started.promise
|
||||
await registration.events.emit("updated", { value: 2 })
|
||||
await second.promise
|
||||
expect(failed).toEqual([1])
|
||||
release.resolve()
|
||||
await reported.promise
|
||||
await registration.events.emit("updated", { value: 3 })
|
||||
await third.promise
|
||||
expect(failed).toEqual([1])
|
||||
expect(healthy).toEqual([1, 2, 3])
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* plugins
|
||||
.activate([{ ...adapted, version: "1" }])
|
||||
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
|
||||
yield* plugins.activate([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("isolates event listeners and closes pending and idle iterators on plugin unload", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const service = Rpc.define({
|
||||
id: "promise-rpc-events",
|
||||
methods: {},
|
||||
events: {
|
||||
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
|
||||
},
|
||||
})
|
||||
const subscriptions = Promise.withResolvers<{
|
||||
pending: Promise<IteratorResult<RpcEventPayload<typeof service, "counted">>>
|
||||
idle: AsyncIterator<RpcEventPayload<typeof service, "counted">>
|
||||
nativeIdle: AsyncIterator<unknown>
|
||||
}>()
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-rpc-events-plugin",
|
||||
setup: async (ctx) => {
|
||||
const registration = await ctx.rpc.register(service, {})
|
||||
const client = ctx.rpc(service)
|
||||
const first: string[] = []
|
||||
const second: string[] = []
|
||||
const firstSeen = Promise.withResolvers<void>()
|
||||
const secondSeen = Promise.withResolvers<void>()
|
||||
const nextSeen = Promise.withResolvers<void>()
|
||||
const unsubscribe = client.events.on("counted", (event) => {
|
||||
first.push(event.data.text)
|
||||
firstSeen.resolve()
|
||||
})
|
||||
client.events.on("counted", (event) => {
|
||||
second.push(event.data.text)
|
||||
if (event.data.text === "1") secondSeen.resolve()
|
||||
if (event.data.text === "2") nextSeen.resolve()
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const iterator = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
|
||||
const next = iterator.next()
|
||||
const idle = client.events.subscribe("counted")[Symbol.asyncIterator]()
|
||||
const idleNext = idle.next()
|
||||
const nativeController = new AbortController()
|
||||
const native = ctx.event.subscribe({ signal: nativeController.signal })[Symbol.asyncIterator]()
|
||||
const nativeNext = native.next()
|
||||
const nativeIdle = ctx.event.subscribe()[Symbol.asyncIterator]()
|
||||
const nativeIdleNext = nativeIdle.next()
|
||||
await registration.events.emit("counted", { count: 1 })
|
||||
await Promise.all([firstSeen.promise, secondSeen.promise])
|
||||
const event = (await next).value
|
||||
expect(event.type).toBe("rpc.promise-rpc-events.counted")
|
||||
expect(event.data).toEqual({ text: "1" })
|
||||
expect(typeof event.location.directory).toBe("string")
|
||||
expect((await idleNext).value.data).toEqual({ text: "1" })
|
||||
expect((await nativeNext).value.type).toBe("rpc.promise-rpc-events.counted")
|
||||
expect((await nativeIdleNext).value.type).toBe("rpc.promise-rpc-events.counted")
|
||||
nativeController.abort()
|
||||
expect((await native.next()).done).toBe(true)
|
||||
unsubscribe()
|
||||
unsubscribe()
|
||||
controller.abort()
|
||||
expect((await iterator.next()).done).toBe(true)
|
||||
await registration.events.emit("counted", { count: 2 })
|
||||
await nextSeen.promise
|
||||
expect(first).toEqual(["1"])
|
||||
expect(second).toEqual(["1", "2"])
|
||||
const aborted = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
|
||||
expect((await aborted.next()).done).toBe(true)
|
||||
subscriptions.resolve({
|
||||
pending: client.events.subscribe("counted")[Symbol.asyncIterator]().next(),
|
||||
idle,
|
||||
nativeIdle,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, version: "1" }])
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
|
||||
const active = yield* Effect.promise(() => subscriptions.promise)
|
||||
yield* plugins.activate([])
|
||||
expect((yield* Effect.promise(() => active.pending)).done).toBe(true)
|
||||
expect((yield* Effect.promise(() => active.idle.next())).done).toBe(true)
|
||||
expect((yield* Effect.promise(() => active.nativeIdle.next())).done).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user