Compare commits

...
Author SHA1 Message Date
Shoubhit Dash be6ddabcc2 chore(ai): sync bedrock media validation 2026-08-31 22:23:16 +05:30
Shoubhit Dash cd25e872e9 test(ai): reject unsupported bedrock image formats 2026-08-31 22:22:57 +05:30
Shoubhit Dash 77deb25801 docs(ai): clarify compaction replay and test contracts 2026-08-31 22:21:46 +05:30
Shoubhit Dash c9ddafa46c test(ai): verify compaction capability and transport recovery 2026-08-31 22:21:29 +05:30
Shoubhit Dash d0396012a1 fix(ai): script compaction replacement windows directly 2026-08-31 22:21:01 +05:30
Shoubhit Dash 64746bf124 fix(ai): preserve compact request and replay metadata 2026-08-31 22:20:41 +05:30
Shoubhit Dash e07ae57caa Merge branch 'bedrock-messages' into native-compaction 2026-08-31 22:09:51 +05:30
Shoubhit Dash 5c2891c2fd fix(ai): validate bedrock messages media sources 2026-08-31 22:09:34 +05:30
Shoubhit Dash dbe2998fce fix(ai): typecheck explicit compaction capability 2026-08-31 21:08:20 +05:30
Shoubhit Dash cd7447e834 feat(ai): support provider-side compaction 2026-08-31 21:08:05 +05:30
Shoubhit Dash fa9976a41a feat(ai): add bedrock messages transport 2026-08-31 20:53:47 +05:30
Shoubhit Dash 0d045152fc fix(ai): preserve responses image detail 2026-08-31 20:52:22 +05:30
Shoubhit Dash ffb9728068 refactor(ai): separate conversation and generation lowering 2026-08-31 20:51:17 +05:30
Kit Langton eb083cce63 fix(server): detach PTYs when sockets close (#46068) 2026-08-31 10:53:48 -04:00
1afb7c614e fix(app): raise active composer surface (#46401)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-31 22:47:49 +08:00
Kit Langton 56e773831c fix(ai): validate cache tail counts (#46067) 2026-08-31 10:46:18 -04:00
Brendan Allan 6b1ed3918a feat(app): refine mobile diff review and wrapping preferences (#46390) 2026-08-31 22:32:11 +08:00
opencode-agent[bot]andvimtor 1b3eb1138e fix(tui): queue autocompleted commands (#46414)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-08-31 19:52:29 +05:30
Brendan Allan 711a0a2da2 feat(app): add mobile session panels and detail drawers (#46389) 2026-08-31 14:04:14 +00:00
opencode-agent[bot]andnexxeln ac77cc46b8 fix(client): isolate shared event consumers (#46393)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-31 19:32:27 +05:30
opencode-agent[bot] 7197fdfb4e chore: update nix node_modules hashes 2026-08-31 13:40:50 +00:00
Brendan Allan 5a4914c670 feat(app): refine mobile shell home and settings (#46388) 2026-08-31 21:20:17 +08:00
Kit Langton 3a797bf6e4 fix(ai): validate canonical tool results (#46062) 2026-08-31 09:14:31 -04:00
opencode-agent[bot]andvimtor 33536da231 fix(core): commit undo before compaction (#46383)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-08-31 17:03:51 +05:30
Shoubhit Dash e56ceed32b Revert "fix(tui): surface subagent permissions and questions" (#46376) 2026-08-31 15:41:46 +05:30
Shoubhit Dash b2c7246134 fix(core): refresh git references on daily activity (#45575) 2026-08-31 15:36:37 +05:30
Shoubhit Dash 9c39e75ce2 fix(tui): surface subagent permissions and questions (#44976) 2026-08-31 14:38:05 +05:30
opencode-agent[bot]andBrendonovich 0dad76e618 fix(ui): prevent menu items from shrinking (#46353)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-31 16:00:55 +08:00
Luke Parker 90fb6562ce fix(shell): bound post-exit pipe draining on all platforms (#46085) 2026-08-31 16:53:35 +10:00
opencode-agent[bot]andBrendonovich 174d263890 fix(app): save session titles on blur and add tab context menu (#46113)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-31 13:07:32 +08:00
opencode-agent[bot]andBrendonovich 5ec29e7a87 refactor(desktop): use password-only server authentication (#45958)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-31 13:06:35 +08:00
Luke Parker 3c6b85acf7 fix(app): reveal pasted composer content with custom scrollbar (#46339) 2026-08-31 14:38:28 +10:00
Luke Parker 50e77f66fd fix(app): keep composer select all scoped to the editor (#46338) 2026-08-31 14:38:00 +10:00
Aiden Cline d484f070d1 fix(core): recover reads with non-breaking spaces (#45807) 2026-08-30 23:13:26 -05:00
Luke Parker 8890294bf0 fix(desktop): preserve Windows editing shortcuts (#46336) 2026-08-31 04:07:42 +00:00
Luke Parker a1925de0c1 fix(core): flush trailing stream chunks while providers pause (#46326) 2026-08-31 14:03:17 +10:00
Aiden Cline 24e826d06b fix(ai): validate Bedrock media data (#46333) 2026-08-30 22:58:58 -05:00
Aiden Cline 19625400c1 fix(ai): normalize tool result history (#46309) 2026-08-30 22:19:48 -05:00
opencode-agent[bot] 52cbe7b3ee chore: update nix node_modules hashes 2026-08-31 02:04:13 +00:00
Dax 6a2c3e91c7 feat(plugin): add typed rpc and custom events (#46105) 2026-08-30 21:28:19 -04:00
Aiden Cline 4afd8e81be fix(ai): deduplicate request tools (#46306) 2026-08-30 16:24:46 -05:00
Aiden Cline 485bdc9c4e fix(ai): omit empty Responses user messages (#46297) 2026-08-30 16:03:41 -05:00
Aiden Cline 1a3aee39de fix(ai): sanitize empty Bedrock tool input keys (#46296) 2026-08-30 16:02:31 -05:00
Aiden Cline 2ddf257c6f fix(ai): filter empty Anthropic messages (#46291) 2026-08-30 14:39:46 -05:00
Aiden Cline ef50e0b6d8 fix(ai): omit empty Bedrock system blocks (#46294) 2026-08-30 14:38:51 -05:00
Aiden Cline 583a1a2b6f fix(ai): omit empty Anthropic system blocks (#46289) 2026-08-30 14:21:05 -05:00
Aiden Cline 68c1207b52 fix(ai): close Gemini text before tools (#46286) 2026-08-30 14:08:25 -05:00
Aiden Cline f77647ad12 fix(ai): accumulate bedrock redacted content (#46283) 2026-08-30 13:45:39 -05:00
Aiden Cline c746ea3210 fix(ai): use unique Gemini block ids (#46279) 2026-08-30 13:21:35 -05:00
Aiden Cline d323b34826 feat(ai): add native Mistral provider (#46278) 2026-08-30 12:56:01 -05:00
Aiden Cline bf50194f99 fix(ai): reject truncated bedrock frames (#46281) 2026-08-30 12:47:33 -05:00
Kit Langton 4a977b2b31 refactor(core): bind standalone skill activation to Session (#46077)
Move standalone skill activation into ID-bound Session handles and delegate from the public service. Preserve current-placement lookup, raw skill content, ambient publication context, validation order, and host-scoped detached resume behavior. Cover ownership and lifecycle contracts with focused regressions.
2026-08-30 10:22:03 -04:00
Aiden Cline b1e3a7b222 fix(ai): preserve forced reasoning signature (#46218) 2026-08-29 23:09:03 -05:00
opencode-agent[bot]andBrendonovich b12d43698e fix(app): recover sessions with unavailable locations (#46215)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-30 11:51:48 +08:00
opencode-agent[bot] 5cd2c22b27 chore: update nix node_modules hashes 2026-08-30 03:43:36 +00:00
Aiden Cline c2ef80a287 fix(ai): fail malformed converse output (#46193) 2026-08-29 22:29:53 -05:00
opencode-agent[bot]andBrendonovich 4df3029536 fix(desktop): scope library validation exception to CLI (#46212)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-30 03:29:31 +00:00
373 changed files with 17088 additions and 2220 deletions
+1
View File
@@ -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
+72 -33
View File
@@ -54,6 +54,7 @@
"name": "@opencode-ai/app",
"version": "1.18.15",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -183,6 +184,7 @@
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"zod": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
@@ -430,6 +432,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",
@@ -1640,6 +1643,10 @@
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -1678,7 +1685,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 +2781,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=="],
@@ -2874,6 +2881,8 @@
"@solid-primitives/media": ["@solid-primitives/media@2.3.6", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/rootless": "^1.5.4", "@solid-primitives/static-store": "^0.1.4", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw=="],
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-VDPrkl9epp0tbby9MvsqphGFCYCtDRC5J8FKzTqHbQiG5hhR8n6xv4MfjhTW231IaBxxPHLxS43EE8c5Q23mSQ=="],
"@solid-primitives/props": ["@solid-primitives/props@3.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A=="],
@@ -3532,7 +3541,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 +3821,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 +3893,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 +4215,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=="],
@@ -5300,6 +5309,10 @@
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
@@ -5314,6 +5327,8 @@
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
@@ -5812,7 +5827,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 +5975,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 +6131,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 +6169,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 +6315,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 +6397,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 +6407,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 +6453,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 +6481,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 +6537,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 +6583,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=="],
@@ -6616,6 +6643,8 @@
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -6642,8 +6671,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 +6813,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 +6937,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 +7161,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 +7267,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 +7285,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 +7421,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 +7433,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 +7483,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 +7515,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 +8195,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
View File
@@ -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-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
}
}
+108 -1
View File
@@ -241,14 +241,121 @@ Constructing `stream()` or `generate()` does not record a request, invoke a resp
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
future reply.
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
Generation responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
it does not repair or truncate them.
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. The client returns that replacement window directly, including retained user messages and usage, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
`testLayer`.
## Provider compaction
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex and Bedrock Messages), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider.
This is different from prompt caching, server-side history storage, or truncation. Compaction returns provider-owned context that must be replayed to continue the conversation.
### Automatic compaction
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
```ts
import { LLM, LLMClient, LLMRequest, Message } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
const request = LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-5.3-codex"),
messages,
providerOptions: {
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
},
})
const response = yield * LLMClient.generate(request)
const next = LLMRequest.update(request, {
messages: [...request.messages, response.message, Message.user("Continue")],
})
```
`store: false` remains the default. Keep the entire `response.message`, not just `response.text`. Compaction events become ordered `CompactionPart`s alongside text and reasoning. The conversation contains everything needed to continue; there is no separate replay object or hidden provider transcript.
A compaction part has `provider` and exactly one representation: `encrypted` for Responses, or `text` for Anthropic. Responses also preserves the optional checkpoint `id`. These fields survive message serialization without becoming visible assistant text. Sending a checkpoint to another provider or an incompatible API fails rather than silently losing context.
```ts
import { CompactionPart, ProviderID } from "@opencode-ai/ai"
CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_123", encrypted: "..." })
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "Summary of the conversation..." })
```
For Anthropic, use:
```ts
providerOptions: {
contextManagement: {
edits: [{
type: "compact_20260112",
trigger: { type: "input_tokens", value: 150_000 },
pauseAfterCompaction: true,
instructions: "Summarize the task and decisions. Do not call tools while summarizing.",
}],
},
}
```
- The trigger is optional (provider default: 150,000 tokens), with a minimum of 50,000.
- Custom instructions replace Anthropic's default summarization instructions.
- The route adds `compact-2026-01-12` to existing beta headers, including when replaying a checkpoint without enabling new compactions.
- A pause is exposed as `response.finishReason.raw === "compaction"`. The caller explicitly issues the next request; the package never automatically resumes.
- Anthropic can return a compaction block with `content: null` when summarization fails. This becomes a compaction part with `text: null`, which is **not** a successful replacement for prior history. The package never prunes history automatically.
- `Usage` totals include all reported Anthropic `usage.iterations`, including compaction. `contextTokens` separately reports the final message iteration's inclusive input size, when available. A compaction-only pause does not report a post-compaction context size. Raw iteration usage remains in `providerMetadata`.
Bedrock's Converse API does not support this feature. Select the native Claude Messages route explicitly; the default `.model(...)` remains Converse:
```ts
import { AmazonBedrock } from "@opencode-ai/ai/providers"
const model = AmazonBedrock.configure({ region: "us-east-1", credentials }).messages("us.anthropic.claude-opus-4-6-v1")
```
The corresponding package entrypoint is `@opencode-ai/ai/providers/amazon-bedrock/messages`. It uses InvokeModelWithResponseStream, AWS event-stream framing, bearer or SigV4 auth, and `anthropic_beta` in the request body.
### Explicit compaction
`LLMClient.compact(request)` performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` containing replacement `messages` and usage, not a normal generation response.
The selected model carries explicit-compaction capability through request construction and updates. Calls using unsupported routes fail type checking. When the model is selected dynamically, narrow the request with `LLMClient.canCompact(request)` before calling `LLMClient.compact`; a model or route switch does not inherit the old capability. Runtime validation still rejects unsupported calls from untyped consumers. Capability describes the route's API, not whether every model or custom deployment supports the operation.
```ts
const compacted = yield * LLMClient.compact(request)
const next = LLMRequest.update(request, {
messages: [...compacted.messages, Message.user("Continue")],
})
const response = yield * LLMClient.generate(next)
```
Replace the prior window with `compacted.messages`. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly. Generation-only body overlays such as `stream` and `store` are not sent to the compact endpoint.
Supported compact controls such as service tier and prompt-cache settings preserve request defaults and HTTP-overlay precedence. Retained image and file detail settings survive serialization and replay.
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. xAI supports this explicit path, not the automatic OpenAI option. Unsupported routes, including Bedrock Mantle, do not inherit an explicit compact endpoint simply because they use a Responses protocol.
### Ownership and verification
The AI package transports options and typed conversation parts. It does not schedule compaction, persist Session checkpoints, select history, switch providers, or replace Core's existing local compaction policy. Native compaction is not enabled for OpenCode Sessions by this feature; Session integration must persist these parts before enabling it. The AI SDK bridge rejects native compaction parts rather than dropping them. Provider-executed tool APIs and persistence changes are a separate follow-up.
Tests cover serialized round trips, real local HTTP plus a tool loop, AWS binary frames and signing, provider errors, malformed blocks, and usage accounting. Live provider tests are gated by `RECORD=true` and the relevant API keys:
```sh
# Run from packages/ai. Only records the selected new cassette group.
RECORD=true RECORDED_PREFIX=openai-compaction bun test test/provider/compaction.recorded.test.ts
RECORD=true RECORDED_PREFIX=xai-compaction bun test test/provider/compaction.recorded.test.ts
RECORD=true RECORDED_PREFIX=anthropic-compaction bun test test/provider/compaction.recorded.test.ts
```
Provider references: [OpenAI](https://developers.openai.com/api/docs/guides/compaction), [Azure](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses#server-side-compaction), [Anthropic](https://platform.claude.com/docs/en/build-with-claude/compaction), [Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-compaction.html), [xAI](https://docs.x.ai/developers/advanced-api-usage/context-compaction).
## Caching
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
+1
View File
@@ -40,6 +40,7 @@ const RESPECTS_INLINE_HINTS = new Set([
"anthropic-messages",
"google-vertex-messages",
"bedrock-converse",
"bedrock-messages",
"openrouter",
])
+197 -32
View File
@@ -6,8 +6,12 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { Headers } from "effect/unstable/http"
import { HttpTransport } from "../route/transport/index.js"
import {
AIError,
HttpOptions,
LLMRequest,
LLMEvent,
mergeJsonRecords,
Usage,
@@ -15,7 +19,6 @@ import {
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ToolCallPart,
@@ -61,6 +64,7 @@ export type ThinkingInput =
))
export interface OptionsInput {
readonly contextManagement?: ContextManagement
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
@@ -89,6 +93,23 @@ export interface OptionsInput {
export type ProviderOptionsInput = OptionsInput
export const ContextManagement = Schema.Struct({
edits: Schema.Array(
Schema.Struct({
type: Schema.Literal("compact_20260112"),
trigger: Schema.optional(
Schema.Struct({
type: Schema.Literal("input_tokens"),
value: Schema.Int.check(Schema.isGreaterThanOrEqualTo(50000)),
}),
),
pauseAfterCompaction: Schema.optional(Schema.Boolean),
instructions: Schema.optional(Schema.String),
}),
),
})
export type ContextManagement = typeof ContextManagement.Type
// =============================================================================
// Request Body Schema
// =============================================================================
@@ -236,7 +257,12 @@ const AnthropicUserBlock = Schema.Union([
AnthropicToolResultBlock,
])
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
const AnthropicCompactionBlock = Schema.Struct({
type: Schema.Literal("compaction"),
content: Schema.NullOr(Schema.String),
})
const AnthropicAssistantBlock = Schema.Union([
AnthropicCompactionBlock,
AnthropicTextBlock,
AnthropicThinkingBlock,
AnthropicRedactedThinkingBlock,
@@ -312,6 +338,18 @@ const AnthropicContainer = Schema.Union([
])
const AnthropicBodyFields = {
context_management: Schema.optional(
Schema.Struct({
edits: Schema.Array(
Schema.Struct({
type: Schema.Literal("compact_20260112"),
trigger: ContextManagement.fields.edits.value.fields.trigger,
pause_after_compaction: Schema.optional(Schema.Boolean),
instructions: Schema.optional(Schema.String),
}),
),
}),
),
model: Schema.String,
system: optionalArray(AnthropicTextBlock),
messages: Schema.Array(AnthropicMessage),
@@ -335,7 +373,7 @@ const AnthropicBodyFields = {
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
const AnthropicUsage = Schema.StructWithRest(
const AnthropicIterationUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: optionalNull(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
@@ -354,6 +392,13 @@ const AnthropicUsage = Schema.StructWithRest(
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
...AnthropicIterationUsage.schema.fields,
iterations: Schema.optional(Schema.Array(AnthropicIterationUsage)),
}),
[JsonObject],
)
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
const AnthropicStreamBlock = Schema.Struct({
@@ -377,6 +422,7 @@ type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.Struct({
content: optionalNull(Schema.String),
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
@@ -406,6 +452,8 @@ const AnthropicEvent = Schema.Struct({
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly provider: LLMRequest["model"]["provider"]
readonly compactions: Readonly<Record<number, string | null>>
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -831,6 +879,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,14 +889,21 @@ 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
}
if (message.role === "assistant") {
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "compaction") {
if (part.provider !== request.model.provider || part.text === undefined)
return yield* invalid("Compaction state must be replayed to its originating provider and API")
content.push({ type: "compaction", content: part.text })
continue
}
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 +947,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
}
@@ -1001,6 +1057,9 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
@@ -1019,10 +1078,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),
@@ -1034,7 +1094,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
const options = yield* resolveOptions(request)
return {
const body = {
model: request.model.id,
system,
messages,
@@ -1055,6 +1115,18 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
metadata: options.metadata,
service_tier: options.service_tier,
}
if (!management) return body
return {
...body,
context_management: {
edits: management.edits.map((edit) => ({
type: edit.type,
trigger: edit.trigger,
pause_after_compaction: edit.pauseAfterCompaction,
instructions: edit.instructions,
})),
},
}
})
// =============================================================================
@@ -1076,18 +1148,31 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const iterations = usage.iterations?.length ? usage.iterations : [usage]
const last = usage.iterations?.at(-1)
const nonCached = ProviderShared.sumTokens(...iterations.map((item) => item.input_tokens ?? undefined))
const cacheRead = ProviderShared.sumTokens(...iterations.map((item) => item.cache_read_input_tokens ?? undefined))
const cacheWrite = ProviderShared.sumTokens(
...iterations.map((item) => item.cache_creation_input_tokens ?? undefined),
)
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
const outputTokens = ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens))
return new Usage({
inputTokens,
outputTokens: usage.output_tokens,
outputTokens,
contextTokens:
last?.type === "message"
? ProviderShared.sumTokens(
last.input_tokens ?? undefined,
last.cache_read_input_tokens ?? undefined,
last.cache_creation_input_tokens ?? undefined,
)
: undefined,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cacheRead,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)),
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -1109,6 +1194,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
return new Usage({
inputTokens,
outputTokens,
contextTokens: right.contextTokens ?? left.contextTokens,
nonCachedInputTokens,
cacheReadInputTokens,
cacheWriteInputTokens,
@@ -1167,7 +1253,6 @@ const onContentBlockStart = (
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
): StepResult => {
const block = event.content_block
if (!block) return [state, NO_EVENTS]
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
@@ -1262,7 +1347,16 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
) {
const delta = event.delta
if (delta?.type === "text_delta" && delta.text) {
if (delta.type === "compaction_delta") {
if (event.index === undefined || !(event.index in state.compactions) || delta.content === undefined)
return yield* ProviderShared.eventError(ADAPTER, "Compaction delta is missing its block or content")
return [
{ ...state, compactions: { ...state.compactions, [event.index]: delta.content } },
NO_EVENTS,
] satisfies StepResult
}
if (delta.type === "text_delta" && delta.text) {
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
@@ -1271,7 +1365,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
] satisfies StepResult
}
if (delta?.type === "thinking_delta" && delta.thinking) {
if (delta.type === "thinking_delta" && delta.thinking) {
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
@@ -1283,7 +1377,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
] satisfies StepResult
}
if (delta?.type === "signature_delta" && delta.signature) {
if (delta.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
return [
@@ -1295,7 +1389,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
] satisfies StepResult
}
if (delta?.type === "input_json_delta" && event.index !== undefined) {
if (delta.type === "input_json_delta" && event.index !== undefined) {
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
@@ -1320,6 +1414,18 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
event: AnthropicEvent,
) {
if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult
if (event.index in state.compactions) {
const { [event.index]: content, ...compactions } = state.compactions
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.compaction({
provider: state.provider,
text: content,
}),
)
return [{ ...state, compactions, lifecycle }, events] satisfies StepResult
}
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
@@ -1371,11 +1477,23 @@ const onMessageDelta = (
}
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
if (Object.keys(state.compactions).length)
return yield* ProviderShared.eventError(ADAPTER, "Response ended with an incomplete compaction block")
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
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,
@@ -1405,16 +1523,21 @@ const onError = (event: AnthropicEvent) => {
)
}
const isKnownStreamBlockType = (type: string) =>
type === "text" ||
type === "thinking" ||
type === "redacted_thinking" ||
type === "tool_use" ||
type === "server_tool_use" ||
isServerToolResultType(type)
const isKnownStreamDeltaType = (type: string) =>
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
const STREAM_BLOCK_TYPES = new Set([
"compaction",
"text",
"thinking",
"redacted_thinking",
"tool_use",
"server_tool_use",
])
const STREAM_DELTA_TYPES = new Set([
"compaction_delta",
"text_delta",
"thinking_delta",
"signature_delta",
"input_json_delta",
])
const invalidStreamEvent = (event: AnthropicEvent) =>
Effect.fail(
@@ -1443,7 +1566,16 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_start") {
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
return invalidStreamEvent(event)
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
if (event.content_block.type === "compaction") {
const decoded = Schema.decodeUnknownOption(AnthropicCompactionBlock)(event.content_block)
if (event.index === undefined || Option.isNone(decoded)) return invalidStreamEvent(event)
return Effect.succeed<StepResult>([
{ ...state, compactions: { ...state.compactions, [event.index]: decoded.value.content } },
NO_EVENTS,
])
}
if (!STREAM_BLOCK_TYPES.has(event.content_block.type) && !isServerToolResultType(event.content_block.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamBlock(event.content_block)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
const block = decoded.value
@@ -1457,7 +1589,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
}
if (event.type === "content_block_delta") {
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
if (typeof event.delta.type === "string" && !STREAM_DELTA_TYPES.has(event.delta.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
@@ -1491,6 +1623,8 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: (request) => ({
provider: request.model.provider,
compactions: {},
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
@@ -1500,6 +1634,37 @@ export const protocol = Protocol.make({
},
})
export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" | "context_management">>() => {
const http = HttpTransport.httpJson<Body, string>({ framing })
return {
...http,
prepare: (input: Parameters<typeof http.prepare>[0]) => {
if (
!input.body.context_management?.edits.length &&
!input.body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
)
return http.prepare(input)
const headers = Headers.fromInput(input.request.http?.headers)
const betas = new Set(
(headers["anthropic-beta"] ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
)
betas.add("compact-2026-01-12")
return http.prepare({
...input,
request: LLMRequest.update(input.request, {
http: new HttpOptions({
...input.request.http,
headers: { ...headers, "anthropic-beta": [...betas].join(",") },
}),
}),
})
},
}
}
export const route = Route.make({
id: ADAPTER,
provider: "anthropic",
@@ -1509,7 +1674,7 @@ export const route = Route.make({
baseURL: DEFAULT_BASE_URL,
}),
auth: Auth.none,
framing,
transport: transport<AnthropicMessagesBody>(),
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
+138 -73
View File
@@ -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"
@@ -0,0 +1,121 @@
import { Effect, Encoding, Schema, Struct } from "effect"
import { Headers } from "effect/unstable/http"
import { AIError } from "../schema/index.js"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { classifyProviderFailure } from "../provider-error.js"
import { AnthropicMessages } from "./anthropic-messages.js"
import { BedrockEventStream } from "./bedrock-event-stream.js"
import { BedrockAuth } from "./utils/bedrock-auth.js"
import { JsonObject, ProviderShared } from "./shared.js"
const ID = "bedrock-messages"
const VERSION = "bedrock-2023-05-31"
const Body = Schema.Struct({
...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model", "stream"]),
anthropic_version: Schema.Literal(VERSION),
anthropic_beta: Schema.optional(Schema.Array(Schema.String)),
}).check(
Schema.makeFilter(
(body) =>
body.messages.flatMap((message) => message.content.map(mediaIssue)).find((issue) => issue !== undefined) ?? true,
),
)
const Event = Schema.Struct({
chunk: Schema.optional(Schema.Struct({ bytes: Schema.String })),
exception: Schema.optional(
Schema.Struct({
type: Schema.String,
details: Schema.StructWithRest(
Schema.Struct({ message: Schema.optional(Schema.String), originalMessage: Schema.optional(Schema.String) }),
[JsonObject],
),
}),
),
})
export const protocol = Protocol.make({
id: ID,
body: {
schema: Body,
from: Effect.fn("BedrockMessages.fromRequest")(function* (request) {
const body = yield* AnthropicMessages.protocol.body.from(request)
const headers = Headers.fromInput(request.http?.headers)
const betas = new Set(
(headers["anthropic-beta"] ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean),
)
if (
body.context_management?.edits.length ||
body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
)
betas.add("compact-2026-01-12")
return {
...Struct.omit(body, ["model", "stream"]),
anthropic_version: VERSION,
anthropic_beta: betas.size ? [...betas] : undefined,
} satisfies typeof Body.Type
}),
},
stream: {
event: Event,
initial: AnthropicMessages.protocol.stream.initial,
step: Effect.fn("BedrockMessages.step")(function* (state, event) {
if (event.exception)
return yield* new AIError({
reason: classifyProviderFailure({
message: event.exception.details.message ?? event.exception.details.originalMessage ?? event.exception.type,
rawBody: ProviderShared.encodeJson(event),
}),
})
if (!event.chunk) return yield* ProviderShared.eventError(ID, "Bedrock Messages event is missing its chunk")
const text = yield* Effect.fromResult(Encoding.decodeBase64String(event.chunk.bytes)).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(ID, "Invalid Bedrock Messages chunk encoding", undefined, cause),
),
)
const decoded = yield* Schema.decodeUnknownEffect(AnthropicMessages.protocol.stream.event)(text).pipe(
Effect.mapError((cause) => ProviderShared.eventError(ID, "Invalid Bedrock Messages event", undefined, cause)),
)
return yield* AnthropicMessages.protocol.stream.step(state, decoded)
}),
},
})
function mediaIssue(
block: AnthropicMessages.AnthropicMessagesBody["messages"][number]["content"][number],
): string | undefined {
if (block.type === "tool_result")
return typeof block.content === "string"
? undefined
: block.content.map(mediaIssue).find((issue) => issue !== undefined)
if (block.type !== "image" && block.type !== "document") return undefined
if (block.source.type === "url" || block.source.type === "file")
return "Bedrock Messages does not support URL or file-ID media sources"
if (
block.type === "image" &&
!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(block.source.media_type)
)
return "Bedrock Messages requires a JPEG, PNG, WebP, or GIF image"
if (block.source.type === "base64" && Encoding.decodeBase64(block.source.data)._tag === "Failure")
return "Bedrock Messages media data must be valid base64"
return undefined
}
export const route = Route.make({
id: ID,
provider: "amazon-bedrock",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path(
({ request }) => `/model/${encodeURIComponent(request.model.id)}/invoke-with-response-stream`,
{ baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" },
),
auth: BedrockAuth.auth,
framing: BedrockEventStream.framing(ID),
})
export * as BedrockMessages from "./bedrock-messages.js"
+80 -24
View File
@@ -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)),
+2
View File
@@ -1,6 +1,8 @@
export * as AnthropicMessages from "./anthropic-messages.js"
export * as BedrockConverse from "./bedrock-converse.js"
export { BedrockMessages } from "./bedrock-messages.js"
export * as Gemini from "./gemini.js"
export * as MistralChat from "./mistral-chat.js"
export * as OpenAIChat from "./openai-chat.js"
export * as OpenAIImages from "./openai-images.js"
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
+780
View File
@@ -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"
+132 -46
View File
@@ -32,17 +32,19 @@ export const PATH = "/responses"
// =============================================================================
// Request Body Schema
// =============================================================================
const OpenResponsesInputText = Schema.Struct({
export const OpenResponsesInputText = Schema.Struct({
type: Schema.tag("input_text"),
text: Schema.String,
})
const OpenResponsesInputImage = Schema.Struct({
export const OpenResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
detail: Schema.optional(Schema.String),
})
const OpenResponsesInputFile = Schema.Struct({
export const OpenResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
detail: Schema.optional(Schema.String),
file_data: Schema.optional(Schema.String),
file_url: Schema.optional(Schema.String),
})
@@ -54,7 +56,7 @@ const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput])
const OpenResponsesOutputText = Schema.Struct({
export const OpenResponsesOutputText = Schema.Struct({
type: Schema.tag("output_text"),
text: Schema.String,
})
@@ -62,6 +64,13 @@ const OpenResponsesOutputText = Schema.Struct({
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
export const MessageMetadata = Schema.Struct({
itemId: Schema.optional(Schema.String),
type: Schema.optional(Schema.Literal("message")),
status: Schema.optional(Schema.String),
phase: Schema.optional(MessagePhase),
})
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
@@ -72,7 +81,7 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
text: Schema.String,
})
const OpenResponsesReasoningItem = Schema.Struct({
export const OpenResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
@@ -149,16 +158,30 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
Schema.Array(OpenResponsesFunctionCallOutputContent),
])
export const CompactionItem = Schema.Struct({
type: Schema.Literal("compaction"),
id: optionalNull(Schema.String),
encrypted_content: Schema.String,
})
export const InputItem = Schema.Union([
CompactionItem,
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("user"),
content: Schema.Array(OpenResponsesInputContent),
type: Schema.optional(Schema.Literal("message")),
id: Schema.optional(Schema.String),
status: Schema.optional(Schema.String),
}),
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
status: Schema.optional(Schema.String),
}),
OpenResponsesReasoningItem,
Schema.Struct({
@@ -267,7 +290,7 @@ const OpenResponsesBody = Schema.Struct({
})
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
const OpenResponsesUsage = Schema.Struct({
export const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.Struct({
@@ -387,6 +410,8 @@ export interface Extension {
const BASE: Extension = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly provider: LLMRequest["model"]["provider"]
readonly completedCompactions: ReadonlySet<string>
readonly id: string
readonly name: string
readonly providerMetadataKey: string
@@ -488,6 +513,9 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const media = ProviderShared.normalizeMedia(part)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
part.providerMetadata?.[metadataKey(request.model)]?.detail,
)
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -498,10 +526,15 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
return {
type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
detail,
...(url ? { file_url: url } : { file_data: media.dataUrl }),
}
}
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
return {
type: "input_image" as const,
image_url: url ?? media.dataUrl,
detail,
}
})
const lowerUserContent = Effect.fnUntraced(function* (
@@ -565,9 +598,12 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
const providerMetadataKey = metadataKey(request.model)
for (const message of request.messages) {
const metadata = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[providerMetadataKey])
if (message.role === "system") {
input.push({
role: "developer",
@@ -577,10 +613,9 @@ 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, type: metadata?.type, id: metadata?.itemId, status: metadata?.status })
continue
}
@@ -593,9 +628,10 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const groups = content.reduce<
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
const partMetadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey) ?? metadata?.itemId
const partPhase = messagePhase(partMetadata?.phase)
const phase = partPhase === undefined ? metadata?.phase : partPhase
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
else groups.push({ id, phase, parts: [part] })
@@ -606,6 +642,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
status: metadata?.status,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
@@ -613,6 +650,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
content.splice(0, content.length)
}
for (const part of message.content) {
if (part.type === "compaction") {
flushText()
if (part.provider !== request.model.provider || part.encrypted === undefined)
return yield* ProviderShared.invalidRequest(
"Compaction state must be replayed to its originating provider and API",
)
input.push({ type: "compaction", id: part.id, encrypted_content: part.encrypted })
continue
}
if (part.type === "text") {
content.push(part)
continue
@@ -691,13 +737,30 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
return input
})
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
request: LLMRequest,
extension: Extension,
) {
const instructions = ProviderShared.joinText(request.system)
return {
model: request.model.id,
input: yield* lowerMessages(request, extension),
...(instructions ? { instructions } : {}),
}
})
export const lowerGeneration = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const generation = request.generation
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(instructions ? { instructions } : {}),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
@@ -725,7 +788,7 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
return disabled === undefined ? undefined : !disabled
}
const allowedToolChoice = (request: LLMRequest) => {
export const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
@@ -739,11 +802,10 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
request: LLMRequest,
extension: Extension,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, extension),
...(yield* lowerConversation(request, extension)),
...lowerGeneration(request),
tools:
request.tools.length === 0
? undefined
@@ -757,13 +819,6 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request),
}
})
@@ -780,7 +835,7 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (req
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
// with a `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
@@ -810,6 +865,8 @@ const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason =
return hasFunctionCall ? "tool-calls" : "unknown"
}
export const metadataKey = (model: LLMRequest["model"]) => model.route.providerMetadataKey ?? "openresponses"
export const providerMetadata = (state: ParserState, metadata: Record<string, unknown>): ProviderMetadata => ({
[state.providerMetadataKey]: metadata,
})
@@ -1086,6 +1143,25 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.compaction({
provider: state.provider,
id: item.id,
encrypted: item.encrypted_content,
}),
)
return [
{ ...state, lifecycle, completedCompactions: new Set([...state.completedCompactions, item.id]) },
events,
] satisfies StepResult
}
if (item.type === "message" && item.id !== undefined) {
const message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
@@ -1245,26 +1321,34 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (id === undefined) continue
if (item.type !== "function_call" || !current.tools[id]) continue
if (item.type !== "compaction" && item.type !== "function_call") continue
if (item.type === "compaction") {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
}
if (item.type === "function_call" && !current.tools[item.id ?? item.call_id ?? ""]) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
}
// Some compatible providers omit output_item.done even after completing the response.
const pending = yield* ToolStream.finishAll(current.id, current.tools)
current = {
...current,
tools: pending.tools,
hasFunctionCall:
current.hasFunctionCall ||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)),
}
events.push(...pending.events)
}
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
events.push(...pending.events)
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
current.hasFunctionCall
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, hasFunctionCall),
normalized: mapFinishReason(event, current.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
@@ -1276,7 +1360,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
})
: undefined,
})
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
return [{ ...current, lifecycle }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
@@ -1411,9 +1495,11 @@ export const step = (state: ParserState, input: Event) => {
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
provider: request.model.provider,
completedCompactions: new Set<string>(),
id: extension.id,
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
providerMetadataKey: metadataKey(request.model),
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
+27 -9
View File
@@ -5,13 +5,14 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -20,6 +21,14 @@ const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = OpenResponses.PATH
export const ContextManagement = Schema.Array(
Schema.Struct({
type: Schema.Literal("compaction"),
compactThreshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
)
export type ContextManagement = typeof ContextManagement.Type
const OpenAIResponsesImageGenerationTool = Schema.Struct({
type: Schema.tag("image_generation"),
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
@@ -78,6 +87,14 @@ const OpenAIResponsesCoreFields = {
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
Schema.Array(
Schema.Struct({
type: Schema.Literal("compaction"),
compact_threshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
),
),
}
const OpenAIResponsesBody = Schema.Struct({
@@ -125,15 +142,14 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
)
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return yield* decodeBody({
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
...(yield* OpenResponses.lowerConversation(request, extension)),
...OpenResponses.lowerGeneration(request),
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
tools:
request.tools.length === 0
? undefined
@@ -141,7 +157,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
})
})
@@ -223,6 +240,7 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: ResponsesCompaction.make(extension),
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
@@ -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}`)
})
@@ -0,0 +1,174 @@
import { Effect, Schema, Stream } from "effect"
import {
AIError,
InvalidProviderOutputError,
CompactionPart,
CompactionResponse,
HttpOptions,
LLMRequest,
Message,
type ContentPart,
mergeJsonRecords,
} from "../../schema/index.js"
import type { CompactOperation } from "../../route/client.js"
import { Endpoint } from "../../route/endpoint.js"
import { RequestExecutor } from "../../route/executor.js"
import { HttpTransport } from "../../route/transport/index.js"
import { OpenResponses } from "../open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
const Body = Schema.Struct({
model: Schema.String,
input: Schema.Array(Schema.Unknown),
instructions: optionalNull(Schema.String),
previous_response_id: optionalNull(Schema.String),
service_tier: optionalNull(Schema.String),
prompt_cache_key: optionalNull(Schema.String),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
),
})
const Text = Schema.Union([OpenResponses.OpenResponsesInputText, OpenResponses.OpenResponsesOutputText])
const File = Schema.Union([
Schema.Struct({
...OpenResponses.OpenResponsesInputFile.fields,
file_url: Schema.String,
file_data: Schema.optional(Schema.Never),
}),
Schema.Struct({
...OpenResponses.OpenResponsesInputFile.fields,
file_data: Schema.String,
file_url: Schema.optional(Schema.Never),
}),
])
const MessageFields = {
type: Schema.Literal("message"),
id: Schema.optional(Schema.String),
status: Schema.optional(Schema.String),
phase: Schema.optional(OpenResponses.MessagePhase),
}
const Response = Schema.Struct({
object: Schema.Literal("response.compaction"),
output: Schema.Array(
Schema.Union([
OpenResponses.CompactionItem,
OpenResponses.OpenResponsesReasoningItem,
Schema.Struct({
...MessageFields,
role: Schema.Literal("user"),
content: Schema.Array(Schema.Union([Text, OpenResponses.OpenResponsesInputImage, File])).check(
Schema.isMinLength(1),
),
}),
Schema.Struct({
...MessageFields,
role: Schema.Literal("assistant"),
content: Schema.Array(Text).check(Schema.isMinLength(1)),
}),
]),
),
usage: Schema.optional(Schema.StructWithRest(OpenResponses.OpenResponsesUsage, [JsonObject])),
})
export const make = (extension: OpenResponses.Extension): CompactOperation =>
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
const route = request.model.route
const native = yield* OpenResponses.lowerConversation(request, extension)
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
mergeJsonRecords(
{
...native,
service_tier: request.providerOptions?.serviceTier,
prompt_cache_key: ProviderShared.promptCacheKey(request),
},
request.http?.body,
),
)
const url = Endpoint.render(route.endpoint, { request, body: native })
url.pathname = `${url.pathname.replace(/\/$/, "")}/compact`
const parts = yield* HttpTransport.jsonRequestParts({
request: LLMRequest.update(request, {
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
}),
body,
endpoint: Endpoint.path(url.toString()),
auth: route.auth,
encodeBody: Schema.encodeSync(Schema.fromJsonString(Body)),
})
const response = yield* executor.execute(
ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
options?.http,
)
const text = yield* RequestExecutor.responseStream(response).pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(text, chunk) => text + chunk,
),
)
const invalid = (message: string, cause?: unknown) =>
new AIError({
reason: new InvalidProviderOutputError({
route: route.id,
message,
body: text,
cause,
http: RequestExecutor.responseHttp(response),
}),
})
const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(text).pipe(
Effect.mapError((cause) => invalid("Invalid compaction response", cause)),
)
if (!result.output.some((item) => item.type === "compaction"))
return yield* invalid("Compaction response did not contain a checkpoint")
return new CompactionResponse({
messages: result.output.map((item) => toMessage(item, request.model)),
usage: OpenResponses.mapUsage(result.usage, OpenResponses.metadataKey(request.model)),
})
})
function toMessage(item: (typeof Response.Type.output)[number], model: LLMRequest["model"]): Message {
if (item.type === "compaction")
return Message.assistant(
CompactionPart.make({ provider: model.provider, id: item.id ?? undefined, encrypted: item.encrypted_content }),
)
const key = OpenResponses.metadataKey(model)
if (item.type === "reasoning") {
const summary = item.summary.length ? item.summary : [{ text: "" }]
return Message.assistant(
summary.map((part) => ({
type: "reasoning" as const,
text: part.text,
providerMetadata: { [key]: { itemId: item.id, reasoningEncryptedContent: item.encrypted_content } },
})),
)
}
return Message.make({
role: item.role,
providerMetadata: { [key]: { itemId: item.id, type: item.type, status: item.status, phase: item.phase } },
content: item.content.map((part): ContentPart => {
if (part.type === "input_text" || part.type === "output_text") return { type: "text", text: part.text }
if (part.type === "input_image")
return {
type: "media",
data: part.image_url,
mediaType: /^data:([^;,]+)/.exec(part.image_url)?.[1] ?? "image/*",
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
}
const data = part.file_url === undefined ? part.file_data : part.file_url
return {
type: "media",
data,
filename: part.filename,
mediaType: /^data:([^;,]+)/.exec(data)?.[1] ?? "application/octet-stream",
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
}
}),
})
}
export * as ResponsesCompaction from "./responses-compaction.js"
@@ -4,6 +4,7 @@ import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
@@ -44,6 +45,10 @@ const extension = {
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
if (request.providerOptions?.contextManagement !== undefined)
return yield* ProviderShared.invalidRequest(
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
)
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
@@ -84,4 +89,6 @@ export const protocol = Protocol.make({
},
})
export const compact = ResponsesCompaction.make(extension)
export * as XAIResponses from "./xai-responses.js"
+3 -1
View File
@@ -1,4 +1,5 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactOperation } from "./route/client.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@@ -9,8 +10,9 @@ export interface Settings extends Readonly<Record<string, unknown>> {
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
}
export * as ProviderPackage from "./provider-package.js"
+21 -9
View File
@@ -1,9 +1,11 @@
import type { RouteDefaultsInput } from "../route/client.js"
import type { Route, RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockMessages } from "../protocols/bedrock-messages.js"
import type { AnthropicMessages } from "../protocols/anthropic-messages.js"
export const id = ProviderID.make("amazon-bedrock")
@@ -25,38 +27,40 @@ export interface Settings extends ProviderPackage.Settings {
readonly region?: string
readonly topP?: number
}
export const routes = [BedrockConverse.route]
export const routes = [BedrockConverse.route, BedrockMessages.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
const configuredRoute = (input: Config) => {
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
return BedrockConverse.route.with({
return route.with({
...rest,
provider: id,
providerMetadataKey: "bedrock",
providerMetadataKey: route.providerMetadataKey,
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
})
}
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
const route = configuredRoute(BedrockConverse.route, input)
const messages = configuredRoute(BedrockMessages.route, input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
messages: (modelID: string | ModelID) => messages.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
return configure({
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
@@ -64,5 +68,13 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
region: settings.region,
}).model(modelID)
}
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).model(modelID)
export const messagesModel: ProviderPackage.Definition<
Settings & { readonly providerOptions?: AnthropicMessages.ProviderOptionsInput },
AnthropicMessages.ProviderOptionsInput
>["model"] = (modelID, settings) =>
configure({ ...config(settings), providerOptions: settings.providerOptions }).messages(modelID)
@@ -0,0 +1 @@
export { messagesModel as model } from "../amazon-bedrock.js"
+11 -6
View File
@@ -1,7 +1,7 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -102,7 +102,11 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
) =>
route.with({
auth: auth(input),
endpoint: endpoint(input, modelID),
@@ -161,10 +165,11 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
CompactOperation
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
@@ -57,7 +57,9 @@ const route = Route.make({
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
framing: AnthropicMessages.framing,
transport: AnthropicMessages.transport<
Omit<AnthropicMessages.AnthropicMessagesBody, "model"> & { readonly anthropic_version: typeof VERSION }
>(),
headers: () => ({ "anthropic-version": HEADER_VERSION }),
})
+1
View File
@@ -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"
+51
View File
@@ -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"
@@ -1,10 +1,12 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
import type { ContextManagement } from "../protocols/openai-responses.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
readonly contextManagement?: ContextManagement
readonly serviceTier?: OpenAIServiceTier
readonly [key: string]: unknown
}
+9 -3
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -73,7 +73,10 @@ const defaults = (input: Config) => {
return rest
}
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
route.with({
auth: auth(input),
endpoint: { baseURL: input.baseURL, query: input.queryParams },
@@ -129,7 +132,10 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
return configure(config(settings)).responses(modelID)
}
+7 -3
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
@@ -13,7 +13,7 @@ import type { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = OpenAIOptionsInput
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
@@ -32,6 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: XAIResponses.compact,
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
@@ -102,7 +103,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+92 -20
View File
@@ -7,11 +7,13 @@ 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"
import {
AIError,
CompactionResponse,
AIErrorReason,
GenerationOptions,
HttpOptions,
@@ -33,7 +35,12 @@ export interface RouteBody<Body> {
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
}
export interface Route<Body, Prepared = unknown> {
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly compact: Compact
readonly id: string
readonly provider?: ProviderID
/** ProviderMetadata namespace emitted and consumed by this route. */
@@ -41,13 +48,15 @@ export interface Route<Body, Prepared = unknown> {
readonly protocol: ProtocolID
readonly endpoint: Endpoint.Definition<Body>
readonly auth: Auth.Definition
/** Deployment headers resolved once for every operation, before transport authentication. */
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options>
) => LanguageModel<Options, Compact>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
@@ -65,7 +74,11 @@ export interface Route<Body, Prepared = unknown> {
// Normal call sites use `OpenAIChat.route`; callers only need body types
// when preparing a request with a protocol-specific type assertion.
// oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute = Route<any, any>
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
any,
any,
Compact
>
export type HttpOptionsInput = HttpOptions.Input
@@ -98,15 +111,15 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
route: AnyRoute<Compact>,
mapped: RouteMappedLanguageModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options>({
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
route,
@@ -149,6 +162,10 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
readonly compact: (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -166,24 +183,38 @@ export interface GenerateMethod {
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
}
export type CompactOperation = (
request: LLMRequest,
executor: RequestExecutor.Interface,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
export type CompactionRequest = LLMRequest<LanguageModel<ProviderOptions, CompactOperation>>
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
request.model.route.compact !== undefined
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),
})
}
export interface MakeInput<Body, Frame, Event, State> {
readonly compact?: CompactOperation
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -205,6 +236,7 @@ export interface MakeInput<Body, Frame, Event, State> {
}
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
readonly compact?: CompactOperation
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -280,12 +312,14 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
const route: Route<Body, Prepared> = {
compact: routeInput.compact,
id: routeInput.id,
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
providerMetadataKey: routeInput.providerMetadataKey,
protocol: protocol.id,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
headers: routeInput.headers,
transport: routeInput.transport,
defaults: routeInput.defaults ?? {},
body: protocol.body,
@@ -307,7 +341,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options>(route, input),
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@@ -315,7 +349,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
middleware: options?.http,
webSocket: options?.webSocket,
}),
@@ -405,6 +438,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
}
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, Prepared, CompactOperation>
export function make<Body, Frame, Event, State>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
@@ -432,6 +471,7 @@ export function make<Body, Prepared, Frame, Event, State>(
if ("transport" in input) return makeFromTransport(input)
const protocol = input.protocol
return makeFromTransport({
compact: input.compact,
id: input.id,
provider: input.provider,
providerMetadataKey: input.providerMetadataKey,
@@ -444,9 +484,19 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const prepareRequest = (request: LLMRequest) => {
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 headers = resolved.model.route.headers?.({ request: resolved })
return headers === undefined
? resolved
: LLMRequest.update(resolved, { http: mergeHttpOptions(new HttpOptions({ headers }), resolved.http) })
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const resolved = prepareRequest(request)
const route = resolved.model.route
const body = yield* route.body
@@ -505,6 +555,15 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
})
}
export const compact = (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
): Effect.Effect<CompactionResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.compact(request, options)
})
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
@@ -515,16 +574,29 @@ export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const stream = streamRequestWith({
http: yield* RequestExecutor.Service,
const executor = yield* RequestExecutor.Service
const stream = streamRequestWith({ http: executor })
return Service.of({
stream,
generate: generateWith(stream),
compact: (request, options) =>
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.invalidRequest(
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
)
return operation(prepareRequest(request), executor, options)
}),
})
return Service.of({ stream, generate: generateWith(stream) })
}),
)
export const Route = { make } as const
export const LLMClient = {
canCompact,
compact,
Service,
layer,
stream,
+18 -4
View File
@@ -3,6 +3,7 @@ import { LLM } from "@opencode-ai/schema/llm"
import { ContentBlockID, ToolCallID } from "./ids.js"
import {
Message,
CompactionPart,
ProviderMetadata,
ToolCallPart,
ToolOutput,
@@ -62,6 +63,8 @@ export { ProviderMetadata } from "./messages.js"
* Matches the same escape-hatch field on `LLMEvent`.
*/
export class Usage extends Schema.Class<Usage>("AI.Usage")({
/** Effective input size of the final message iteration, when reported; not billed totals. */
contextTokens: Schema.optional(Schema.Number),
inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number),
@@ -72,7 +75,7 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
providerMetadata: Schema.optional(ProviderMetadata),
}) {
/**
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
* Non-reasoning output tokens (including compaction summaries) — `outputTokens` minus `reasoningTokens`, clamped
* to zero. The one place subtraction happens in this contract; the clamp
* means a provider reporting `reasoningTokens > outputTokens` produces a
* harmless zero rather than a negative that crashes downstream schemas.
@@ -88,6 +91,12 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
export type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
/** A replacement context window. Replace prior history with these messages. */
export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.CompactionResponse")({
messages: Schema.Array(Message),
usage: Schema.optional(Usage),
}) {}
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
index: Schema.Number,
@@ -241,6 +250,7 @@ export const ProviderErrorEvent = Schema.Struct({
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
const llmEventTagged = Schema.Union([
CompactionPart,
StepStart,
TextStart,
TextDelta,
@@ -274,6 +284,7 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
* `events.filter(LLMEvent.guards["tool-call"])`.
*/
export const LLMEvent = Object.assign(llmEventTagged, {
compaction: CompactionPart.make,
stepStart: StepStart.make,
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
@@ -311,6 +322,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
}),
providerError: ProviderErrorEvent.make,
is: {
compaction: llmEventTagged.guards.compaction,
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
textDelta: llmEventTagged.guards["text-delta"],
@@ -333,10 +345,10 @@ export const LLMEvent = Object.assign(llmEventTagged, {
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
const joinFragments = (
events: ReadonlyArray<LLMEvent>,
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
isDelta: (event: LLMEvent) => event is LLMEvent & { id: string; text: string },
isEnd: (event: LLMEvent) => event is LLMEvent & { id: string; text?: string },
) => {
const order: string[] = []
const parts = new Map<string, string>()
@@ -563,6 +575,8 @@ const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState =>
const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => {
const next = appendEvent(state, event)
switch (event.type) {
case "compaction":
return appendContent(next, event)
case "text-start":
return ensureText(next, event.id, event.providerMetadata)
case "text-delta":
+67 -14
View File
@@ -7,9 +7,10 @@ import {
HttpOptions,
JsonSchema,
LanguageModelSchema,
type LanguageModel,
ProviderOptions,
} from "./options.js"
import { isRecord } from "../utils/record.js"
import { ProviderID } from "./ids.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
@@ -53,14 +54,10 @@ export const MediaPart = Schema.Struct({
filename: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value
const toolResultValueSchema = Schema.Union([
Schema.Struct({
type: Schema.Literal("json"),
@@ -80,6 +77,7 @@ const toolResultValueSchema = Schema.Union([
}),
]).annotate({ identifier: "LLM.ToolResult" })
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
const isToolResultValue = Schema.is(toolResultValueSchema)
export const ToolResultValue = Object.assign(toolResultValueSchema, {
is: isToolResultValue,
@@ -190,9 +188,40 @@ export const ReasoningPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Reasoning" })
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
Schema.toTaggedUnion("type"),
)
/** A provider-generated context checkpoint, distinct from visible assistant text. */
type CompactionContent =
| { readonly encrypted: string; readonly text?: never }
| { readonly text: string | null; readonly encrypted?: never }
const compactionPartSchema = Schema.Struct({
type: Schema.Literal("compaction"),
provider: ProviderID,
id: Schema.optional(Schema.String),
encrypted: Schema.optional(Schema.String),
/** Null means the provider failed to produce a summary; prior history must be retained. */
text: Schema.optional(Schema.NullOr(Schema.String)),
})
.pipe(
Schema.refine(
(part): part is typeof part & CompactionContent => (part.encrypted !== undefined) !== (part.text !== undefined),
{ message: "Compaction requires either encrypted content or a summary" },
),
)
.annotate({ identifier: "LLM.Content.Compaction" })
export type CompactionPart = typeof compactionPartSchema.Type
export const CompactionPart = Object.assign(compactionPartSchema, {
make: (input: Omit<CompactionPart, "type" | "encrypted" | "text"> & CompactionContent): CompactionPart =>
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
})
export const ContentPart = Schema.Union([
TextPart,
MediaPart,
ToolCallPart,
ToolResultPart,
ReasoningPart,
CompactionPart,
]).pipe(Schema.toTaggedUnion("type"))
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
export class Message extends Schema.Class<Message>("LLM.Message")({
@@ -200,6 +229,7 @@ export class Message extends Schema.Class<Message>("LLM.Message")({
role: MessageRole,
content: Schema.Array(ContentPart),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
@@ -277,7 +307,7 @@ export namespace ToolChoice {
}
}
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
const requestSchema = Schema.Struct({
id: Schema.optional(Schema.String),
model: LanguageModelSchema,
system: Schema.Array(SystemPart),
@@ -291,12 +321,26 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
// Stable cache affinity for protocols that support provider-managed prompt caching.
promptCacheKey: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
})
export class LLMRequest<Model extends LanguageModel = LanguageModel> extends Schema.Class<LLMRequest>("LLM.Request")(
requestSchema.fields,
) {
declare readonly model: Model
// Preserve model inference instead of inheriting the schema's erased constructor signature.
// oxlint-disable-next-line no-useless-constructor
constructor(input: LLMRequest.Input<Model>) {
super(input)
}
}
export namespace LLMRequest {
export type Input = ConstructorParameters<typeof LLMRequest>[0]
export type Input<Model extends LanguageModel = LanguageModel> = Omit<typeof requestSchema.Type, "model"> & {
readonly model: Model
}
export const input = (request: LLMRequest): Input => ({
export const input = <Model extends LanguageModel>(request: LLMRequest<Model>): Input<Model> => ({
id: request.id,
model: request.model,
system: request.system,
@@ -311,7 +355,16 @@ export namespace LLMRequest {
metadata: request.metadata,
})
export const update = (request: LLMRequest, patch: Partial<Input>) => {
export function update<Model extends LanguageModel>(
request: LLMRequest,
patch: Partial<Input<Model>> & { readonly model: Model },
): LLMRequest<Model>
export function update<Model extends LanguageModel>(
request: LLMRequest<Model>,
patch: Partial<Omit<Input, "model">> & { readonly model?: undefined },
): LLMRequest<Model>
export function update(request: LLMRequest, patch: Partial<Input>): LLMRequest
export function update(request: LLMRequest, patch: Partial<Input>) {
if (Object.keys(patch).length === 0) return request
return new LLMRequest({
...input(request),
+35 -11
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute } from "../route/client.js"
import type { AnyRoute, CompactOperation } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -173,15 +173,18 @@ export namespace LanguageModelCompatibility {
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
}
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
export class LanguageModel<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly route: AnyRoute<Compact>
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
constructor(input: LanguageModel.ConstructorInput) {
constructor(input: LanguageModel.ConstructorInput<Compact>) {
this.id = input.id
this.provider = input.provider
this.route = input.route
@@ -189,8 +192,11 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
this.compatibility = input.compatibility
}
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
return new LanguageModel<Options>({
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
>(input: LanguageModel.Input<Compact>) {
return new LanguageModel<Options, Compact>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
@@ -200,7 +206,9 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
})
}
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
return {
id: model.id,
provider: model.provider,
@@ -210,25 +218,41 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
}
}
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input>,
): LanguageModel<Options>
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
if (Object.keys(patch).length === 0) return model
return LanguageModel.make<Options>({
...LanguageModel.input(model),
...patch,
route: patch.route ?? model.route,
})
}
}
export namespace LanguageModel {
export type ConstructorInput = {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly route: AnyRoute<Compact>
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
}
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly defaults?: LanguageModelDefaults.Input
@@ -267,7 +291,7 @@ export const CachePolicyObject = Schema.Struct({
Schema.Union([
Schema.Literal("latest-user-message"),
Schema.Literal("latest-assistant"),
Schema.Struct({ tail: Schema.Number }),
Schema.Struct({ tail: Schema.Natural }),
]),
),
ttlSeconds: Schema.optional(Schema.Number),
+25 -11
View File
@@ -4,6 +4,7 @@ import { LLMClient } from "./route/client.js"
import {
LLMEvent,
LLMResponse,
CompactionResponse,
type FinishReasonDetails,
type AIError,
type LLMRequest,
@@ -12,7 +13,7 @@ import {
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -99,8 +100,6 @@ export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
const make = (options: LayerOptions) =>
Effect.sync(() => {
const requests: LLMRequest[] = []
@@ -113,26 +112,41 @@ const make = (options: LayerOptions) =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
const stream: ClientInterface["stream"] = (request) =>
Stream.suspend(() => {
const take = (request: LLMRequest) =>
Effect.suspend(() => {
const count = requests.push(options.transformRequest?.(request) ?? request)
const waiting = started
started = Deferred.makeUnsafe()
const gate = activeGate
try {
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
const streamed = toStream(response)
if (!gate) return streamed
return Stream.unwrap(
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
)
if (!response) return Effect.die(new Error(`TestLLM has no response for request ${count}`))
if (!gate) return Effect.succeed(response)
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
} finally {
// Waiters can resume synchronously; assign the reply and gate before notifying them.
Deferred.doneUnsafe(waiting, Effect.void)
}
})
const stream: ClientInterface["stream"] = (request) =>
Stream.unwrap(
take(request).pipe(
Effect.map((response) => {
if (response instanceof CompactionResponse)
return Stream.die("TestLLM generation requires an event response")
return Stream.isStream(response) ? response : Stream.fromIterable(response)
}),
),
)
const test = Test.of({
compact: (request) =>
take(request).pipe(
Effect.flatMap((response) =>
response instanceof CompactionResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse"),
),
),
stream,
generate: (request) =>
stream(request).pipe(
+75
View File
@@ -0,0 +1,75 @@
import { Message, ToolResultPart, type ToolCallPart } from "./schema/messages.js"
const EMPTY_TOOL_OUTPUT = "(no tool output)"
const MISSING_TOOL_RESULT = "Tool result missing"
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
const normalized: Message[] = []
const pending = new Map<string, ToolCallPart>()
const appendMissingResults = () => {
if (pending.size === 0) return
normalized.push(missingToolResults(pending.values()))
pending.clear()
}
for (const message of messages) {
if (message.role === "user" || message.role === "assistant") appendMissingResults()
if (message.role === "tool") {
const tool = normalizeToolMessage(message, pending)
if (tool) normalized.push(tool)
continue
}
normalized.push(message)
if (message.role !== "assistant") continue
for (const part of message.content) {
if (part.type === "tool-call" && part.providerExecuted !== true) pending.set(part.id, part)
}
}
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,
providerMetadata: message.providerMetadata,
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 } }
}
+32
View File
@@ -126,6 +126,38 @@ describe("applyCachePolicy", () => {
}),
)
for (const fixture of [
{ name: "default", cache: undefined, control: { type: "ephemeral" } },
{ name: "auto", cache: "auto", control: { type: "ephemeral" } },
{
name: "explicit one-hour",
cache: { tools: true, system: true, messages: { tail: 1 }, ttlSeconds: 3600 },
control: { type: "ephemeral", ttl: "1h" },
},
{ name: "disabled", cache: "none", control: undefined },
] as const) {
it.effect(`Bedrock Messages respects ${fixture.name} caching`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("anthropic.claude-opus-4-6-v1"),
system: "Stable instructions",
tools: [
{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } },
],
prompt: "hello",
cache: fixture.cache,
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "lookup", cache_control: fixture.control }],
system: [{ type: "text", text: "Stable instructions", cache_control: fixture.control }],
messages: [{ role: "user", content: [{ type: "text", text: "hello", cache_control: fixture.control }] }],
})
}),
)
}
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+97
View File
@@ -0,0 +1,97 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { LLM, LLMRequest, Message } from "../src/index.js"
import { LLMClient } from "../src/route/client.js"
import { OpenAI } from "../src/providers.js"
import { testEffect } from "./lib/effect.js"
import { runtimeLayer } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
testEffect(runtimeLayer(FetchHttpClient.layer)).live("compaction and a tool loop work end to end over HTTP", () =>
Effect.gen(function* () {
const checkpoint = { type: "compaction", id: "cmp_local", encrypted_content: "opaque-local-state" }
const calls: string[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const path = new URL(request.url).pathname
calls.push(path)
const body = await request.json()
expect(request.headers.get("authorization")).toBe("Bearer fixture")
if (path === "/v1/responses/compact") {
expect(body.stream).toBeUndefined()
return Response.json({
object: "response.compaction",
output: [checkpoint],
usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 },
})
}
expect(body.input[0]).toEqual(checkpoint)
expect(body.stream).toBe(true)
if (calls.length === 2)
return new Response(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
{ headers: { "content-type": "text/event-stream" } },
)
expect(body.input.at(-2)).toMatchObject({ type: "function_call", call_id: "call_1" })
expect(body.input.at(-1)).toEqual({ type: "function_call_output", call_id: "call_1", output: "42" })
const output = sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "The answer is 42." },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "The answer is 42." }] },
},
{ type: "response.completed", response: { id: "resp_2" } },
)
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(output.slice(0, 37)))
controller.enqueue(new TextEncoder().encode(output.slice(37)))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
}),
),
(server) => Effect.sync(() => server.stop(true)),
)
const model = OpenAI.configure({ apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` }).responses(
"fixture",
)
const request = LLM.request({
model,
prompt: "original",
tools: [{ name: "lookup", description: "Lookup a number", inputSchema: { type: "object", properties: {} } }],
})
const compacted = yield* LLMClient.compact(request)
const messages = [...compacted.messages, Message.user("Look up the answer")]
const first = yield* LLMClient.generate(LLMRequest.update(request, { messages }))
expect(first.toolCalls).toHaveLength(1)
const call = first.toolCalls[0]!
const last = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: "42", resultType: "text" }),
],
}),
)
expect(last.text).toBe("The answer is 42.")
expect(calls).toEqual(["/v1/responses/compact", "/v1/responses", "/v1/responses"])
}),
)
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { CompactionPart, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
import { OpenAI, Anthropic } from "../src/providers.js"
test("runtime capability checks follow model and route updates", () => {
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
const unsupported = Anthropic.configure({ apiKey: "test" }).model("fixture")
const request = LLM.request({ model: supported, prompt: "hello" })
expect(LLMClient.canCompact(request)).toBe(true)
expect(LLMClient.canCompact(LLMRequest.update(request, { messages: [] }))).toBe(true)
expect(LLMClient.canCompact(LLMRequest.update(request, { model: unsupported }))).toBe(false)
expect(
LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: unsupported.route }) })),
).toBe(false)
expect(LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: undefined }) }))).toBe(true)
})
test("compaction survives event assembly and message serialization without becoming text", () => {
const part = CompactionPart.make({
provider: ProviderID.make("openai"),
id: "cmp_1",
encrypted: "opaque",
})
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "before" }),
LLMEvent.textDelta({ id: "before", text: "Before" }),
LLMEvent.textEnd({ id: "before" }),
part,
LLMEvent.textStart({ id: "after" }),
LLMEvent.textDelta({ id: "after", text: "After" }),
LLMEvent.textEnd({ id: "after" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])!
expect(response.message.content.map((part) => part.type)).toEqual(["text", "compaction", "text"])
expect(response.text).toBe("BeforeAfter")
expect(response.reasoning).toBe("")
expect(response.events.filter(LLMEvent.is.compaction)).toEqual([part])
const codec = Schema.fromJsonString(Message)
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(response.message))).toEqual(response.message)
})
test("compaction requires exactly one typed representation", () => {
const provider = ProviderID.make("anthropic")
expect(CompactionPart.make({ provider, text: null })).toEqual({ type: "compaction", provider, text: null })
const decode = Schema.decodeUnknownSync(CompactionPart)
expect(() => decode({ type: "compaction", provider })).toThrow()
expect(() => decode({ type: "compaction", provider, text: "summary", encrypted: "opaque" })).toThrow()
})
test("tagged content and event guards accept both checkpoint representations", () => {
for (const part of [
CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "opaque" }),
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "summary" }),
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: null }),
]) {
expect(LLMEvent.is.compaction(part)).toBe(true)
expect(LLMEvent.guards.compaction(part)).toBe(true)
const codec = Schema.fromJsonString(Message)
const message = Message.assistant(part)
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message)
}
})
+50 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, 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({
@@ -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"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -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,154 @@
import { Effect } from "effect"
import {
CompactionPart,
LanguageModel,
LLM,
LLMClient,
LLMEvent,
LLMRequest,
Message,
ProviderID,
} from "../../src/index.js"
import {
OpenAI,
Azure,
XAI,
Anthropic,
AmazonBedrock,
AmazonBedrockMantle,
OpenAICompatibleResponses,
} from "../../src/providers.js"
const openai = OpenAI.configure({
apiKey: "test",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
}).responses("gpt-5.3-codex")
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" }))
for (const model of [
OpenAI.configure().responses("fixture"),
Azure.configure({ resourceName: "test" }).responses("fixture"),
XAI.configure().responses("fixture"),
OpenAI.model("fixture", {}),
Azure.responsesModel("fixture", { resourceName: "test" }),
XAI.model("fixture", {}),
openai.route.with({ headers: { "x-test": "test" } }).model({ id: "fixture" }),
LanguageModel.update(openai, { defaults: { generation: { maxTokens: 100 } } }),
LanguageModel.make(LanguageModel.input(openai)),
]) {
LLMClient.compact(LLM.request({ model, prompt: "hello" }))
}
const unsupported = {
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
openaiChat: LLM.request({ model: OpenAI.configure().chat("fixture") }),
azureChat: LLM.request({ model: Azure.configure({ resourceName: "test" }).chat("fixture") }),
xaiChat: LLM.request({ model: XAI.configure().chat("fixture") }),
converse: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
bedrockMessages: LLM.request({ model: AmazonBedrock.configure().messages("fixture") }),
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
compatible: LLM.request({
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
}),
}
// @ts-expect-error Anthropic has no standalone compact endpoint.
LLMClient.compact(unsupported.anthropic)
// @ts-expect-error Chat does not expose Responses compaction.
LLMClient.compact(unsupported.openaiChat)
// @ts-expect-error Azure Chat does not expose Responses compaction.
LLMClient.compact(unsupported.azureChat)
// @ts-expect-error xAI Chat does not expose Responses compaction.
LLMClient.compact(unsupported.xaiChat)
// @ts-expect-error Converse has no standalone compact endpoint.
LLMClient.compact(unsupported.converse)
// @ts-expect-error Bedrock Messages has no standalone compact endpoint.
LLMClient.compact(unsupported.bedrockMessages)
// @ts-expect-error Mantle does not inherit the OpenAI compact endpoint.
LLMClient.compact(unsupported.mantle)
// @ts-expect-error Protocol compatibility does not guarantee endpoint support.
LLMClient.compact(unsupported.compatible)
LLMClient.Service.use((client) => {
// @ts-expect-error The service enforces the same capability as the convenience function.
return client.compact(unsupported.anthropic)
})
const request = LLM.request({ model: openai, prompt: "hello" })
LLMClient.compact(LLMRequest.update(request, { messages: [Message.user("continue")] }))
LLMClient.compact(new LLMRequest(LLMRequest.input(request)))
const switched = LLMRequest.update(request, { model: Anthropic.configure().model("fixture") })
// @ts-expect-error Switching models replaces, rather than inherits, the capability.
LLMClient.compact(switched)
LLMClient.compact(LLMRequest.update(switched, { model: openai }))
LLMClient.compact(
// @ts-expect-error Replacing the route also replaces compaction capability.
LLM.request({ model: LanguageModel.update(openai, { route: Anthropic.configure().model("fixture").route }) }),
)
declare const dynamicModel: LanguageModel
declare const dynamicPatch: Partial<LLMRequest.Input>
const dynamicRequest = LLM.request({ model: dynamicModel, prompt: "hello" })
// @ts-expect-error A dynamically selected model must be narrowed first.
LLMClient.compact(dynamicRequest)
if (LLMClient.canCompact(dynamicRequest)) LLMClient.compact(dynamicRequest)
// @ts-expect-error An optional model override cannot retain the old capability statically.
LLMClient.compact(LLMRequest.update(request, dynamicPatch))
const checkpoint = CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_1", encrypted: "opaque" })
const provider = ProviderID.make("anthropic")
CompactionPart.make({ provider, text: "summary" })
CompactionPart.make({ provider, text: null })
// @ts-expect-error A checkpoint must have a representation.
CompactionPart.make({ provider })
// @ts-expect-error Encrypted and summary representations are mutually exclusive.
CompactionPart.make({ provider, encrypted: "opaque", text: "summary" })
// @ts-expect-error A failed summary cannot also carry encrypted content.
LLMEvent.compaction({ provider, encrypted: "opaque", text: null })
// @ts-expect-error The canonical message type also enforces the invariant.
Message.assistant({ type: "compaction", provider })
if (checkpoint.encrypted !== undefined) {
checkpoint.encrypted satisfies string
checkpoint.text satisfies undefined
}
if (checkpoint.text !== undefined) {
checkpoint.text satisfies string | null
checkpoint.encrypted satisfies undefined
}
checkpoint.encrypted
// @ts-expect-error Compaction parts do not contain a generic provider payload.
checkpoint.value
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" })).pipe(
Effect.map((result) => {
result.messages
// @ts-expect-error Compaction returns replacement history, not a synthetic assistant message.
result.message
}),
)
LLM.request({
model: openai,
providerOptions: {
// @ts-expect-error A token threshold is numeric.
contextManagement: [{ type: "compaction", compactThreshold: "100000" }],
},
})
for (const model of [
Anthropic.configure().model("claude-opus-4-6"),
AmazonBedrock.configure().messages("anthropic.claude-opus-4-6-v1"),
]) {
LLM.request({
model,
providerOptions: {
contextManagement: {
edits: [
{ type: "compact_20260112", pauseAfterCompaction: true, instructions: "Summarize without using tools" },
],
},
},
})
LLM.request({
model,
providerOptions: {
// @ts-expect-error A pause setting is boolean.
contextManagement: { edits: [{ type: "compact_20260112", pauseAfterCompaction: "yes" }] },
},
})
}
@@ -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" },
})
@@ -0,0 +1,170 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { Anthropic, GoogleVertexMessages } from "../../src/providers/index.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
for (const fixture of [
{
name: "empty iterations fall back to top-level usage",
usage: { input_tokens: 2, output_tokens: 3, cache_read_input_tokens: null, iterations: [] },
expected: { inputTokens: 2, outputTokens: 3, totalTokens: 5, contextTokens: undefined },
},
{
name: "compaction-only usage has no post-compaction context size",
usage: {
input_tokens: 0,
output_tokens: 0,
iterations: [{ type: "compaction", input_tokens: 7, cache_read_input_tokens: 3, output_tokens: 2 }],
},
expected: { inputTokens: 10, outputTokens: 2, totalTokens: 12, contextTokens: undefined },
},
{
name: "partially reported iterations preserve known totals",
usage: {
iterations: [
{ type: "compaction", input_tokens: 7, cache_creation_input_tokens: 2 },
{ type: "message", output_tokens: 3 },
],
},
expected: { inputTokens: 9, outputTokens: 3, totalTokens: 12, contextTokens: undefined },
},
{
name: "missing counters remain unknown rather than zero",
usage: { iterations: [{ type: "message" }] },
expected: { inputTokens: undefined, outputTokens: undefined, totalTokens: undefined, contextTokens: undefined },
},
]) {
testEffect(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: fixture.usage } },
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
{ type: "message_stop" },
),
),
).effect(fixture.name, () =>
Effect.gen(function* () {
const result = yield* LLMClient.generate(
LLM.request({
model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
prompt: "hello",
}),
)
expect(result.usage).toMatchObject(fixture.expected)
}),
)
}
for (const model of [
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
GoogleVertexMessages.configure({ accessToken: "test", project: "test" }).model("claude-opus-4-6"),
]) {
for (const summary of ["Summary of the conversation", null]) {
const block = { type: "compaction", content: summary }
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
if (body.messages.length === 1) {
expect(body.context_management.edits).toEqual([
{
type: "compact_20260112",
trigger: { type: "input_tokens", value: 50000 },
pause_after_compaction: true,
},
])
}
if (body.messages.length > 1) {
expect(body.messages[1].content).toEqual([block])
expect(body.context_management).toBeUndefined()
}
return respond(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 50000, output_tokens: 0 } } },
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: summary } },
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "compaction" },
usage: {
input_tokens: 1000,
output_tokens: 5,
iterations: [
{ type: "compaction", input_tokens: 50000, output_tokens: 1000, cache_read_input_tokens: 10 },
{ type: "message", input_tokens: 1000, output_tokens: 5 },
],
},
},
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
).effect(
`${model.provider} replays ${summary === null ? "failed" : "successful"} compaction with billing and context usage`,
() =>
Effect.gen(function* () {
const request = LLM.request({
model,
prompt: "hello",
http: { headers: { "anthropic-beta": "existing-beta" } },
providerOptions: {
contextManagement: {
edits: [
{
type: "compact_20260112",
trigger: { type: "input_tokens", value: 50000 },
pauseAfterCompaction: true,
},
],
},
},
})
const first = yield* LLMClient.generate(request)
expect(first.finishReason.raw).toBe("compaction")
expect(first.message.content).toEqual([{ type: "compaction", provider: model.provider, text: summary }])
expect(first.text).toBe("")
expect(first.usage?.inputTokens).toBe(51010)
expect(first.usage?.outputTokens).toBe(1005)
expect(first.usage?.totalTokens).toBe(52015)
expect(first.usage?.contextTokens).toBe(1000)
const codec = Schema.fromJsonString(Message)
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
yield* LLMClient.generate(
LLMRequest.update(request, {
providerOptions: {},
messages: [...request.messages, message, Message.user("continue")],
}),
)
}),
)
}
}
for (const events of [
[{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: 42 } }],
[{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: "no start" } }],
[
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
{ type: "message_stop" },
],
]) {
testEffect(fixedResponse(sseEvents(...events))).effect(
`rejects malformed compaction lifecycle: ${JSON.stringify(events)}`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.http?.status).toBe(200)
}),
)
}
@@ -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!" },
@@ -0,0 +1,69 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers/index.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
const codec = new EventStreamCodec(toUtf8, fromUtf8)
const frame = (event: object) =>
codec.encode({
headers: { ":message-type": { type: "string", value: "event" }, ":event-type": { type: "string", value: "chunk" } },
body: new TextEncoder().encode(JSON.stringify({ bytes: Buffer.from(JSON.stringify(event)).toString("base64") })),
})
const response = Buffer.concat(
[
{ type: "message_start", message: { usage: { input_tokens: 60000 } } },
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: "Summary" } },
{ type: "content_block_stop", index: 0 },
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 10 } },
{ type: "message_stop" },
].map(frame),
)
for (const auth of [
{ apiKey: "test" },
{ credentials: { accessKeyId: "test", secretAccessKey: "test", region: "us-west-2" } },
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(request.url).toBe(
"https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-opus-4-6-v1%3A0/invoke-with-response-stream",
)
expect(request.headers.authorization).toStartWith(auth.apiKey ? "Bearer test" : "AWS4-HMAC-SHA256")
const body = JSON.parse(text)
expect(body.model).toBeUndefined()
expect(body.stream).toBeUndefined()
expect(body.anthropic_version).toBe("bedrock-2023-05-31")
expect(body.anthropic_beta).toEqual(["compact-2026-01-12"])
expect(body.context_management.edits).toEqual([{ type: "compact_20260112" }])
if (body.messages.length > 1)
expect(body.messages[1].content[0]).toEqual({ type: "compaction", content: "Summary" })
return respond(response, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
}),
),
).effect(`Bedrock Messages compaction round trip with ${auth.apiKey ? "bearer" : "SigV4"} authentication`, () =>
Effect.gen(function* () {
const model = AmazonBedrock.configure({ ...auth, region: "us-west-2" }).messages("anthropic.claude-opus-4-6-v1:0")
const request = LLM.request({
model,
prompt: "hello",
providerOptions: { contextManagement: { edits: [{ type: "compact_20260112" }] } },
})
const first = yield* LLMClient.generate(request)
expect(first.text).toBe("Hello")
expect(first.message.content.map((part) => part.type)).toEqual(["compaction", "text"])
yield* LLMClient.generate(
LLMRequest.update(request, { messages: [...request.messages, first.message, Message.user("continue")] }),
)
}),
)
}
@@ -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(
@@ -0,0 +1,166 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient, compileRequest } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers/index.js"
import { it, testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
const codec = new EventStreamCodec(toUtf8, fromUtf8)
const response = Buffer.concat(
[
{ type: "message_start", message: { usage: { input_tokens: 10 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
].map((event) =>
codec.encode({
headers: {
":message-type": { type: "string", value: "event" },
":event-type": { type: "string", value: "chunk" },
},
body: new TextEncoder().encode(JSON.stringify({ bytes: Buffer.from(JSON.stringify(event)).toString("base64") })),
}),
),
)
for (const auth of [
{ apiKey: "test" },
{ credentials: { accessKeyId: "test", secretAccessKey: "test", region: "us-west-2" } },
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(request.url).toBe(
"https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-opus-4-6-v1%3A0/invoke-with-response-stream",
)
expect(request.headers.authorization).toStartWith(auth.apiKey ? "Bearer test" : "AWS4-HMAC-SHA256")
const body = JSON.parse(text)
expect(body.model).toBeUndefined()
expect(body.stream).toBeUndefined()
expect(body.anthropic_version).toBe("bedrock-2023-05-31")
expect(body.anthropic_beta).toEqual(["existing-beta"])
if (body.messages.length > 1) expect(body.messages[1].content).toEqual([{ type: "text", text: "Hello" }])
return respond(response, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
}),
),
).effect(`Bedrock Messages text round trip with ${auth.apiKey ? "bearer" : "SigV4"} authentication`, () =>
Effect.gen(function* () {
const provider = AmazonBedrock.configure({ ...auth, region: "us-west-2" })
expect(provider.model("fixture").route.id).toBe("bedrock-converse")
const request = LLM.request({
model: provider.messages("anthropic.claude-opus-4-6-v1:0"),
prompt: "hello",
http: { headers: { "anthropic-beta": "existing-beta, existing-beta" } },
})
const first = yield* LLMClient.generate(request)
expect(first.text).toBe("Hello")
expect(first.usage?.totalTokens).toBe(12)
yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("continue")],
}),
)
}),
)
}
testEffect(
fixedResponse(
codec.encode({
headers: {
":message-type": { type: "string", value: "exception" },
":exception-type": { type: "string", value: "throttlingException" },
},
body: new TextEncoder().encode(JSON.stringify({ message: "Too many requests", trace: "keep-original" })),
}),
),
).effect("Bedrock Messages retains the original exception frame", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
prompt: "hello",
}),
).pipe(Effect.flip)
expect(error.reason.body).toContain("keep-original")
expect(error.reason.http?.status).toBe(200)
}),
)
for (const mediaType of ["image/png", "application/pdf"]) {
for (const data of ["https://example.com/media", "invalid base64!"]) {
for (const role of ["user", "tool"] as const) {
it.effect(`rejects ${role} ${mediaType} with ${data}`, () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
messages:
role === "user"
? [Message.user({ type: "media", mediaType, data })]
: [
Message.tool({
id: "call_1",
name: "read",
result: { type: "content", value: [{ type: "file", mime: mediaType, uri: data }] },
}),
],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("Bedrock Messages")
}),
)
}
}
}
it.effect("accepts inline image and document sources", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,AQID" },
{ type: "media", mediaType: "application/pdf", data: "data:application/pdf;base64,AQID" },
]),
],
}),
)
expect(prepared.body.messages[0].content.map((block: { source: { type: string } }) => block.source.type)).toEqual([
"base64",
"base64",
])
}),
)
it.effect("rejects Anthropic file IDs", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
messages: [Message.user({ type: "media", mediaType: "image/png", data: "", metadata: { file_id: "file_1" } })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("file-ID")
}),
)
it.effect("rejects unsupported image formats", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
messages: [Message.user({ type: "media", mediaType: "image/svg+xml", data: "AQID" })],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("JPEG, PNG, WebP, or GIF")
}),
)
@@ -0,0 +1,50 @@
import { expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient, WebSocketTransport } from "../../src/route.js"
import { OpenAI } from "../../src/providers.js"
import { testEffect } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
testEffect(fixedResponse("unexpected HTTP fallback")).effect(
"WebSocket responses preserve compaction options and replay state",
() =>
Effect.gen(function* () {
const checkpoint = { type: "compaction", id: "cmp_ws", encrypted_content: "opaque" }
const sent: unknown[] = []
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: (message) =>
Effect.sync(() => {
const body = JSON.parse(message)
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
expect(body.stream).toBeUndefined()
if (sent.length) expect(body.input[1]).toEqual(checkpoint)
sent.push(body)
}),
messages: Stream.fromIterable(
[
{ type: "response.created", response: { id: "resp_ws" } },
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.completed", response: { id: "resp_ws", output: [checkpoint] } },
].map((event) => JSON.stringify(event)),
),
close: Effect.void,
}),
})
const request = LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
prompt: "hello",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
})
const first = yield* LLMClient.generate(request, { webSocket })
expect(first.message.content).toHaveLength(1)
expect(first.message.content[0]?.type).toBe("compaction")
yield* LLMClient.generate(
LLMRequest.update(request, { messages: [...request.messages, first.message, Message.user("continue")] }),
{ webSocket },
)
expect(sent).toHaveLength(2)
}),
)
@@ -0,0 +1,91 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { OpenAI, XAI, Anthropic } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
const history = [
Message.user("Remember the project codename COPPER-ORBIT-42."),
Message.assistant(
"The project codename is COPPER-ORBIT-42. " + "We reviewed the implementation and tests. ".repeat(1000),
),
]
for (const provider of [
{
id: "openai",
key: "OPENAI_API_KEY",
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.3-codex"),
},
{
id: "xai",
key: "XAI_API_KEY",
model: XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }).responses("grok-4.6"),
},
]) {
recordedTests({ prefix: `${provider.id}-compaction`, provider: provider.id, requires: [provider.key] }).effect(
"compacts and continues with the provider checkpoint",
() =>
Effect.gen(function* () {
const request = LLM.request({ model: provider.model, messages: history, generation: { maxTokens: 1024 } })
const compacted = yield* LLMClient.compact(request)
const result = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...compacted.messages,
Message.user("What is the project codename? Reply only with the codename."),
],
}),
)
expect(result.text).toContain("COPPER-ORBIT-42")
}),
120000,
)
}
recordedTests({
prefix: "anthropic-compaction",
provider: "anthropic",
requires: ["ANTHROPIC_API_KEY"],
options: { redact: { allowRequestHeaders: ["anthropic-version", "anthropic-beta"] } },
}).effect(
"automatically compacts and continues after a pause",
() =>
Effect.gen(function* () {
const model = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" }).model(
"claude-sonnet-4-6",
)
const request = LLM.request({
model,
messages: [
Message.user(
"Remember the project codename COPPER-ORBIT-42. " +
"The implementation and tests were reviewed. ".repeat(10000),
),
],
generation: { maxTokens: 4096 },
providerOptions: {
contextManagement: {
edits: [
{ type: "compact_20260112", trigger: { type: "input_tokens", value: 50000 }, pauseAfterCompaction: true },
],
},
},
})
const first = yield* LLMClient.generate(request)
expect(first.finishReason.raw).toBe("compaction")
expect(first.message.content.some((part) => part.type === "compaction")).toBe(true)
const result = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.user("What is the project codename? Reply only with the codename."),
],
}),
)
expect(result.text).toContain("COPPER-ORBIT-42")
}),
120000,
)
@@ -0,0 +1,136 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { OpenAI, Azure, XAI } from "../../src/providers/index.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const response = sseEvents(
{ type: "response.output_item.done", item: checkpoint },
{
type: "response.completed",
response: { id: "resp_1", output: [checkpoint], usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } },
},
)
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
]) {
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
expect(body.store).toBe(false)
if (body.input.length > 1) expect(body.input[1]).toEqual(checkpoint)
return respond(response, { headers: { "content-type": "text/event-stream" } })
}),
),
).effect(`${model.provider} compaction survives generation, serialization, and a second request`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
prompt: "hello",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
})
const first = yield* LLMClient.generate(request)
expect(first.message.content).toHaveLength(1)
expect(first.message.content[0]?.type).toBe("compaction")
expect(first.text).toBe("")
const codec = Schema.fromJsonString(Message)
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
yield* LLMClient.generate(
LLMRequest.update(request, { messages: [...request.messages, message, Message.user("continue")] }),
)
const rejected = yield* LLMClient.generate(
LLMRequest.update(request, {
model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
providerOptions: {},
messages: [message],
}),
).pipe(Effect.flip)
expect(rejected.reason._tag).toBe("InvalidRequest")
}),
)
}
testEffect(fixedResponse(sseEvents({ type: "response.completed", response: { output: [checkpoint] } }))).effect(
"recovers compaction from the terminal output when item completion is absent",
() =>
Effect.gen(function* () {
const result = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
)
expect(result.message.content).toHaveLength(1)
expect(result.message.content[0]?.type).toBe("compaction")
}),
)
testEffect(
fixedResponse(sseEvents({ type: "response.output_item.done", item: { type: "compaction", id: "cmp_bad" } })),
).effect("rejects incomplete compaction payloads without publishing a checkpoint", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain("cmp_bad")
}),
)
const textItem = {
type: "message",
id: "msg_after",
role: "assistant",
content: [{ type: "output_text", text: "After checkpoint" }],
}
for (const completed of [false, true]) {
testEffect(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id: checkpoint.id } },
...(completed ? [{ type: "response.output_item.done", output_index: 0, item: checkpoint }] : []),
{ type: "response.output_item.added", output_index: 1, item: textItem },
{ type: "response.output_text.delta", output_index: 1, item_id: textItem.id, delta: "After checkpoint" },
{ type: "response.output_item.done", output_index: 1, item: textItem },
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, textItem] } },
),
),
).effect(completed ? "keeps streamed checkpoints before later text" : "rejects order-unsafe terminal recovery", () =>
Effect.gen(function* () {
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
if (completed) {
const response = yield* LLMClient.generate(request)
expect(response.message.content.map((part) => part.type)).toEqual(["compaction", "text"])
return
}
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Cannot recover a compaction checkpoint")
expect(error.reason.body).toContain("response.completed")
expect(error.reason.http?.status).toBe(200)
}),
)
}
testEffect(
fixedResponse(
sseEvents({
type: "response.completed",
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("rejects terminal checkpoints missing an id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("missing its id")
}),
)
@@ -0,0 +1,30 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { it } from "../lib/effect.js"
it.effect("conversation lowering excludes generation settings and tool definitions", () =>
Effect.gen(function* () {
const body = yield* OpenResponses.lowerConversation(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
system: "Keep the context",
messages: [Message.user("hello"), Message.assistant("hi")],
generation: { maxTokens: 100, temperature: 0.5 },
providerOptions: { store: false },
tools: [{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } }],
}),
{ id: "open-responses", name: "Open Responses" },
)
expect(body).toEqual({
model: "fixture",
instructions: "Keep the context",
input: [
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
],
})
}),
)
@@ -0,0 +1,447 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient, Route } from "../../src/route/client.js"
import { Auth } from "../../src/route/auth.js"
import { Endpoint } from "../../src/route/endpoint.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { OpenAI, Azure, XAI, Anthropic, AmazonBedrockMantle } from "../../src/providers/index.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const retained = {
type: "message",
role: "user",
id: "msg_1",
status: "completed",
content: [{ type: "input_text", text: "retained" }],
}
const output = [retained, checkpoint]
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(request.headers["x-deployment"]).toBe("fixture")
expect(request.headers["x-override"]).toBe("request")
expect(request.headers["x-default"]).toBe("configured")
expect(request.headers.authorization).toBe("Bearer test")
expect(new URL(request.url).searchParams.get("api-version")).toBe("fixture")
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
if (new URL(request.url).pathname.endsWith("/compact")) {
expect(JSON.parse(text)).toEqual({
model: "overlaid",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
instructions: "request instructions",
previous_response_id: "resp_previous",
})
return respond(JSON.stringify({ object: "response.compaction", output }))
}
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
}),
),
).effect("generation and compaction share deployment headers, defaults, auth, query, and middleware", () =>
Effect.gen(function* () {
const headers: string[] = []
const middleware: string[] = []
const route = Route.make({
id: "compaction-headers",
provider: "openai",
protocol: OpenAIResponses.protocol,
compact: OpenAIResponses.route.compact,
transport: OpenAIResponses.httpTransport,
endpoint: Endpoint.path(({ body }) => `/${body.model}/responses`, {
baseURL: "https://example.com",
query: { "api-version": "fixture" },
}),
auth: Auth.bearer("test"),
headers: ({ request }) => {
expect(request.providerOptions?.store).toBe(false)
headers.push(String(request.model.id))
return { "x-deployment": "fixture", "x-override": "route" }
},
defaults: {
headers: { "x-default": "configured", "x-override": "configured" },
providerOptions: { store: false },
http: { body: { instructions: "default instructions" } },
},
})
const request = LLM.request({
model: route.model({ id: "fixture" }),
prompt: "hello",
system: "system instructions",
http: {
headers: { "x-override": "request" },
query: { trace: "request" },
body: {
model: "overlaid",
instructions: "request instructions",
previous_response_id: "resp_previous",
store: false,
stream: true,
},
},
})
const options: Parameters<typeof LLMClient.compact>[1] = {
http: (request, next) => {
middleware.push(new URL(request.url).pathname)
return next(request)
},
}
yield* LLMClient.generate(request, options)
yield* LLMClient.compact(request, options)
expect(headers).toEqual(["fixture", "fixture"])
expect(middleware).toEqual(["/fixture/responses", "/fixture/responses/compact"])
}),
)
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
const item = {
type: model.provider === "xai" ? "x_search_call" : "computer_call",
id: "hosted_1",
status: "completed",
}
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
expect(JSON.parse(text)).toEqual({ model: "fixture", input: [item], instructions: "Keep the context" })
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect(`${model.provider} compacts provider-specific history without lowering generation settings`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
system: "Keep the context",
messages: [
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { [model.route.providerMetadataKey ?? model.provider]: { itemId: item.id } },
}),
],
})
for (const candidate of [
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
]) {
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
const response = yield* LLMClient.compact(candidate)
expect(response.messages[0]?.content[0]?.type).toBe("compaction")
}
}),
)
}
const retainedItems = [
retained,
{
type: "message",
id: "msg_assistant",
role: "assistant",
status: "completed",
phase: "commentary",
content: [
{ type: "output_text", text: "First" },
{ type: "output_text", text: "Second" },
],
},
{
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "Thinking" },
{ type: "summary_text", text: "More thinking" },
],
encrypted_content: "reasoning-state",
},
{ type: "reasoning", id: "rs_2", summary: [], encrypted_content: "hidden-reasoning" },
{
type: "message",
id: "msg_media",
role: "user",
content: [
{ type: "input_image", image_url: "https://example.com/image.png" },
{ type: "input_file", filename: "report.pdf", file_data: "data:application/pdf;base64,cGRm", detail: "high" },
{ type: "input_file", filename: "other.pdf", file_url: "https://example.com/report.pdf", detail: "low" },
],
},
checkpoint,
]
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
...[undefined, "custom"].map((providerMetadataKey) =>
Route.make({
id: providerMetadataKey ?? "default-metadata",
provider: "openai",
providerMetadataKey,
protocol: OpenAIResponses.protocol,
compact: OpenAIResponses.route.compact,
endpoint: OpenAIResponses.route.endpoint,
transport: OpenAIResponses.httpTransport,
}).model({ id: "fixture" }),
),
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
if (new URL(request.url).pathname.endsWith("/compact"))
return respond(JSON.stringify({ object: "response.compaction", output: retainedItems }))
expect(JSON.parse(text).input).toEqual(retainedItems)
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect(`${model.route.id} retains messages, reasoning, and media through typed conversation parts`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
prompt: "hello",
})
const compacted = yield* LLMClient.compact(request)
expect(compacted.messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"assistant",
"assistant",
"user",
"assistant",
])
expect(compacted.messages[1]?.content).toEqual([
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
])
expect(compacted.messages[2]?.content.map((part) => part.type)).toEqual(["reasoning", "reasoning"])
expect(compacted.messages[4]?.content.map((part) => part.type)).toEqual(["media", "media", "media"])
const codec = Schema.fromJsonString(Schema.Array(Message))
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.messages))
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
}),
)
}
for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key: "overridden" }]) {
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
expect(JSON.parse(text)).toEqual({
model: "fixture",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
service_tier: overlay?.service_tier ?? "flex",
prompt_cache_key: overlay?.prompt_cache_key ?? "affinity",
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
})
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect(`compact preserves supported request controls${overlay ? " with HTTP overrides" : ""}`, () =>
LLMClient.compact(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
prompt: "hello",
promptCacheKey: "affinity",
providerOptions: { serviceTier: "flex" },
generation: { maxTokens: 100 },
http: {
body: {
stream: true,
store: false,
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
...overlay,
},
},
}),
),
)
}
for (const item of [
{ type: "unknown_provider_item", data: "do not hide in a compaction part" },
{
type: "message",
role: "user",
content: [{ type: "input_image", image_url: "https://example.com/image.png", detail: 42 }],
},
{ type: "message", role: "user", content: [] },
{
type: "message",
role: "assistant",
content: [{ type: "input_image", image_url: "https://example.com/image.png" }],
},
{ type: "message", role: "user", content: [{ type: "input_file", filename: "missing.pdf" }] },
{
type: "message",
role: "user",
content: [{ type: "input_file", filename: "bad.pdf", file_url: "https://example.com/report.pdf", detail: 42 }],
},
{
type: "message",
role: "user",
content: [
{
type: "input_file",
filename: "both.pdf",
file_url: "https://example.com/report.pdf",
file_data: "data:application/pdf;base64,cGRm",
},
],
},
]) {
testEffect(fixedResponse(JSON.stringify({ object: "response.compaction", output: [item, checkpoint] }))).effect(
`rejects unsupported compact output: ${JSON.stringify(item)}`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain(JSON.stringify(item))
expect(error.reason.http?.status).toBe(200)
}),
)
}
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
const images = [undefined, "low", "high", "auto"].map((detail) => ({
type: "input_image",
image_url: "https://example.com/image.png",
...(detail === undefined ? {} : { detail }),
}))
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
if (new URL(request.url).pathname.endsWith("/compact"))
return respond(
JSON.stringify({
object: "response.compaction",
output: [{ type: "message", role: "user", content: images }, checkpoint],
}),
)
expect(JSON.parse(text).input[0].content).toEqual(images)
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
}),
),
).effect(`${model.provider} preserves retained image detail through serialization and replay`, () =>
Effect.gen(function* () {
const request = LLM.request({ model, prompt: "hello" })
const compacted = yield* LLMClient.compact(request)
const codec = Schema.fromJsonString(Schema.Array(Message))
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.messages))
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
}),
)
}
testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic compaction options", () =>
Effect.gen(function* () {
const request = LLMRequest.update(
LLM.request({ model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"), prompt: "hello" }),
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
)
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("LLMClient.compact")
}),
)
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(request.method).toBe("POST")
expect(request.headers[model.provider === "azure" ? "api-key" : "authorization"]).toBe(
model.provider === "azure" ? "test" : "Bearer test",
)
if (new URL(request.url).pathname.endsWith("/responses/compact")) {
expect(body).toEqual({
model: model.id,
input: [{ role: "user", content: [{ type: "input_text", text: "original" }] }],
instructions: "system",
})
return respond(
JSON.stringify({
object: "response.compaction",
output,
usage: { input_tokens: 1000, output_tokens: 10, total_tokens: 1010 },
}),
{ headers: { "content-type": "application/json" } },
)
}
expect(new URL(request.url).pathname.endsWith("/responses")).toBe(true)
expect(body.input).toEqual([...output, { role: "user", content: [{ type: "input_text", text: "continue" }] }])
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect(`${model.provider} explicitly compacts and replays the entire canonical window`, () =>
Effect.gen(function* () {
const request = LLM.request({ model, prompt: "original", system: "system", http: { body: { store: false } } })
const compacted = yield* LLMClient.compact(request)
expect(compacted.usage?.totalTokens).toBe(1010)
expect(compacted.messages.map((message) => message.role)).toEqual(["user", "assistant"])
expect(compacted.messages[0]?.content).toEqual([{ type: "text", text: "retained" }])
expect(compacted.messages[1]?.content).toEqual([
{ type: "compaction", provider: model.provider, id: "cmp_1", encrypted: "opaque" },
])
const codec = Schema.fromJsonString(Schema.Array(Message))
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.messages))
yield* LLMClient.generate(LLMRequest.update(request, { messages: [...messages, Message.user("continue")] }))
}),
)
}
for (const model of [
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"),
]) {
testEffect(fixedResponse("must not execute")).effect(
`${model.route.id} does not inherit an unsupported compact endpoint`,
() =>
Effect.gen(function* () {
// @ts-expect-error Untyped callers must still receive the runtime capability error.
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
}
testEffect(
fixedResponse(JSON.stringify({ object: "response.compaction", output: [retained], debug: "original payload" })),
).effect("invalid explicit compaction preserves the original response and HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain("original payload")
expect(error.reason.http?.status).toBe(200)
}),
)
+138
View File
@@ -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,60 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, Message } from "../../src/index.js"
import { OpenAI, Azure, XAI } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
it.effect(`${model.provider} preserves image detail through message serialization and lowering`, () =>
Effect.gen(function* () {
const details = [undefined, "low", "high", "auto"]
const message = Message.user(
details.map((detail) => ({
type: "media",
mediaType: "image/png",
data: "https://example.com/image.png",
providerMetadata:
detail === undefined ? undefined : { [model.route.providerMetadataKey ?? model.provider]: { detail } },
})),
)
const codec = Schema.fromJsonString(Message)
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))],
}),
)
expect(prepared.body.input[0].content).toEqual(
details.map((detail) => ({
type: "input_image",
image_url: "https://example.com/image.png",
detail,
})),
)
}),
)
}
it.effect("rejects malformed image detail instead of silently discarding it", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
messages: [
Message.user({
type: "media",
mediaType: "image/png",
data: "https://example.com/image.png",
providerMetadata: { openai: { detail: 42 } },
}),
],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
@@ -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({
+32
View File
@@ -21,6 +21,7 @@ import {
QuotaExceededError,
RateLimitError,
RouteID,
ToolResultValue,
TransportError,
UnknownProviderError,
Usage,
@@ -83,6 +84,37 @@ describe("llm schema", () => {
})
})
describe("ToolResultValue", () => {
test("uses the canonical schema guard", () => {
const cases: ReadonlyArray<{ readonly value: unknown; readonly expected: boolean }> = [
{ value: { type: "json", value: { ok: true } }, expected: true },
{ value: { type: "text", value: "done" }, expected: true },
{ value: { type: "error", value: "failed" }, expected: true },
{ value: { type: "content", value: [{ type: "text", text: "done" }] }, expected: true },
{ value: { type: "content", value: [{ type: "text" }] }, expected: false },
{ value: { type: "content", value: "done" }, expected: false },
{ value: { type: "json" }, expected: false },
{ value: { type: "unknown", value: "done" }, expected: false },
]
for (const item of cases) {
expect(Schema.is(ToolResultValue)(item.value)).toBe(item.expected)
expect(ToolResultValue.is(item.value)).toBe(item.expected)
}
})
test("accepts canonical results with extra fields", () => {
expect(ToolResultValue.is({ type: "json", value: { ok: true }, metadata: { source: "tool" } })).toBe(true)
expect(
ToolResultValue.is({
type: "content",
value: [{ type: "file", uri: "https://example.test/result.txt", mime: "text/plain", checksum: "abc" }],
metadata: { source: "tool" },
}),
).toBe(true)
})
})
describe("AI.Usage", () => {
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
// Defense against a provider reporting cached_tokens > prompt_tokens or
+62 -1
View File
@@ -1,5 +1,18 @@
import { describe, expect } from "bun:test"
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
import {
AIError,
CompactionPart,
CompactionResponse,
LanguageModel,
LLM,
LLMClient,
LLMEvent,
LLMRequest,
Message,
ProviderID,
RateLimitError,
} from "../src/index.js"
import { OpenAI } from "../src/providers.js"
import { OpenAIChat } from "../src/protocols/openai-chat.js"
import { TestLLM } from "../src/testing.js"
import { Effect, Fiber, Latch, Stream } from "effect"
@@ -66,6 +79,54 @@ describe("TestLLM legacy client", () => {
})
describe("TestLLM first-class client", () => {
it.effect("rejects response fixtures for the wrong operation", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
yield* client.push(TestLLM.stop(), new CompactionResponse({ messages: [] }))
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM compaction requires a CompactionResponse",
)
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM generation requires an event response",
)
}),
)
it.effect("scripts replacement windows with the same lazy recording, gates, and fallback controls", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
const compacted = new CompactionResponse({
messages: [
Message.user("retained input"),
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
Message.user("retained tail"),
],
})
yield* client.push(compacted, TestLLM.text("continued", "answer"))
const operation = LLMClient.compact(request)
expect(yield* client.requests()).toEqual([])
const gate = yield* client.gate()
const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true }))
yield* gate.started
yield* client.wait(1)
expect(fiber.pollUnsafe()).toBeUndefined()
yield* gate.release
expect(yield* Fiber.join(fiber)).toBe(compacted)
const next = LLMRequest.update(request, { messages: compacted.messages })
expect((yield* LLMClient.generate(next)).text).toBe("continued")
yield* client.serve((observed) => {
expect(observed).toBe(next)
return compacted
})
expect(yield* LLMClient.compact(next)).toBe(compacted)
yield* client.always(compacted)
expect(yield* LLMClient.compact(next)).toBe(compacted)
expect(yield* client.requests()).toEqual([request, next, next, next])
}),
)
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
Effect.gen(function* () {
const llm = yield* TestLLM.Test
+77
View File
@@ -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])
})
})
+27
View File
@@ -442,6 +442,33 @@ describe("LLMClient tools", () => {
}),
)
it.effect("projects malformed tagged dynamic output as opaque JSON", () =>
Effect.gen(function* () {
const malformed = { type: "content", value: [{ type: "text" }] }
const dynamic = Tool.make({
description: "Return caller-defined JSON.",
jsonSchema: { type: "object", properties: {} },
execute: () => Effect.succeed(malformed),
})
const dispatched = yield* ToolRuntime.dispatch(
{ dynamic },
LLMEvent.toolCall({ id: "call_1", name: "dynamic", input: {} }),
)
expect(dispatched.result).toEqual({ type: "json", value: malformed })
expect(dispatched.output).toEqual({ structured: malformed, content: [] })
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "dynamic",
result: { type: "json", value: malformed },
output: { structured: malformed, content: [] },
}),
])
}),
)
it.effect("executes tool calls for one step without looping by default", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
@@ -1,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
@@ -8,6 +8,30 @@ story.beforeEach(async ({ mount }) => {
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
})
story("spaces the first mobile message without changing desktop spacing", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer({ count: 1, rowHeight: 60, immediate: true })
}, fixture)
const root = page.getByTestId("timeline-virtualizer-fixture")
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
const content = root.locator("[data-timeline-virtual-content]")
await expect(content).toHaveCSS("visibility", "visible")
const gap = () =>
root.locator('[data-timeline-key="user-message:message-0"]').evaluate((element) => {
const viewport = element.closest("[data-scrollable]")!
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
})
await expect.poll(gap).toBe(16)
await root.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect.poll(gap).toBe(16)
await page.setViewportSize({ width: 1280, height: 900 })
await expect.poll(gap).toBe(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(gap).toBe(16)
})
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
@@ -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> &amp;",
"first\nsecond",
@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile files browse, search, switch, and close shared file tabs in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: (path) =>
path
? []
: ["first.ts", "second.ts"].map((name) => ({
name,
path: name,
absolute: `${fixture.directory}/${name}`,
type: "file",
ignored: false,
})),
fileContent: (path) => `contents:${path}`,
findFiles: ({ query }) => ["first.ts", "second.ts"].filter((path) => path.includes(query)),
})
await page.goto(stressSessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
const files = page.locator('[data-slot="session-mobile-files"]')
await expect(files.getByRole("button", { name: "first.ts", exact: true })).toBeVisible()
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expect(files.locator('[data-slot="session-mobile-files-header"]')).toHaveCSS("border-bottom-width", "0px")
await expect
.poll(() =>
files.getByRole("tablist", { name: "Open files", exact: true }).evaluate((element) => {
const header = element.closest('[data-slot="session-mobile-files-header"]')!
const separator = getComputedStyle(element, "::before")
return {
height: separator.height,
fullWidth: parseFloat(separator.width) === header.clientWidth,
start: separator.insetInlineStart,
bottom: separator.bottom,
}
}),
)
.toEqual({ height: "1px", fullWidth: true, start: "0px", bottom: "0px" })
await files.getByRole("button", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files.locator('[data-column-number="1"]').click()
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
await expect(editor.getByRole("textbox")).toBeVisible()
for (const width of [390, 700]) {
await page.setViewportSize({ width, height: 844 })
await expect
.poll(async () => {
const panel = await files.boundingBox()
const comment = await editor.boundingBox()
if (!panel || !comment) return false
return Math.abs(comment.x - panel.x - 12) < 2 && Math.abs(comment.width - panel.width + 24) < 2
})
.toBe(true)
}
await editor.getByRole("textbox").fill("Full-width file comment")
await editor.getByRole("button", { name: "Comment", exact: true }).click()
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
await expect(comment).toContainText("Full-width file comment")
await expect
.poll(async () => {
const panel = await files.boundingBox()
const card = await comment.boundingBox()
if (!panel || !card) return false
return Math.abs(card.x - panel.x - 12) < 2 && Math.abs(card.width - panel.width + 24) < 2
})
.toBe(true)
await page.setViewportSize({ width: 390, height: 844 })
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeHidden()
await files.getByRole("button", { name: "All files", exact: true }).click()
await files.getByRole("combobox", { name: "Filter files", exact: true }).fill("second")
await files.getByRole("option", { name: "second.ts", exact: true }).click()
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
const openTabs = files.getByRole("tablist", { name: "Open files", exact: true })
await expect(openTabs.getByRole("tab")).toHaveText(["first.ts", "second.ts"])
await expect
.poll(() =>
openTabs.getByRole("tab", { name: "second.ts", exact: true }).evaluate((element) => {
const tab = element.closest('[data-slot="tabs-v2-trigger-wrapper"]')!
const header = element.closest('[data-slot="session-mobile-files-header"]')!
return Math.abs(tab.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom) < 1
}),
)
.toBe(true)
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
await files
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
.filter({ has: page.getByRole("tab", { name: "first.ts", exact: true }) })
.getByRole("button", { name: "Close tab", exact: true })
.click()
await expect(openTabs.getByRole("tab")).toHaveText(["second.ts"])
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
await files.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(openTabs.getByRole("tab")).toHaveCount(0)
})
}
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test("opening changed files selects the requested tab before file loading completes", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
const loading = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: () => [],
fileContent: async (path) => {
if (path === "second.ts") await loading.promise
return `contents:${path}`
},
vcsDiff: ["first.ts", "second.ts"].map((file) => ({
file,
status: "modified",
additions: 1,
deletions: 1,
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-before\n+after\n`,
})),
})
await page.goto(stressSessionHref(fixture.targetID))
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
const files = page.locator('[data-slot="session-mobile-files"]')
for (const file of ["first.ts", "second.ts", "first.ts", "second.ts"]) {
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
const diff = page.locator(`[data-component="session-review"] [data-file="${file}"]`)
const header = diff.getByRole("button", { name: file, exact: true })
await expect(header).toBeEnabled()
if ((await header.getAttribute("aria-expanded")) === "false") await header.click()
await diff.getByRole("button", { name: "Open file", exact: true }).click()
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(files.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
if (file === "second.ts") loading.resolve()
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
}
})
@@ -0,0 +1,138 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const direction of ["ltr", "rtl"] as const) {
test(`mobile change summaries appear only for expanded diffs in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page, {
vcsDiff: [
{
file: "added.ts",
status: "added",
additions: 1,
deletions: 0,
patch:
"diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+export const added = 1\n",
},
{
file: "removed.ts",
status: "deleted",
additions: 0,
deletions: 1,
patch:
"diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const removed = 1\n",
},
{
file: "modified.ts",
status: "modified",
additions: 1,
deletions: 1,
patch: `diff --git a/modified.ts b/modified.ts\n--- a/modified.ts\n+++ b/modified.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = "${"long content ".repeat(30)}"\n`,
},
],
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", exact: true }).click()
const review = page.locator('[data-component="session-review"]')
await expect(review.getByRole("button", { name: "Expand all", exact: true })).toBeVisible()
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expect(review.locator('[data-file="added.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
"border-top-width",
"0px",
)
await expect(review.locator('[data-file="modified.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
"border-bottom-width",
"0px",
)
for (const change of [
{ file: "added.ts", status: "Added", additions: "+1", deletions: "-0" },
{ file: "removed.ts", status: "Removed", additions: "+0", deletions: "-1" },
{ file: "modified.ts", status: undefined, additions: "+1", deletions: "-1" },
]) {
const item = review.locator(`[data-file="${change.file}"]`)
const trigger = item.getByRole("button", { name: change.file, exact: true })
const summary = item.locator('[data-slot="session-review-change-summary"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(trigger.locator('[data-component="diff-changes"]')).toHaveCount(0)
await expect(trigger.locator('[data-slot="session-review-change"]')).toHaveCount(0)
await expect(summary).toHaveCount(0)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
if (change.file === "modified.ts") {
await expect(item.locator('[data-slot="accordion-content"]')).toHaveCSS("border-bottom-width", "0px")
await expect(trigger).not.toHaveCSS("border-bottom-width", "0px")
}
await expect(summary).toBeVisible()
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCount(0)
}
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
const modified = review.locator('[data-file="modified.ts"]')
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
await expect(modified.locator("[data-line-number-content]")).toHaveText(["1", "1"])
await expect(modified.locator("[data-diff]")).not.toHaveAttribute("data-disable-line-numbers")
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
await expect(review.getByRole("button", { name: "Diff options", exact: true })).toHaveCount(0)
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
await expect(wrap).toBeChecked()
await wrapControl.click()
await expect(wrap).not.toBeChecked()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(false)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
.toBe(true)
await modified.locator("[data-code]").evaluate((element) => {
element.scrollLeft = 100
})
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => Math.abs(element.scrollLeft)))
.toBeGreaterThan(0)
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await page.keyboard.press("Control+,")
await expect(wrap).not.toBeChecked()
await wrapControl.click()
await expect(wrap).toBeChecked()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(true)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
await expect(openFile).toBeVisible()
await expect
.poll(async () => {
const button = await openFile.boundingBox()
const summary = await modified.locator('[data-slot="session-review-change-summary"]').boundingBox()
if (!button || !summary) return false
return direction === "ltr"
? button.x > summary.x + summary.width / 2
: button.x + button.width < summary.x + summary.width / 2
})
.toBe(true)
await openFile.click()
await expect(
page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Files", exact: true }),
).toHaveAttribute("aria-selected", "true")
const files = page.locator('[data-slot="session-mobile-files"]')
await expect(files.getByRole("tab", { name: "modified.ts", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(files).toHaveAttribute("data-browsing", "false")
})
}
@@ -0,0 +1,186 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const position of ["top", "bottom"] as const) {
test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
fileList: () => [],
})
await installStressSessionTabs(page)
await page.addInitScript(
(position) =>
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: position } })),
position,
)
await page.route("**/api/pty*", (route) =>
route.fulfill({
json: {
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
data: {
id: "pty_mobile_views",
title: "Terminal 1",
command: "sh",
args: [],
cwd: fixture.directory,
status: "running",
pid: 1,
},
},
}),
)
await page.routeWebSocket("**/api/pty/pty_mobile_views/connect", () => undefined)
await page.route("**/api/pty/pty_mobile_views/connect-token*", (route) =>
route.fulfill({
json: {
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
},
}),
)
await page.goto(stressSessionHref(fixture.targetID))
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = navigation.getByRole("button", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(message).toBeVisible()
await expect(composer).toBeVisible()
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
})
.toBe(true)
await expect
.poll(async () => {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
await expect(page.locator("[data-session-title]")).toHaveCount(0)
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
await expect(drawer).toHaveAttribute("data-open", "")
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await page.getByRole("button", { name: "Back to app", exact: true }).click()
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(picker).toHaveCount(0)
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
const usage = page.locator('[data-slot="session-usage-content"]')
await expect(usage).toHaveCSS("padding-top", "16px")
await expect(usage).toHaveCSS("padding-inline-start", "16px")
await expect(usage).toHaveCSS("padding-inline-end", "16px")
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await details.getByRole("button", { name: "No changes", exact: true }).click()
await expect(details).toBeHidden()
await expect(picker).toHaveText("Changes")
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("Files")
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
const panel = page.locator("#terminal-panel")
await expect(panel).toHaveAttribute("data-opened", "true")
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
await expect(panel.locator('[data-component="terminal"]')).toBeVisible()
await expect(panel.locator("textarea")).toBeEditable()
await expect(panel).toHaveCount(1)
await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await expect(composer).toBeHidden()
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
await expect(message).toBeVisible()
await expect(panel).toBeHidden()
await expect(panel).toHaveAttribute("inert", "")
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await expect(panel).toBeVisible()
await expect(panel).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Session")
await page.keyboard.press("Control+Backquote")
await expect(picker).toHaveText("Terminal")
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(picker).toHaveText("Session")
await expect(panel).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
await page.goto(stressSessionHref(fixture.sourceID))
await expect(picker).toHaveText("Session")
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.sourceTitle)
await page.setViewportSize({ width: 1280, height: 900 })
await expect(picker).toBeHidden()
await expect(page.locator("[data-session-title]")).toBeVisible()
})
}
@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
)
},
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
)
await page.goto("/")
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
test("mobile project selection and drawer navigation preserve session identity", async ({ page }) => {
const projects = page.getByRole("button", { name: "Projects", exact: true })
await projects.click()
const picker = page.getByRole("dialog", { name: "Projects", exact: true })
await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible()
await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click()
await expect(picker).toBeHidden()
await expect(projects).toContainText(fixture.project.name)
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveText([fixture.project.name, fixture.project.name])
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
const help = drawer.getByRole("button", { name: "Help", exact: true })
await expect(settings).toBeVisible()
await expect(help).toBeVisible()
expect((await settings.boundingBox())?.width).toBeGreaterThan((await help.boundingBox())?.width ?? 0)
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle }).click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await expect(drawer).toBeHidden()
await expect(trigger).toContainText(fixture.expected.sourceTitle)
await trigger.click()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(drawer).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
test("mobile settings section menu stays above a full-width panel", async ({ page }) => {
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await page.locator('[data-slot="mobile-tabs-drawer"]').getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
const panel = settings.getByRole("tabpanel")
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeHidden()
await expect(settings).toBeVisible()
await menu.click()
await page.getByRole("menuitemradio", { name: "Models", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("switch", { name: "Claude Opus 4.6", exact: true })).toBeAttached()
await expect
.poll(async () => {
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
const content = await panel.boundingBox()
return !!navigation && !!content && navigation.y + navigation.height <= content.y
})
.toBe(true)
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
await page.mouse.down()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 + 1)
await page.mouse.move(bounds!.x + bounds!.width / 2, 843)
await page.mouse.up()
}
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
await expect(overlay).toHaveCount(0)
await expect(more).toBeFocused()
}
})
@@ -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 },
})),
},
})
@@ -89,7 +89,7 @@ async function openReview(page: Page) {
)
await changes.click()
expect((await (await diffResponse).json()).data).toHaveLength(1)
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
await expect(changes).toHaveAttribute("aria-selected", "true")
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
@@ -32,12 +32,13 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
const username = editor.getByPlaceholder("username")
await expect(editor.getByPlaceholder("username")).toHaveCount(0)
const name = editor.getByPlaceholder("Localhost", { exact: true })
const password = editor.getByPlaceholder("password")
await username.click()
await expect(username).toBeFocused()
await username.fill("kit")
await expect(username).toHaveValue("kit")
await name.click()
await expect(name).toBeFocused()
await name.fill("Remote")
await expect(name).toHaveValue("Remote")
await page.keyboard.press("Tab")
await expect(password).toBeFocused()
await password.fill("secret")
@@ -0,0 +1,193 @@
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" },
...Array.from({ length: 20 }, (_, index) => ({
directory: `/projects/worktree-${index}`,
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()
const existing = page.getByRole("menuitem", { name: "existing-worktree", exact: true })
await expect(existing).toBeVisible()
await expect(existing).toHaveCSS("height", "28px")
await page.keyboard.press("Escape")
await expect(page.getByRole("menu")).toHaveCount(0)
listing = Promise.withResolvers<void>()
listingRequested = Promise.withResolvers<void>()
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
await listingRequested.promise
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
listing.resolve()
await page.getByRole("menuitem", { name: create ? "New workspace" : "existing-worktree", exact: true }).click()
await moveRequested.promise
await expect(page.getByRole("button", { name: "Moving session…", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Choose worktree", exact: true })).toBeDisabled()
moving.resolve()
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
expect(requests).toEqual([
...(create ? [{ operation: "create", body: { strategy: "git", from: canonical, directory: "/projects/" } }] : []),
{ operation: "move", body: { directory: destination } },
])
})
}
@@ -0,0 +1,130 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.beforeEach(async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.route("**/api/session/*/rename", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const id = new URL(route.request().url()).pathname.split("/").at(-2)
const session = sessions.find((item) => item.id === id)
const payload: unknown = route.request().postDataJSON()
if (
!session ||
!payload ||
typeof payload !== "object" ||
!("title" in payload) ||
typeof payload.title !== "string"
)
throw new Error("Invalid rename request")
session.title = payload.title
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await page.goto("/")
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
for (const commit of ["Enter", "blur", "click outside"]) {
test(`saves the session heading on ${commit}`, async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await expect(input).toBeFocused()
await input.fill("Renamed session")
if (commit === "Enter") await input.press("Enter")
if (commit === "blur") await input.press("Tab")
if (commit === "click outside") await page.locator('[data-component="composer-editor"]').click()
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
})
}
test("cancels the session heading with Escape", async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill("Discard this title")
await input.press("Escape")
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
test("keeps the draft when saving the session heading fails", async ({ page }) => {
await page.route("**/api/session/*/rename", (route) =>
route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } }),
)
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill("Retry this title")
await input.press("Tab")
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(input).toBeEnabled()
await expect(input).toHaveValue("Retry this title")
await expect(
page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }),
).toBeVisible()
})
test("does not save an empty session heading", async ({ page }) => {
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
const input = page.locator('input[data-slot="session-title-child"]')
await input.fill(" ")
await input.press("Tab")
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
})
test("renames and closes the session tab from its context menu", async ({ page }) => {
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden()
await expect(tab).toBeFocused()
await tab.press("Shift+F10")
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Renamed from tab")
await input.press("Enter")
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" })
await renamed.click({ button: "right" })
await page.getByRole("menuitem", { name: "Close tab", exact: true }).click()
await expect(renamed).toBeHidden()
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(
page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }),
).toBeVisible()
})
test("renames an inactive tab without switching sessions", async ({ page }) => {
await page.getByRole("button", { name: "Home", exact: true }).click()
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Inactive tab renamed")
await input.press("Tab")
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click()
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
await page.reload()
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
})
@@ -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 },
},
],
},
})
@@ -141,7 +141,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
})
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
})
@@ -69,7 +69,7 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
test("mobile drawer exposes close controls and navigates between tabs", async ({ page }) => {
await page.setViewportSize({ width: 360, height: 720 })
await mockServer(page)
await page.addInitScript(
@@ -89,26 +89,31 @@ test("cramped tabs only show the close button for the active tab", async ({ page
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabB).toBeVisible()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await tabB.locator(`a[href="${hrefB}"]`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
for (const direction of ["ltr", "rtl"]) {
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
await page.setViewportSize({ width: 450, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
await expect(tabA).toBeVisible()
await page.setViewportSize({ width: 1280, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
await page.setViewportSize({ width: 450, height: 720 })
await page.getByRole("button", { name: "Tabs", exact: true }).click()
}
})
@@ -202,28 +207,28 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await expect(version).toBeInViewport()
await expect(settings.getByRole("tablist")).toBeHidden()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 720 })
await expect(version).toBeInViewport()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect(version).toBeInViewport()
await expect(version).toHaveCSS("direction", "ltr")
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
await page.setViewportSize({ width: 390, height: 360 })
await version.scrollIntoViewIfNeeded()
await expect(version).toBeInViewport()
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
// Reload the UI-selected preference without seeding settings storage.
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(
page
.locator('[data-slot="titlebar-tabs"]')
.locator('[data-slot="mobile-tabs-drawer"]')
.locator(`[data-titlebar-tab-link][href="${href}"]`)
.getByText(sessionA.title, { exact: true }),
).toBeVisible()
@@ -242,7 +247,7 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await expect(layout).toContainText("Vertical")
})
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 720 })
await mockServer(page)
await page.addInitScript(
@@ -259,7 +264,8 @@ test("vertical tab preference falls back to horizontal on mobile", async ({ page
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const tabs = page.locator('[data-slot="titlebar-tabs"]')
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const tabs = page.locator('[data-slot="mobile-tabs-drawer"]')
await expect(tabs.locator(`[data-titlebar-tab-link][href="${href}"]`)).toContainText(sessionA.title)
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
+6
View File
@@ -8,6 +8,12 @@ export async function expectAppVisible(locator: Locator) {
}
export async function expectSessionTitle(page: Page, title: string) {
if ((page.viewportSize()?.width ?? 1280) < 768) {
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
await expectAppVisible(trigger)
await expect(trigger.locator('span[dir="auto"]')).toHaveText(title, { timeout: APP_READY_TIMEOUT })
return
}
await expectAppVisible(page.getByRole("heading", { name: title }))
}
+1
View File
@@ -56,6 +56,7 @@
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
+29 -5
View File
@@ -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
@@ -112,8 +114,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
@@ -145,7 +149,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 +173,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 +201,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 +230,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
+6 -1
View File
@@ -2,9 +2,14 @@ import type { HomeProjectsController } from "./controller"
import { HomeProjectsView } from "./view"
import type { HomeScrollController } from "../scroll"
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
export function HomeProjects(props: {
projects: HomeProjectsController
scroll: HomeScrollController
dropdown?: boolean
}) {
return (
<HomeProjectsView
dropdown={props.dropdown}
language={props.projects.copy.language}
servers={props.projects.server.list()}
projects={props.projects.project.list()}
+131 -22
View File
@@ -1,5 +1,6 @@
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Popover } from "@kobalte/core/popover"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
@@ -28,6 +29,7 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
`project:${ServerConnection.key(server)}:${directory}`
export type HomeProjectsViewProps = {
dropdown?: boolean
language: ReturnType<typeof useLanguage>
servers: ServerConnection.Any[]
projects: LocalProject[]
@@ -64,6 +66,81 @@ export type HomeProjectsViewProps = {
}
export function HomeProjectsView(props: HomeProjectsViewProps) {
const [state, setState] = createStore({ open: false })
const selected = createMemo(() => props.projects.find((project) => project.worktree === props.selection.directory))
const server = createMemo(() =>
props.servers.find((server) => ServerConnection.key(server) === props.selection.server),
)
return (
<Show when={props.dropdown} fallback={<HomeProjectsPanel {...props} />}>
<Popover
open={state.open}
onOpenChange={(open) => setState("open", open)}
placement="bottom-start"
sameWidth
gutter={6}
>
<Popover.Trigger
data-component="home-projects-dropdown"
aria-label={props.language.t("home.projects")}
class="flex h-10 w-full min-w-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-base px-1.5 text-start text-v2-text-text-base outline-none hover:bg-v2-background-bg-layer-01"
>
<Show when={selected()} fallback={<Icon name="folder" size="small" />}>
{(project) => <HomeProjectAvatar project={project()} />}
</Show>
<span class="flex min-w-0 flex-1 flex-col leading-[var(--line-height-compact)]">
<bdi class="truncate">
<Show when={selected()} fallback={props.language.t("home.projects.all")}>
{(project) => displayName(project())}
</Show>
</bdi>
<Show when={props.servers.length > 1 && server()}>
{(server) => (
<bdi class="truncate text-v2-text-text-muted opacity-70">
{server().displayName ?? new URL(server().http.url).host}
</bdi>
)}
</Show>
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
aria-label={props.language.t("home.projects")}
dir={props.language.direction()}
class="z-50 max-h-[min(70dvh,var(--kb-popper-content-available-height))] overflow-hidden rounded-[10px] bg-v2-background-bg-base p-1.5 shadow-[var(--v2-elevation-floating)] outline-none data-[expanded]:animate-in data-[expanded]:fade-in data-[expanded]:slide-in-from-top-2 duration-150 ease-out motion-reduce:animate-none"
>
<HomeProjectsPanel
{...props}
onSelectProject={(server, directory) => {
props.onSelectProject(server, directory)
setState("open", false)
}}
onFocusServer={(server) => {
props.onFocusServer(server)
setState("open", false)
}}
onChooseProject={(server) => {
setState("open", false)
props.onChooseProject(server)
}}
onAddProjects={(server, directories) => {
setState("open", false)
props.onAddProjects(server, directories)
}}
onOpenProjectNewSession={(server, directory) => {
setState("open", false)
props.onOpenProjectNewSession(server, directory)
}}
/>
</Popover.Content>
</Popover.Portal>
</Popover>
</Show>
)
}
function HomeProjectsPanel(props: HomeProjectsViewProps) {
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
const contextMenuProps = {
contextMenuOpen: (id: string) => contextMenu.open === id,
@@ -71,48 +148,77 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
}
return (
<aside
class={`
class={
props.dropdown
? "flex max-h-[min(60dvh,calc(var(--kb-popper-content-available-height)-12px))] min-h-0 min-w-0 flex-col overflow-hidden"
: `
mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
`}
`
}
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<Icon name="folder-add-left" />}
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
aria-label={props.language.t("home.project.add")}
/>
</Tooltip>
</Show>
</div>
<Show when={!props.dropdown}>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<Icon name="folder-add-left" />}
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
aria-label={props.language.t("home.project.add")}
/>
</Tooltip>
</Show>
</div>
</Show>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show when={props.dropdown && props.servers.length === 1}>
<HomeProjectNavButton
type="button"
class="mb-1"
data-selected={!props.selection.directory ? "" : undefined}
onClick={() => props.onFocusServer(props.servers[0])}
>
<Icon name="folder" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.projects.all")}</span>
</HomeProjectNavButton>
</Show>
<Show
when={props.servers.length > 1}
fallback={
<div class="pr-3">
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
<Show when={props.dropdown}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="mt-1 disabled:opacity-60"
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
<For each={props.servers}>
{(item) => {
const projects = () => props.projectsForServer(item)
@@ -157,7 +263,9 @@ export function HomeUtilityNav(props: {
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<div
class={`${props.class ?? ""} min-w-0 flex-row justify-between gap-1 lg:flex-col lg:justify-start lg:pr-3 [&>button]:w-auto lg:[&>button]:w-full`}
>
<HomeProjectNavButton
type="button"
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
@@ -511,6 +619,7 @@ function HomeProjectRow(
// does not focus that server and load its session index. Touch is
// excluded so flick-scrolling the list cannot select rows.
pointerDownSelected = undefined
if (props.dropdown) return
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
+23 -11
View File
@@ -1,4 +1,6 @@
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { createMediaQuery } from "@solid-primitives/media"
import { Show } from "solid-js"
import { createHomeController } from "./model"
import { createHomeProjectsController } from "./projects/controller"
import { HomeUtilityNav } from "./projects/view"
@@ -9,6 +11,7 @@ import { createHomeSessionsController } from "./sessions/controller"
import { HomeSessions } from "./sessions/region"
export function Home() {
const mobile = createMediaQuery("(max-width: 767px)")
const home = createHomeController()
const projects = createHomeProjectsController(home)
const sessions = createHomeSessionsController(home)
@@ -17,12 +20,17 @@ export function Home() {
return (
<div
class={`
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
<Show when={mobile()}>
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
<HomeProjects projects={projects} scroll={scroll} dropdown />
</div>
</Show>
<ScrollView
class="h-full [container-type:size]"
class="min-h-0 flex-1 [container-type:size]"
thumbContainer={scroll.viewport.thumbTrack()}
thumbHoverTarget={scroll.viewport.hoverTarget()}
viewportRef={scroll.viewport.setViewport}
@@ -31,20 +39,24 @@ export function Home() {
>
<div
class={`
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)] gap-4 px-3
max-md:grid-rows-[minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
`}
>
<HomeProjects projects={projects} scroll={scroll} />
<Show when={!mobile()}>
<HomeProjects projects={projects} scroll={scroll} />
</Show>
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
<HomeUtilityNav
class="flex lg:hidden"
onOpenSettings={projects.utility.settings}
onOpenHelp={projects.utility.help}
language={projects.copy.language}
/>
</div>
</ScrollView>
<div class="hidden shrink-0 px-3 py-2 md:block lg:hidden">
<HomeUtilityNav
class="flex"
onOpenSettings={projects.utility.settings}
onOpenHelp={projects.utility.help}
language={projects.copy.language}
/>
</div>
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
@media (max-width: 767px) {
[data-component="home-session-row-container"][data-project-name="true"],
[data-component="home-session-row-container"][data-project-name="true"] [data-component="home-session-row"],
[data-component="home-session-row-container"][data-project-name="true"] [data-slot="home-session-editor"],
[data-component="home-session-search-row"][data-project-name="true"] {
height: 48px;
padding-block: 0;
}
[data-project-name="true"] [data-slot="home-session-labels"] {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 0;
text-align: start;
}
[data-project-name="true"] [data-slot="home-session-labels"] > * {
max-width: 100%;
flex: none;
font-size: 13px;
line-height: var(--line-height-compact);
}
}
+76 -54
View File
@@ -15,6 +15,7 @@ import { ServerConnection } from "@/runtime/server/registry"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { sessionLabel } from "@/session/title"
import { shouldOpenSessionInBackground } from "./open"
import "./view.css"
import {
HomeSessionStatusController,
homeSessionSearchKey,
@@ -96,10 +97,13 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={props.language.t("sidebar.project.recentSessions")}
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<div
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-1 pt-6 md:pb-3 lg:pt-12"
onWheel={props.onWheel}
>
<HomeSessionSearch {...props} />
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<div class="pointer-events-none absolute right-0 top-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
@@ -113,18 +117,18 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div class="pointer-events-none sticky top-[68px] z-40 h-0 -mr-3 md:top-[84px] lg:top-[108px]">
<div
ref={props.onSetThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
class="relative ml-auto h-[calc(100cqh-68px)] w-3 md:h-[calc(100cqh-84px)] lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<div class="-mr-3 min-h-[calc(100cqh-64px)] md:min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!props.loading}
fallback={
<div class="pt-3">
<div class="pt-1 md:pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
</div>
}
@@ -138,7 +142,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<div ref={props.onSetContent} class="flex flex-col pt-1 pr-3 pb-16 md:pt-3">
{/* Index keeps group subtrees mounted when the group arrays are
rebuilt, so store updates cannot recreate rows mid-gesture. */}
<Index each={props.groups}>
@@ -150,7 +154,9 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
onSetRef={(element) => props.onSetHeader(group().id, element)}
elevated={index === 0}
/>
<div class={`flex min-w-0 flex-col gap-px pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}>
<div
class={`flex min-w-0 flex-col gap-px pt-2 md:pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}
>
{/* Rows key by session ID: session.sync replaces the
stored session object wholesale, so reference-keyed
rows would be disposed mid-interaction whenever a
@@ -235,7 +241,11 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
absolute flex flex-col overflow-hidden rounded-[12px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
`}
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
style={{
top: "-6px",
"inset-inline-start": "-6px",
width: "calc(100% + 12px)",
}}
>
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
@@ -269,7 +279,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
>
{props.language.t("home.sessions.search.sessions")}
</p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<ScrollView class="max-h-[min(20rem,40dvh)]" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2">
<For each={props.searchResults}>
{(record) => (
@@ -291,7 +301,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
</Show>
<label
class={`
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 ps-3 pe-2
bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
`}
@@ -375,6 +385,7 @@ function HomeSessionSearchResultRow(
id={`home-session-search-option-${key()}`}
data-key={key()}
data-component="home-session-search-row"
data-project-name={!!showProjectName()}
role="option"
aria-selected={props.selected}
class={`
@@ -403,7 +414,7 @@ function HomeSessionSearchResultRow(
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<div data-slot="home-session-labels" class="flex min-w-0 flex-1 items-center gap-1.5">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} search />
@@ -423,8 +434,8 @@ function HomeSessionGroupHeader(props: {
<div
ref={props.onSetRef}
class={`
pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
bg-v2-background-bg-base pl-3 lg:top-[108px]
pointer-events-none sticky top-[68px] flex h-7 min-w-0 items-center justify-between
bg-v2-background-bg-base ps-1.5 md:ps-3 md:top-[84px] lg:top-[108px]
`}
classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
>
@@ -510,6 +521,7 @@ function HomeSessionRow(
return (
<div
data-component="home-session-row-container"
data-project-name={!!showProjectName()}
data-session-id={props.record.session.id}
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
classList={{ group: !!showProjectName() }}
@@ -523,51 +535,56 @@ function HomeSessionRow(
<Show
when={!editor()}
fallback={
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
<div
data-slot="home-session-editor"
class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 ps-1.5 pe-3 md:ps-3 md:pe-10"
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={false}
/>
<InlineInput
data-component="home-session-rename"
aria-label={props.language.t("common.rename")}
dir="auto"
value={editor()?.draft ?? ""}
disabled={editor()?.renaming ?? false}
class={`
<div data-slot="home-session-labels" class="contents">
<InlineInput
data-component="home-session-rename"
aria-label={props.language.t("common.rename")}
dir="auto"
value={editor()?.draft ?? ""}
disabled={editor()?.renaming ?? false}
class={`
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
const draft = event.currentTarget.value
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
}}
onKeyDown={(event) => {
event.stopPropagation()
// Enter and Escape during IME composition commit or cancel
// the composition, not the rename. Safari can report the
// composition-confirming keydown with isComposing false but
// keyCode 229.
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
const draft = event.currentTarget.value
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
}}
onKeyDown={(event) => {
event.stopPropagation()
// Enter and Escape during IME composition commit or cancel
// the composition, not the rename. Safari can report the
// composition-confirming keydown with isComposing false but
// keyCode 229.
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => rowButton()?.focus())
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
closeEditor()
requestAnimationFrame(() => rowButton()?.focus())
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
</div>
}
>
@@ -578,7 +595,7 @@ function HomeSessionRow(
aria-expanded={!!menu()}
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
bg-transparent py-3 ps-1.5 pe-3 md:ps-3 md:pe-10 text-start text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
@@ -641,10 +658,12 @@ function HomeSessionRow(
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
<div data-slot="home-session-labels" class="contents">
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
</button>
</Show>
<Menu
@@ -723,6 +742,7 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
return (
<span
data-component="home-session-title"
dir="auto"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
classList={{
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
@@ -738,6 +758,8 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
function HomeSessionProjectName(props: { name: string; search?: boolean }) {
return (
<span
data-component="home-session-project-name"
dir="auto"
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
>
@@ -779,7 +801,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
function HomeSessionSkeleton(props: { label: string }) {
return (
<div class="flex min-w-0 flex-col gap-4">
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class="flex h-7 min-w-0 items-center justify-between ps-1.5 pe-4 md:ps-4">
<div class={HOME_SECTION_LABEL}>{props.label}</div>
</div>
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
+1 -1
View File
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</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"])
-3
View File
@@ -414,8 +414,6 @@ export const dict = {
"dialog.server.add.button": "አገልጋይ አክል",
"dialog.server.add.name": "የአገልጋይ ስም (አማራጭ)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "የተጠቃሚ ስም (አማራጭ)",
"dialog.server.add.usernamePlaceholder": "የተጠቃሚ ስም",
"dialog.server.add.password": "የይለፍ ቃል (አማራጭ)",
"dialog.server.add.passwordPlaceholder": "የይለፍ ቃል",
"dialog.server.edit.title": "አገልጋይ አርትዕ",
@@ -498,7 +496,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "ዴስክቶፕ {{version}}",
"wsl.onboarding.versionMismatch": "የተጫነው ሥሪት ከዴስክቶፕ መተግበሪያ ሥሪት ጋር አይዛመድም።",
"wsl.onboarding.adding": "በማከል ላይ...",
"server.row.noUsername": "የተጠቃሚ ስም የለም",
"dialog.project.edit.title": "ፕሮጀክት አርትዕ",
"dialog.project.edit.name": "ስም",
"dialog.project.edit.icon": "አዶ",
-3
View File
@@ -420,7 +420,6 @@ export const dict = {
"dialog.server.add.button": "إضافة خادم",
"dialog.server.add.name": "اسم الخادم (اختياري)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "اسم المستخدم (اختياري)",
"dialog.server.add.password": "كلمة المرور (اختياري)",
"dialog.server.edit.title": "تحرير الخادم",
"dialog.server.default.title": "الخادم الافتراضي",
@@ -1024,9 +1023,7 @@ export const dict = {
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
"app.server.retrying": "جارٍ إعادة المحاولة تلقائيًا...",
"app.server.otherServers": "خوادم أخرى",
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
"server.row.noUsername": "لا يوجد اسم مستخدم",
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
"session.review.noVcs.createGit.actionLoading": "جارٍ إنشاء مستودع Git...",
-3
View File
@@ -421,8 +421,6 @@ export const dict = {
"dialog.server.add.button": "Server əlavə et",
"dialog.server.add.name": "Server adı (istəyə bağlı)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "İstifadəçi adı (istəyə bağlı)",
"dialog.server.add.usernamePlaceholder": "istifadəçi adı",
"dialog.server.add.password": "Şifrə (istəyə bağlı)",
"dialog.server.add.passwordPlaceholder": "parol",
"dialog.server.edit.title": "Serveri redaktə et",
@@ -509,7 +507,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "masaüstü {{version}}",
"wsl.onboarding.versionMismatch": "Quraşdırılmış versiya masaüstü proqram versiyasına uyğun gəlmir.",
"wsl.onboarding.adding": "Əlavə edilir...",
"server.row.noUsername": "istifadəçi adı yoxdur",
"dialog.project.edit.title": "Layihəni redaktə et",
"dialog.project.edit.name": "Ad",
"dialog.project.edit.icon": "İkon",
-3
View File
@@ -421,8 +421,6 @@ export const dict = {
"dialog.server.add.button": "Добавете сървър",
"dialog.server.add.name": "Име на сървъра (по избор)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "потребителско име (по избор)",
"dialog.server.add.usernamePlaceholder": "потребителско име",
"dialog.server.add.password": "Парола (по избор)",
"dialog.server.add.passwordPlaceholder": "парола",
"dialog.server.edit.title": "Редактиране на сървъра",
@@ -508,7 +506,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "работен плот {{version}}",
"wsl.onboarding.versionMismatch": "Инсталираната версия не съответства на версията на настолното приложение.",
"wsl.onboarding.adding": "Добавяне...",
"server.row.noUsername": "няма потребителско име",
"dialog.project.edit.title": "Редактиране на проекта",
"dialog.project.edit.name": "Име",
"dialog.project.edit.icon": "Икона",
-3
View File
@@ -418,8 +418,6 @@ export const dict: Record<string, string> = {
"dialog.server.add.button": "সার্ভার যোগ করুন",
"dialog.server.add.name": "সার্ভারের নাম (ঐচ্ছিক)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "ব্যবহারকারীর নাম (ঐচ্ছিক)",
"dialog.server.add.usernamePlaceholder": "ব্যবহারকারীর নাম",
"dialog.server.add.password": "পাসওয়ার্ড (ঐচ্ছিক)",
"dialog.server.add.passwordPlaceholder": "পাসওয়ার্ড",
"dialog.server.edit.title": "সার্ভার সম্পাদনা করুন",
@@ -505,7 +503,6 @@ export const dict: Record<string, string> = {
"wsl.onboarding.desktopVersion": "ডেস্কটপ {{version}}",
"wsl.onboarding.versionMismatch": "ইনস্টল করা সংস্করণ ডেস্কটপ অ্যাপ সংস্করণের সাথে মেলে না।",
"wsl.onboarding.adding": "যোগ করা হচ্ছে...",
"server.row.noUsername": "ব্যবহারকারীর নাম নেই",
"dialog.project.edit.title": "প্রকল্প সম্পাদনা করুন",
"dialog.project.edit.name": "নাম",
"dialog.project.edit.icon": "আইকন",
-3
View File
@@ -422,7 +422,6 @@ export const dict = {
"dialog.server.add.button": "Adicionar servidor",
"dialog.server.add.name": "Nome do servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nome de usuário (opcional)",
"dialog.server.add.password": "Senha (opcional)",
"dialog.server.edit.title": "Editar servidor",
"dialog.server.default.title": "Servidor padrão",
@@ -1035,9 +1034,7 @@ export const dict = {
"app.server.unreachable": "Não foi possível conectar a {{server}}",
"app.server.retrying": "Tentando novamente automaticamente...",
"app.server.otherServers": "Outros servidores",
"dialog.server.add.usernamePlaceholder": "nome de usuário",
"dialog.server.add.passwordPlaceholder": "senha",
"server.row.noUsername": "sem nome de usuário",
"session.review.noVcs.createGit.title": "Criar um repositório Git",
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
-3
View File
@@ -449,7 +449,6 @@ export const dict = {
"dialog.server.add.button": "Dodaj server",
"dialog.server.add.name": "Ime servera (opcionalno)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Korisničko ime (opcionalno)",
"dialog.server.add.password": "Lozinka (opcionalno)",
"dialog.server.edit.title": "Uredi server",
"dialog.server.default.title": "Podrazumijevani server",
@@ -1113,9 +1112,7 @@ export const dict = {
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
"app.server.retrying": "Automatski ponovni pokušaj...",
"app.server.otherServers": "Drugi serveri",
"dialog.server.add.usernamePlaceholder": "korisničko ime",
"dialog.server.add.passwordPlaceholder": "lozinka",
"server.row.noUsername": "nema korisničkog imena",
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
"session.review.noVcs.createGit.description": "Prati, pregledaj i poništi promjene u ovom projektu",
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
-3
View File
@@ -420,8 +420,6 @@ export const dict = {
"dialog.server.add.button": "Afegeix servidor",
"dialog.server.add.name": "Nom del servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nom d'usuari (opcional)",
"dialog.server.add.usernamePlaceholder": "nom d'usuari",
"dialog.server.add.password": "Contrasenya (opcional)",
"dialog.server.add.passwordPlaceholder": "contrasenya",
"dialog.server.edit.title": "Edita el servidor",
@@ -507,7 +505,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "escriptori {{version}}",
"wsl.onboarding.versionMismatch": "La versió instal·lada no coincideix amb la versió de l'aplicació d'escriptori.",
"wsl.onboarding.adding": "S'està afegint...",
"server.row.noUsername": "sense nom d'usuari",
"dialog.project.edit.title": "Edita el projecte",
"dialog.project.edit.name": "Nom",
"dialog.project.edit.icon": "Icona",
-3
View File
@@ -418,8 +418,6 @@ export const dict = {
"dialog.server.add.button": "Přidat server",
"dialog.server.add.name": "Název serveru (volitelné)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "uživatelské jméno (volitelné)",
"dialog.server.add.usernamePlaceholder": "uživatelské jméno",
"dialog.server.add.password": "Heslo (volitelné)",
"dialog.server.add.passwordPlaceholder": "heslo",
"dialog.server.edit.title": "Upravit server",
@@ -505,7 +503,6 @@ export const dict = {
"wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "Nainstalovaná verze neodpovídá verzi aplikace pro stolní počítače.",
"wsl.onboarding.adding": "Přidávání...",
"server.row.noUsername": "žádné uživatelské jméno",
"dialog.project.edit.title": "Upravit projekt",
"dialog.project.edit.name": "Jméno",
"dialog.project.edit.icon": "ikona",
-3
View File
@@ -346,7 +346,6 @@ export const dict = {
"dialog.server.add.button": "Tilføj server",
"dialog.server.add.name": "Servernavn (valgfrit)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Brugernavn (valgfrit)",
"dialog.server.add.password": "Adgangskode (valgfrit)",
"dialog.server.edit.title": "Rediger server",
"dialog.server.default.title": "Standardserver",
@@ -1003,9 +1002,7 @@ export const dict = {
"app.server.unreachable": "Kunne ikke nå {{server}}",
"app.server.retrying": "Prøver igen automatisk...",
"app.server.otherServers": "Andre servere",
"dialog.server.add.usernamePlaceholder": "brugernavn",
"dialog.server.add.passwordPlaceholder": "adgangskode",
"server.row.noUsername": "intet brugernavn",
"session.review.noVcs.createGit.title": "Opret et Git-repository",
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
-3
View File
@@ -327,7 +327,6 @@ export const dict = {
"dialog.server.add.button": "Server hinzufügen",
"dialog.server.add.name": "Servername (optional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Benutzername (optional)",
"dialog.server.add.password": "Passwort (optional)",
"dialog.server.edit.title": "Server bearbeiten",
"dialog.server.default.title": "Standardserver",
@@ -945,9 +944,7 @@ export const dict = {
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
"app.server.retrying": "Verbindung wird automatisch erneut hergestellt…",
"app.server.otherServers": "Andere Server",
"dialog.server.add.usernamePlaceholder": "Benutzername",
"dialog.server.add.passwordPlaceholder": "Passwort",
"server.row.noUsername": "Kein Benutzername",
"session.review.noVcs.createGit.title": "Git-Repository erstellen",
"session.review.noVcs.createGit.description":
"Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",

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