Compare commits

...
Author SHA1 Message Date
Dax Raad 9b887030e6 feat(tui): filter subagents by activity 2026-07-24 01:50:20 -04:00
Aiden ClineandGitHub 65c5c7e3f6 feat(ai): round-trip Anthropic redacted thinking blocks (#38614) 2026-07-24 00:42:44 -05:00
ee69a91f26 docs: add ideal pseudocode skill (#38611)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-23 23:02:44 -05:00
Kit LangtonandGitHub 7456598cde fix(core): share one tool snapshot per request (#38596) 2026-07-24 02:54:22 +00:00
Kit LangtonandGitHub c228fc4886 fix(core): stabilize tool definition ordering (#38590) 2026-07-23 21:30:06 -04:00
Kit LangtonandGitHub bb3f4cc3c7 fix(codemode): stabilize catalog ordering (#38588) 2026-07-23 20:56:18 -04:00
Aiden ClineandGitHub 8d9727be9f fix(core): improve patch errors (#38369) 2026-07-23 18:59:29 -05:00
Aiden ClineandGitHub 6401eeaea0 feat(ai): preserve raw finish reasons (#38423) 2026-07-23 18:16:11 -05:00
Kit LangtonandGitHub e7ecee5df2 fix(core): isolate tool hook outcomes (#38571) 2026-07-23 17:40:42 -04:00
+30 02f2725154 chore: merge dev into v2 (#38563)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
2026-07-23 16:20:37 -05:00
Kit LangtonandGitHub 79c1544072 refactor(tools): unify tool APIs and result handling (#38367) 2026-07-23 21:13:31 +00:00
Aiden ClineandGitHub 8cac010bac fix(core): stop forcing toolChoice none on session.generate (#38557) 2026-07-23 14:12:23 -05:00
Aiden ClineandGitHub 193f6be99c fix(ai): keep tools when Gemini tool choice is none (#38556) 2026-07-23 14:09:25 -05:00
2a9f8e3a2c fix(tui): manage focus in devtools panels (#38555)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-23 18:58:02 +00:00
Aiden ClineandGitHub 2c814120c7 fix(ai): keep tools when Anthropic tool_choice is none (#38553) 2026-07-23 13:52:20 -05:00
18fccac6ff fix(tui): preserve first message in new sessions (#38542)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-23 13:59:51 -04:00
360e7b412d feat(tui): expose debug settings (#38546)
Co-authored-by: James Long <longster@gmail.com>
2026-07-23 13:37:16 -04:00
ad596fb42b chore(core): upgrade fff to 0.10.1 (#38545)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-23 12:34:24 -05:00
74e92f73e0 refactor(ai): remove unused response format (#38540)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-23 12:32:33 -05:00
James LongandGitHub 8f3465c951 refactor(tui): load native V2 themes (#38430) 2026-07-23 12:35:29 -04:00
Shoubhit DashandGitHub 466b75b19d feat(cli): expand acp v1 support (#38325) 2026-07-23 21:13:46 +05:30
James LongandGitHub 833dd2ed7f refactor(tui): simplify turn usage reduction (#38514) 2026-07-23 11:22:00 -04:00
Shoubhit DashandGitHub bbe985b4d0 chore(cli): upgrade acp sdk (#38316) 2026-07-23 20:44:14 +05:30
James LongandGitHub 5b1321a8ca feat(tui): add turn token usage diagnostics (#38398) 2026-07-23 10:00:51 -04:00
Simon KleeandGitHub 52c98a4eeb mini: add replay settings to cli config (#38487) 2026-07-23 12:12:25 +02:00
b6f85c2250 fix(core): default custom model capabilities (#38449)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 23:20:16 -05:00
6e8aefcfa0 fix(ai): normalize Bedrock cache usage (#38427)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 21:42:53 -05:00
James LongandGitHub f1f0f47ee2 fix(core): migrate named agent colors (#38414) 2026-07-22 22:38:13 -04:00
Kit LangtonandGitHub 203b9f59b7 fix(core): load dynamic models for generation (#38401) 2026-07-22 22:26:08 -04:00
Aiden ClineandGitHub 48bcbd09ef fix(ai): handle incomplete responses without reasons (#38374) 2026-07-22 20:49:08 -05:00
James LongandGitHub d86f732df3 refactor(tui): generate syntax from V2 theme (#38397) 2026-07-22 21:33:25 -04:00
Dax Raad a817fe5e6c fix(schema): loosen agent color response 2026-07-22 19:22:01 -04:00
James LongandGitHub 381f6c47b4 docs(tui): add generated V2 theme reference (#38396) 2026-07-22 18:30:31 -04:00
James LongandGitHub b6e14b5a74 refactor(tui): finish V2 theme migration (#38383) 2026-07-22 17:56:22 -04:00
36979c9641 test(core): consolidate provider factory coverage (#38390)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 16:50:33 -05:00
4e067a2014 test(core): remove duplicate patch integration tests (#38389)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 16:19:06 -05:00
+30 5913c1db0b chore: merge dev into v2 (#38377)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
2026-07-22 15:33:14 -05:00
James LongandGitHub 88f572cfce refactor(tui): migrate selection views to V2 theme (#38001) 2026-07-22 16:19:00 -04:00
Aiden ClineandGitHub 2271f9b222 feat(ai): support PDF inputs (#38253) 2026-07-22 13:42:29 -05:00
James LongandGitHub 8a36abd328 feat(tui): add developer debug bar (#38359) 2026-07-22 14:35:28 -04:00
Aiden ClineandGitHub 532292b5f3 fix(core): reject malformed patch hunks (#38188) 2026-07-22 13:11:51 -05:00
Dax Raad 89e3141079 fix(core): preserve cache for session generation 2026-07-22 12:21:40 -04:00
Kit LangtonandGitHub 7a1f9764a2 feat(core): compact durable tool metadata (#38343) 2026-07-22 12:09:48 -04:00
b91dd78ab3 test(core): remove source text assertions (#38342)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-22 11:50:40 -04:00
James LongandGitHub dba5da7c10 fix(tui): restore shell mode styling (#38231) 2026-07-22 11:23:50 -04:00
aea36d7630 fix(tui): prevent duplicate message forks (#38240)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 10:11:58 -05:00
03474816ea test(core): stabilize wellknown event subscriptions (#38331)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 09:40:31 -05:00
Kit LangtonandGitHub 5a9ed4d350 fix: make tool progress live-only (#38217) 2026-07-22 10:40:07 -04:00
Simon KleeandGitHub 4204b9d087 fix(tui): show providers in Mini model search (#38321) 2026-07-22 16:02:17 +02:00
Simon KleeandGitHub 8fad13365b feat(tui): add mini splash setting (#38317)
Fix #38010
2026-07-22 15:46:50 +02:00
Simon KleeandGitHub 5841b04fe7 tui: resolve default mini footer agent (#38315) 2026-07-22 15:42:12 +02:00
Simon KleeandGitHub e12ec8681b fix(tui): render mini markdown in mono mode (#38313) 2026-07-22 15:32:43 +02:00
Simon KleeandGitHub a3e2cc0dcd feat(tui): add mini agent switching (#38287)
Fixes #36121
2026-07-22 11:14:10 +00:00
Simon KleeandGitHub 648183cecb fix(tui): quiet hidden mini footer (#38279) 2026-07-22 12:21:43 +02:00
Simon KleeandGitHub 58d18be590 oc mini v2 (#38278) 2026-07-22 11:50:21 +02:00
Simon KleeandGitHub dc9fd126a0 test(cli): cover interactive command flags (#38273) 2026-07-22 11:16:14 +02:00
Simon KleeandGitHub 794137b33b refactor(tui): narrow mini compatibility surfaces (#38262) 2026-07-22 10:35:35 +02:00
Simon KleeandGitHub 0e08b7330f fix(tui): set mini terminal title on Linux (#38261) 2026-07-22 10:35:07 +02:00
Simon KleeandGitHub ced3d5e02a fix(tui): submit prompt when resuming session (#38260) 2026-07-22 10:34:39 +02:00
Simon KleeandGitHub 6e826f3e22 fix(tui): hide project commands from mini palette (#38259) 2026-07-22 10:34:08 +02:00
Simon KleeandGitHub 8bb1cfaa3b fix: defer catalog validation to session execution (#38258) 2026-07-22 10:32:52 +02:00
Simon KleeandGitHub 691a7d93c8 feat(tui): add compact command to mini (#38251) 2026-07-22 07:03:46 +02:00
Simon KleeandGitHub ca6da05d07 fix(tui): show skill name in mini tool output (#38250) 2026-07-22 07:03:02 +02:00
Aiden ClineandGitHub 23483ea013 feat(ai): support custom reasoning fields (#38227) 2026-07-21 23:42:59 -05:00
Aiden ClineandGitHub 69c05ae3fc feat(codemode): assimilate promise thenables (#38237) 2026-07-21 23:32:02 -05:00
59ad593d9c fix(core): reduce snapshot repository setup (#38162)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-21 23:08:24 -05:00
James LongandGitHub 6b9136e797 fix(tui): inherit elevated tool theme (#38224) 2026-07-21 22:45:21 -04:00
80dc21d8f7 fix(cli): use hosted updater API (#38223)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-22 02:34:48 +00:00
Aiden ClineandGitHub c8a40450e5 feat(codemode): support generator functions (#38172) 2026-07-21 20:36:42 -05:00
Dax Raad e84938b309 fix(core): make session warming observable 2026-07-21 21:30:05 -04:00
Dax Raad c821d49386 feat(updates): add artifact build endpoints 2026-07-21 21:29:42 -04:00
Dax Raad f683eef5f9 feat(core): add session warming 2026-07-21 20:43:26 -04:00
Aiden ClineandGitHub a0a6963beb fix(core): discover Copilot API endpoint (#38184) 2026-07-21 19:28:31 -05:00
Dax Raad da35eac93f fix(sdk): build without legacy sources 2026-07-21 20:13:27 -04:00
Dax Raad c944048bcf feat(updates): add release control service 2026-07-21 19:58:56 -04:00
Dax Raad c2351e308f fix(ci): skip legacy artifacts on v2 2026-07-21 19:11:52 -04:00
DaxandGitHub 8de40be6ea feat(plugin): expose app metadata (#38179) 2026-07-21 22:16:25 +00:00
Simon KleeandGitHub 2ed8fe5960 mini: add hideable footer details (#38192) 2026-07-21 23:28:40 +02:00
DaxandGitHub 1ea9137c01 fix(core): identify spawned subagents (#38189) 2026-07-21 16:54:01 -04:00
+29 77d3289d27 chore: merge dev into v2 (#38182)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
2026-07-21 15:24:47 -05:00
e6607fb58d fix(core): authorize symlinks by lexical path (#38180)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-21 15:13:18 -05:00
128e2550ef fix(core): use shared app node import (#38181)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-21 15:08:53 -05:00
Simon KleeandGitHub e8d273ae5b mini: add monochrome ASCII mode (#38173) 2026-07-21 22:01:09 +02:00
Aiden ClineandGitHub 7ec1b6e580 feat(core): deliver CodeMode catalog through instructions (#38003) 2026-07-21 14:46:18 -05:00
Simon KleeandGitHub c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden ClineandGitHub 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
DaxandGitHub 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
caf727ecb7 fix(core): filter unsupported media inputs (#38145)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-21 13:44:10 -05:00
534 changed files with 23371 additions and 8702 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@opencode-ai/plugin": minor
"@opencode-ai/sdk": minor
"@opencode-ai/client": minor
"@opencode-ai/protocol": minor
---
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
+15 -4
View File
@@ -90,11 +90,18 @@ jobs:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build
- name: Build legacy CLI
if: github.ref_name != 'v2'
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
- name: Build preview CLI
id: build
run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -102,6 +109,7 @@ jobs:
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with:
name: opencode-cli
path: |
@@ -109,6 +117,7 @@ jobs:
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
@@ -491,11 +500,13 @@ jobs:
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-windows
path: packages/opencode/dist
+5
View File
@@ -97,6 +97,11 @@ jobs:
working-directory: packages/client
run: bun run check:generated
- name: Check generated documentation
if: runner.os == 'Linux'
working-directory: packages/docs
run: bun run check:generated
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
@@ -0,0 +1,68 @@
---
name: ideal-pseudocode
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
---
# Ideal Pseudocode
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
## Loop
One function per round. Never touch code before the user picks a direction.
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
8. **Offer the next target** and stop.
## Style
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
- Prefer early return and `if` chains for control flow; matchers are for producing values.
- Compare names against the question each caller is actually asking; rename toward the domain noun.
## Example round
Every round should read like this (abridged from a real one, on an Effect step-runner):
> `runStep` it is. The ideal pseudocode:
>
> ```ts
> // One logical step: one settled model call, however many attempts it takes.
> // An attempt can end without settling in two ways:
> // transient provider failure -> retry the same call, same assistant message
> // compaction rewrote history -> rebuild the request and call again
> function runStep(session, promotable, step) {
> while (true) {
> const result = callModel(session, promotable, step)
>
> if (result.completed) return result
>
> if (result.retryable) {
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
> promotable = none // never re-promote on a second attempt
> continue
> }
>
> // compaction restarted the step: fresh request from rewritten history
> promotable = none
> step = result.step
> }
> }
> ```
>
> Comparing against the real thing, three deltas:
>
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
>
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
>
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
+166 -15
View File
@@ -61,6 +61,7 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -124,7 +125,7 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@agentclientprotocol/sdk": "1.2.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -350,7 +351,7 @@
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -362,7 +363,7 @@
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@ff-labs/fff-bun": "0.10.1",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/ai": "workspace:*",
@@ -470,7 +471,9 @@
"packages/docs": {
"name": "@opencode-ai/docs",
"devDependencies": {
"effect": "catalog:",
"mint": "4.2.666",
"prettier": "3.6.2",
},
},
"packages/effect-drizzle-sqlite": {
@@ -706,6 +709,7 @@
"version": "1.18.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -960,6 +964,20 @@
"solid-js": "^1.9.0",
},
},
"packages/updates": {
"name": "@opencode-ai/updates",
"version": "1.18.4",
"dependencies": {
"jose": "6.0.11",
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"wrangler": "4.110.0",
},
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.18.3",
@@ -1065,6 +1083,7 @@
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
@@ -1159,7 +1178,7 @@
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
@@ -1191,7 +1210,7 @@
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
@@ -1627,23 +1646,25 @@
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="],
"@ff-labs/fff-bin-android-arm64": ["@ff-labs/fff-bin-android-arm64@0.10.1", "", { "os": "android", "cpu": "arm64" }, "sha512-6Bsaa6yKEd2HV1M2WtqSYhoZucKYffIUms6GYoPN48QHP8hZgO8GXJ85/JDxKlkCJsN2hw1ROiwQK0+xUHYhFQ=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7yUP+56sG3UTrLg7eepOD16yM2dgiD7g6Ase2XWQB7oXwLV7mBMylPKIli2pP3kHzfw9K+BS3WAwHKHJ2QhxYw=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-9zb+P1xtyqu/jVklm5RKF2zm9doRRsBNbkF/a8S4aSqSJoNlQR8ZF7C129fzOLfffVAjjgcO2l8oJgxOzHYiwQ=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-KTwr9CUfCJv0vtG2xG+nMxDae/2NJY2/oVmtgSvTnH9baI6JprqsFGphbukx7mdW/QMPwBiYtoO8ZGoC7i92jA=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-oznmSpV+zjiAPiwqbA4y+SAsMaYvDhGllHiT9L4ebdQnSOfcQzlUwvo45OhBPwHBt47tIois4bkF/MITlDk54A=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KDpl8lwSEOauP/6FSJIvnARzE+ILm2rVIRQAio9dc5nn56EvlsryBWry0c5V0Tbw1PV0lNrZ+bzcIiPuTacvzw=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-mZCpojVtGNDr/wdCUEAHdfrl6qORvKHv3Pw35TaOdshnG4wocoA5bBKTGj/zmAU11o0GMK4V+wUhdNrgqoE10w=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-SbT76ETXC5AgV9J8sVDozKG8wzrrxsOn8lbBprlOtx+O5V5EYmS1W7BlsatTHy6ddQ3uU5oOYO04PWZBuP6wXg=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dcpHUCBEZoXKQCdKa3bADfgcNyeyfM8tatXrILqIFYi9GL8E4GnzKx1rGkgP5ukVtuj0WuoJyyqSvGjpJPIT8w=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="],
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
@@ -2119,6 +2140,8 @@
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
"@opencode-ai/updates": ["@opencode-ai/updates@workspace:packages/updates"],
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
@@ -6133,7 +6156,9 @@
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6563,6 +6588,8 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
@@ -6581,12 +6608,16 @@
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opencode-ai/www/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.44.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260708.1", "unenv": "2.0.0-rc.24", "wrangler": "4.110.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-8wGGunqRcs34o4GRq0Rurp7GZg30xtLJeRGUU81a49r9zQRjlp3xIlsWr3nFlSCso4eE3cjZfiKC/2y116M4TQ=="],
@@ -6907,6 +6938,8 @@
"aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -7857,6 +7890,16 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/updates/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@opencode-ai/updates/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@opencode-ai/updates/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode-ai/updates/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="],
"@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
@@ -8017,6 +8060,8 @@
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -8705,6 +8750,72 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@opencode-ai/updates/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@opencode-ai/updates/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@@ -8989,6 +9100,44 @@
"@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
@@ -9139,6 +9288,8 @@
"@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@opencode-ai/www/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
"aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
"aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
"x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
}
}
+1 -1
View File
@@ -155,12 +155,12 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
+5 -2
View File
@@ -78,7 +78,10 @@ const streamText = LLM.stream(request).pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
if (event.type === "finish")
process.stdout.write(
`\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`,
)
}),
),
Stream.runDrain,
@@ -194,7 +197,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
event: Schema.String,
initial: () => undefined,
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
onHalt: () => [{ type: "finish", reason: "stop" }],
onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }],
},
})
+1 -1
View File
@@ -81,7 +81,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice" | "responseFormat">
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice">
export class GenerateObjectResponse<T> {
constructor(
+102 -42
View File
@@ -27,6 +27,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "anthropic-messages"
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
@@ -56,6 +57,17 @@ const AnthropicImageBlock = Schema.Struct({
})
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
const AnthropicDocumentBlock = Schema.Struct({
type: Schema.tag("document"),
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
}),
cache_control: Schema.optional(AnthropicCacheControl),
})
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
const AnthropicThinkingBlock = Schema.Struct({
type: Schema.tag("thinking"),
thinking: Schema.String,
@@ -63,6 +75,15 @@ const AnthropicThinkingBlock = Schema.Struct({
cache_control: Schema.optional(AnthropicCacheControl),
})
// Safety-filtered thinking arrives as an opaque encrypted `data` payload with
// no visible text. It must round-trip verbatim so multi-turn thinking + tool
// use conversations keep their reasoning continuity.
const AnthropicRedactedThinkingBlock = Schema.Struct({
type: Schema.tag("redacted_thinking"),
data: Schema.String,
cache_control: Schema.optional(AnthropicCacheControl),
})
const AnthropicToolUseBlock = Schema.Struct({
type: Schema.tag("tool_use"),
id: Schema.String,
@@ -101,13 +122,10 @@ const AnthropicServerToolResultBlock = Schema.Struct({
})
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
// Anthropic accepts either a plain string or an ordered array of text/image
// blocks inside `tool_result.content`. The array form is required when a tool
// returns image bytes (screenshot, image search, etc.) so they can be passed
// to the model as proper image inputs instead of being JSON-stringified into
// the prompt — which silently inflates context by megabytes and can push the
// conversation over the model's token limit.
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock])
// Anthropic accepts either a plain string or an ordered array of text, image, and
// document blocks inside `tool_result.content`. The array form keeps media as native
// model input instead of JSON-stringifying base64 into prompt text.
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicDocumentBlock])
const AnthropicToolResultBlock = Schema.Struct({
type: Schema.tag("tool_result"),
@@ -117,11 +135,17 @@ const AnthropicToolResultBlock = Schema.Struct({
cache_control: Schema.optional(AnthropicCacheControl),
})
const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock])
const AnthropicUserBlock = Schema.Union([
AnthropicTextBlock,
AnthropicImageBlock,
AnthropicDocumentBlock,
AnthropicToolResultBlock,
])
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
const AnthropicAssistantBlock = Schema.Union([
AnthropicTextBlock,
AnthropicThinkingBlock,
AnthropicRedactedThinkingBlock,
AnthropicToolUseBlock,
AnthropicServerToolUseBlock,
AnthropicServerToolResultBlock,
@@ -145,7 +169,7 @@ const AnthropicTool = Schema.Struct({
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
const AnthropicToolChoice = Schema.Union([
Schema.Struct({ type: Schema.Literals(["auto", "any"]) }),
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
])
@@ -200,6 +224,9 @@ const AnthropicStreamBlock = Schema.Struct({
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// redacted_thinking blocks arrive whole in content_block_start with the
// encrypted payload in `data`; there is no streaming delta sequence.
data: Schema.optional(Schema.String),
input: Schema.optional(Schema.Unknown),
// *_tool_result blocks arrive whole as content_block_start (no streaming
// delta) with the structured payload in `content` and the originating
@@ -273,6 +300,12 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
}
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
const anthropic = metadata?.anthropic
if (!ProviderShared.isRecord(anthropic)) return undefined
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
name: tool.name,
description: tool.description,
@@ -283,7 +316,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
auto: () => ({ type: "auto" as const }),
none: () => undefined,
none: () => ({ type: "none" as const }),
required: () => ({ type: "any" as const }),
tool: (name) => ({ type: "tool" as const, name }),
})
@@ -316,15 +349,23 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
const wireType = serverToolResultType(part.name)
if (!wireType)
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
})
const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) {
const media = yield* ProviderShared.validateMedia(
"Anthropic Messages",
part,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES)
if (media.mime === "application/pdf")
return {
type: "document" as const,
source: {
type: "base64" as const,
media_type: "application/pdf" as const,
data: media.base64,
},
} satisfies AnthropicDocumentBlock
return {
type: "image" as const,
source: {
@@ -335,25 +376,13 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
} satisfies AnthropicImageBlock
})
// Tool results may carry structured text/images. Keep media as provider-native
// Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: ToolContent,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
const media = yield* ProviderShared.validateToolFile(
"Anthropic Messages",
item,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
return {
type: "image" as const,
source: {
type: "base64" as const,
media_type: media.mime,
data: media.base64,
},
} satisfies AnthropicImageBlock
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
})
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
@@ -445,7 +474,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(yield* lowerImage(part))
content.push(yield* lowerMedia(part))
continue
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
@@ -462,11 +491,16 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
content.push({
type: "thinking",
thinking: part.text,
signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata),
})
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
// thinking; only signature-less parts carrying redactedData
// round-trip as opaque redacted_thinking blocks.
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
const redactedData = redactedDataFromMetadata(part.providerMetadata)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
continue
}
if (part.type === "tool-call") {
@@ -535,7 +569,6 @@ const outputConfig = (request: LLMRequest) => {
}
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
@@ -544,7 +577,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const tools =
request.tools.length === 0 || request.toolChoice?.type === "none"
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
lowerTool(
@@ -553,6 +586,9 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
)
// 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 system =
request.system.length === 0
? undefined
@@ -589,7 +625,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// =============================================================================
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
if (reason === "max_tokens") return "length"
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
if (reason === "tool_use") return "tool-calls"
if (reason === "refusal") return "content-filter"
return "unknown"
@@ -673,7 +709,9 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
name: SERVER_TOOL_RESULT_NAMES[block.type],
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
providerExecuted: true,
providerMetadata: anthropicMetadata({ blockType: block.type }),
// The complete payload is irreducible provider replay state: subsequent
// stateless requests must round-trip the typed result block verbatim.
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
})
}
@@ -733,6 +771,25 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
]
}
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
// payload as `redactedData` metadata (same model as Vercel's
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
if (block.type === "redacted_thinking" && block.data) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
anthropicMetadata({ redactedData: block.data }),
),
},
events,
]
}
const result = serverToolResultEvent(block)
if (!result) return [state, NO_EVENTS]
const events: LLMEvent[] = []
@@ -822,7 +879,10 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
const usage = mergeUsage(state.usage, mapUsage(event.usage))
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: mapFinishReason(event.delta?.stop_reason),
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
usage,
providerMetadata: event.delta?.stop_sequence
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
+46 -19
View File
@@ -8,6 +8,7 @@ import {
Usage,
type CacheHint,
type FinishReason,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type ModelToolSchemaCompatibility,
@@ -52,6 +53,7 @@ const BedrockToolResultContentItem = Schema.Union([
Schema.Struct({ text: Schema.String }),
Schema.Struct({ json: Schema.Unknown }),
BedrockMedia.ImageBlock,
BedrockMedia.DocumentBlock,
])
const BedrockToolResultBlock = Schema.Struct({
@@ -283,8 +285,6 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
data: item.uri,
filename: item.name,
})
if (!("image" in media))
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
content.push(media)
}
return content
@@ -393,8 +393,13 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const toolConfig =
request.tools.length > 0 && request.toolChoice?.type !== "none"
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
request.tools.length > 0
? {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
: undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)
@@ -431,27 +436,29 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// =============================================================================
const mapFinishReason = (reason: string): FinishReason => {
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
if (reason === "max_tokens") return "length"
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"
}
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
// the total through and derive the non-cached breakdown. Bedrock does
// not break reasoning out of `outputTokens` for any current model.
// AWS reports inputTokens separately from cache reads and writes.
// Bedrock does not break reasoning out of outputTokens for current models.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
if (!usage) return undefined
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
const inputTokens = ProviderShared.sumTokens(
usage.inputTokens,
usage.cacheReadInputTokens,
usage.cacheWriteInputTokens,
)
return new Usage({
inputTokens: usage.inputTokens,
inputTokens,
outputTokens: usage.outputTokens,
nonCachedInputTokens: nonCached,
nonCachedInputTokens: usage.inputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
})
}
@@ -461,7 +468,7 @@ interface ParserState {
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -578,7 +585,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage },
pendingFinish: {
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
},
},
[],
] as const
@@ -586,7 +599,16 @@ const step = (state: ParserState, event: BedrockEvent) =>
if (event.metadata) {
const usage = mapUsage(event.metadata.usage)
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
return [
{
...state,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
},
[],
] as const
}
const exception = (
@@ -619,8 +641,13 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
? (() => {
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason:
state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
reason: {
...state.pendingFinish.reason,
normalized:
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
? "tool-calls"
: state.pendingFinish.reason.normalized,
},
usage: state.pendingFinish.usage,
})
return events
+45 -15
View File
@@ -41,9 +41,11 @@ const GeminiInlineDataPart = Schema.Struct({
data: Schema.String,
}),
})
type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
const GeminiFunctionCallPart = Schema.Struct({
functionCall: Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.String,
args: Schema.Unknown,
}),
@@ -52,8 +54,10 @@ const GeminiFunctionCallPart = Schema.Struct({
const GeminiFunctionResponsePart = Schema.Struct({
functionResponse: Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.String,
response: Schema.Unknown,
parts: Schema.optional(Schema.Array(GeminiInlineDataPart)),
}),
})
@@ -197,8 +201,13 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
: undefined
}
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
const google = providerMetadata?.google
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined
}
const lowerToolCall = (part: ToolCallPart) => ({
functionCall: { name: part.name, args: part.input },
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
thoughtSignature: thoughtSignature(part.providerMetadata),
})
@@ -255,6 +264,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (part.result.type !== "content") {
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
name: part.name,
response: {
name: part.name,
@@ -266,20 +276,23 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
}
const content: ReadonlyArray<ToolContent> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
const media: GeminiInlineDataPart[] = []
for (const item of content) {
if (item.type === "text") continue
const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
}
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
name: part.name,
response: {
name: part.name,
content: text.join("\n"),
},
parts: media.length > 0 ? media : undefined,
},
})
for (const item of content) {
if (item.type === "text") continue
const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
}
}
contents.push({ role: "user", parts })
}
@@ -300,7 +313,7 @@ const thinkingConfig = (request: LLMRequest) => {
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
const hasTools = request.tools.length > 0
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
@@ -316,7 +329,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
contents: yield* lowerMessages(request),
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: toolsEnabled
tools: hasTools
? [
{
functionDeclarations: request.tools.map((tool) =>
@@ -325,7 +338,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
},
]
: undefined,
toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
toolConfig: hasTools && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
? generationConfig
: undefined,
@@ -369,10 +382,22 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
finishReason === "SAFETY" ||
finishReason === "BLOCKLIST" ||
finishReason === "PROHIBITED_CONTENT" ||
finishReason === "SPII"
finishReason === "SPII" ||
finishReason === "MODEL_ARMOR" ||
finishReason === "IMAGE_PROHIBITED_CONTENT" ||
finishReason === "IMAGE_RECITATION" ||
finishReason === "LANGUAGE"
)
return "content-filter"
if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
if (
finishReason === "MALFORMED_FUNCTION_CALL" ||
finishReason === "UNEXPECTED_TOOL_CALL" ||
finishReason === "NO_IMAGE" ||
finishReason === "TOO_MANY_TOOL_CALLS" ||
finishReason === "MISSING_THOUGHT_SIGNATURE" ||
finishReason === "MALFORMED_RESPONSE"
)
return "error"
return "unknown"
}
@@ -389,7 +414,10 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
)
: state.lifecycle
Lifecycle.finish(lifecycle, events, {
reason: mapFinishReason(state.finishReason, state.hasToolCalls),
reason: {
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
raw: state.finishReason,
},
usage: state.usage,
})
return events
@@ -441,6 +469,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
@@ -453,9 +485,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
id,
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined,
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
}),
)
hasToolCalls = true
+95 -41
View File
@@ -5,9 +5,11 @@ import { Endpoint } from "../route/endpoint"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMError,
LLMEvent,
Usage,
type FinishReason,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type MediaPart,
@@ -17,6 +19,7 @@ import {
type ToolDefinition,
type ToolContent,
} from "../schema"
import { classifyProviderFailure } from "../provider-error"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
@@ -25,6 +28,7 @@ import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-chat"
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions"
@@ -70,15 +74,18 @@ const OpenAIChatMessage = Schema.Union([
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
}),
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.NullOr(Schema.String),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: optionalArray(Schema.Unknown),
}),
Schema.StructWithRest(
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.NullOr(Schema.String),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -145,23 +152,33 @@ const OpenAIChatToolCallDelta = Schema.Struct({
})
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
const OpenAIChatDelta = Schema.Struct({
content: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Unknown),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatChoice = Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
native_finish_reason: optionalNull(Schema.String),
})
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
})
export const OpenAIChatEvent = Schema.Struct({
choices: Schema.Array(OpenAIChatChoice),
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
})
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
@@ -177,9 +194,9 @@ export interface ParserState {
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly finishReason?: FinishReasonDetails
readonly lifecycle: Lifecycle.State
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
readonly reasoningField?: string
readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
@@ -227,7 +244,7 @@ const openAICompatibleReasoningContent = (native: unknown) =>
const reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
return typeof field === "string" ? field : undefined
}
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
@@ -259,6 +276,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField?: string,
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -285,24 +303,25 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const field = (() => {
if (reasoning.length === 0) return
if (configuredField !== undefined) return configuredField
if (reasoning.length === 0) return undefined
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
})()
const reasoningContent = (() => {
const reasoningText = (() => {
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
if (reasoning.length === 0) return nativeReasoning
if (field === "reasoning_content") return text
return text
})()
return {
const result = {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content: reasoningContent,
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
reasoning_details: details,
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
})
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
@@ -328,9 +347,12 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
return { messages, images }
})
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) {
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField?: string,
) {
if (message.role === "user") return [yield* lowerUserMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
return (yield* lowerToolMessages(message)).messages
})
@@ -368,7 +390,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
}
flushImages()
return messages
@@ -386,6 +408,11 @@ const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LL
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`.
const reasoningField = request.model.compatibility?.reasoningField
if (reasoningField && RESERVED_REASONING_FIELDS.has(reasoningField))
return yield* ProviderShared.invalidRequest(
`OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`,
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
@@ -422,6 +449,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "length") return "length"
if (reason === "content_filter") return "content-filter"
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
if (reason === "error") return "error"
return "unknown"
}
@@ -446,10 +474,18 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
})
}
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
const reasoningDelta = (
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
configuredField?: string,
) => {
if (!delta) return undefined
const fields = new Set([configuredField, "reasoning_content", "reasoning", "reasoning_text"])
for (const field of fields) {
if (field === undefined) continue
const text = delta[field]
if (typeof text === "string" && text.length > 0) return { field, text }
}
return undefined
}
const detailText = (details: ReadonlyArray<unknown>) => {
@@ -507,10 +543,22 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
if (event.error)
return yield* new LLMError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message: event.error.message,
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
status: typeof event.error.code === "number" ? event.error.code : undefined,
}),
})
const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices[0]
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
const choice = event.choices?.[0]
const finishReason = choice?.finish_reason
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
: state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
@@ -518,7 +566,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta)
const reasoning = reasoningDelta(delta, state.reasoningField)
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
@@ -602,7 +650,13 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
const reason = state.finishReason
? {
...state.finishReason,
normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: undefined
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
@@ -635,12 +689,12 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({
initial: (request) => ({
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningField: undefined,
reasoningField: request.model.compatibility?.reasoningField,
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
+77 -36
View File
@@ -11,6 +11,7 @@ import {
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ReasoningPart,
type TextPart,
@@ -28,6 +29,7 @@ import { ToolStream } from "./utils/tool-stream"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-responses"
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/responses"
@@ -42,7 +44,17 @@ const OpenAIResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
})
const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
const OpenAIResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
file_data: Schema.String,
mime_type: Schema.optional(Schema.String),
})
const OpenAIResponsesInputContent = Schema.Union([
OpenAIResponsesInputText,
OpenAIResponsesInputImage,
OpenAIResponsesInputFile,
])
type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
const OpenAIResponsesOutputText = Schema.Struct({
@@ -68,9 +80,13 @@ const OpenAIResponsesItemReference = Schema.Struct({
})
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images in addition to text.
// array of content items so tools can return images and files in addition to text.
// https://platform.openai.com/docs/api-reference/responses/object
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([
OpenAIResponsesInputText,
OpenAIResponsesInputImage,
OpenAIResponsesInputFile,
])
const OpenAIResponsesFunctionCallOutput = Schema.Union([
Schema.String,
@@ -237,7 +253,7 @@ const OpenAIResponsesEvent = Schema.Struct({
Schema.Struct({
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
usage: optionalNull(OpenAIResponsesUsage),
error: optionalNull(OpenAIResponsesErrorPayload),
}),
@@ -343,42 +359,58 @@ const hostedToolItemID = (part: ToolResultPart) => {
: undefined
}
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") {
const media = yield* ProviderShared.validateMedia(
"OpenAI Responses",
part,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
return { type: "input_image" as const, image_url: media.dataUrl }
const lowerMedia = Effect.fn("OpenAIResponses.lowerMedia")(function* (part: MediaPart, provider: string) {
const media = yield* ProviderShared.validateMedia("OpenAI Responses", part, MEDIA_MIMES)
if (media.mime === "application/pdf") {
// xAI models inline bytes and MIME separately; OpenAI uses a data URL in file_data.
if (provider === "xai")
return {
type: "input_file" as const,
filename: part.filename ?? "document.pdf",
file_data: media.base64,
mime_type: media.mime,
}
return {
type: "input_file" as const,
filename: part.filename ?? "document.pdf",
file_data: media.dataUrl,
}
}
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
// Tool results may carry structured text/images. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
item: ToolContent,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
const media = yield* ProviderShared.validateToolFile(
"OpenAI Responses",
item,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
return { type: "input_image" as const, image_url: media.dataUrl }
})
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) {
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
provider: string,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMedia(part, provider)
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
item: ToolContent,
provider: string,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
provider,
)
})
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (
part: ToolResultPart,
provider: string,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, provider))
})
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
@@ -401,7 +433,10 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
}
if (message.role === "user") {
input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) })
input.push({
role: "user",
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.provider)),
})
continue
}
@@ -460,7 +495,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, lowerToolResultContentItem),
content: yield* Effect.forEach(content, (item) =>
lowerToolResultContentItem(item, request.model.provider),
),
})
}
if (itemID) hostedToolReferences.add(itemID)
@@ -483,7 +520,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part),
output: yield* lowerToolResultOutput(part, request.model.provider),
})
}
}
@@ -565,7 +602,8 @@ const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => {
const reason = event.response?.incomplete_details?.reason
if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop"
if (reason === undefined || reason === null)
return hasFunctionCall ? "tool-calls" : event.type === "response.incomplete" ? "unknown" : "stop"
if (reason === "max_output_tokens") return "length"
if (reason === "content_filter") return "content-filter"
return hasFunctionCall ? "tool-calls" : "unknown"
@@ -941,7 +979,10 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: mapFinishReason(event, state.hasFunctionCall),
reason: {
normalized: mapFinishReason(event, state.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage),
providerMetadata:
event.response?.id || event.response?.service_tier
+2 -1
View File
@@ -158,7 +158,8 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
export const PDF_MIMES = ["application/pdf"] as const
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
@@ -49,10 +49,10 @@ const DOCUMENT_FORMATS = {
"text/markdown": "md",
} as const satisfies Record<string, DocumentFormat>
const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({
const documentBlock = (name: string, format: DocumentFormat, bytes: string): DocumentBlock => ({
document: {
format,
name: part.filename ?? `document.${format}`,
name,
source: { bytes },
},
})
@@ -77,12 +77,14 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
if (documentFormat) {
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
const media = yield* ProviderShared.validateMedia(
"Bedrock Converse",
part,
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
)
return documentBlock(part, documentFormat, media.base64)
return documentBlock(part.filename, documentFormat, media.base64)
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
})
+2 -2
View File
@@ -1,4 +1,4 @@
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
export interface State {
readonly stepStarted: boolean
@@ -81,7 +81,7 @@ export const finish = (
state: State,
events: LLMEvent[],
input: {
readonly reason: FinishReason
readonly reason: FinishReasonDetails
readonly usage?: Usage
readonly providerMetadata?: ProviderMetadata
},
+15 -8
View File
@@ -34,11 +34,12 @@ import { ProviderFailureClassification } from "./errors"
*
* **Semantics by provider**:
*
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
* - OpenAI Chat / Responses / Gemini: provider reports inclusive
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
* derive the breakdown.
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
* mappers sum the breakdown to derive the inclusive `inputTokens`.
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
* `reasoningTokens` is `undefined` and `outputTokens` carries the
* combined total — a documented limitation of the Anthropic API.
@@ -190,10 +191,16 @@ export const ToolError = Schema.Struct({
}).annotate({ identifier: "LLM.Event.ToolError" })
export type ToolError = Schema.Schema.Type<typeof ToolError>
export const FinishReasonDetails = Schema.Struct({
normalized: FinishReason,
raw: Schema.optional(Schema.String),
}).annotate({ identifier: "LLM.FinishReasonDetails" })
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
export const StepFinish = Schema.Struct({
type: Schema.tag("step-finish"),
index: Schema.Number,
reason: FinishReason,
reason: FinishReasonDetails,
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.StepFinish" })
@@ -201,7 +208,7 @@ export type StepFinish = Schema.Schema.Type<typeof StepFinish>
export const Finish = Schema.Struct({
type: Schema.tag("finish"),
reason: FinishReason,
reason: FinishReasonDetails,
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.Finish" })
@@ -364,7 +371,7 @@ interface ResponseState {
readonly events: ReadonlyArray<LLMEvent>
readonly message: Message
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly finishReason?: FinishReasonDetails
readonly textParts: Readonly<Record<string, ContentAssembly>>
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
@@ -392,7 +399,7 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
return {
...state,
events,
finishReason: state.finishReason ?? "error",
finishReason: state.finishReason ?? { normalized: "error" },
}
}
return {
@@ -579,7 +586,7 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
message: Message,
events: Schema.Array(LLMEvent),
usage: Schema.optional(Usage),
finishReason: FinishReason,
finishReason: FinishReasonDetails,
}) {
/** Concatenated assistant text assembled from streamed `text-delta` events. */
get text() {
-9
View File
@@ -261,13 +261,6 @@ export namespace ToolChoice {
}
}
export const ResponseFormat = Schema.Union([
Schema.Struct({ type: Schema.Literal("text") }),
Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }),
Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }),
]).pipe(Schema.toTaggedUnion("type"))
export type ResponseFormat = Schema.Schema.Type<typeof ResponseFormat>
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String),
model: ModelSchema,
@@ -278,7 +271,6 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
responseFormat: Schema.optional(ResponseFormat),
cache: Schema.optional(CachePolicy),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
@@ -296,7 +288,6 @@ export namespace LLMRequest {
generation: request.generation,
providerOptions: request.providerOptions,
http: request.http,
responseFormat: request.responseFormat,
cache: request.cache,
metadata: request.metadata,
})
+1
View File
@@ -168,6 +168,7 @@ export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSc
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
}) {}
export namespace ModelCompatibility {
+22 -3
View File
@@ -68,10 +68,29 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
events:
settlement.result.type === "error"
? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(settlement.result.value), error }),
LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result }),
LLMEvent.toolError({
id: call.id,
name: call.name,
message: String(settlement.result.value),
error,
providerMetadata: call.providerMetadata,
}),
LLMEvent.toolResult({
id: call.id,
name: call.name,
result: settlement.result,
providerMetadata: call.providerMetadata,
}),
]
: [LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result, output: settlement.output })],
: [
LLMEvent.toolResult({
id: call.id,
name: call.name,
result: settlement.result,
output: settlement.output,
providerMetadata: call.providerMetadata,
}),
],
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ const fakeFraming: FramingDef<FakeEvent> = {
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
event.type === "finish"
? { type: "finish", reason: event.reason }
? { type: "finish", reason: { normalized: event.reason } }
: { type: "text-delta", id: "text-0", text: event.text }
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"tool",
"tool-result"
],
"name": "pdf/anthropic-tool-result",
"recordedAt": "2026-07-22T18:15:39.002Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzxyRpSVFgwTm6ccUr\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"user-input"
],
"name": "pdf/anthropic-user-input",
"recordedAt": "2026-07-22T18:15:37.979Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzsayb45rgfamcjFt3\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -0,0 +1,36 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"tool",
"tool-result"
],
"name": "pdf/bedrock-tool-result",
"recordedAt": "2026-07-22T18:15:52.400Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"call_pdf_1\",\"content\":[{\"text\":\"PDF read successfully\"},{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}}}]}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAqgAAAFLa0GiGCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSUyIsInJvbGUiOiJhc3Npc3RhbnQifXIDPnsAAADIAAAAV0lIuCQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUSJ9z2HHHgAAAMUAAABXsdh8lQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJJRC0ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PIn2+8d8RAAAAywAAAFcO6ML0CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVlcifSIeZ+kAAACzAAAAV8dKafoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiMzkxIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dyJ9HAStJQAAAMAAAABWDj/Dcws6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyJ9EPTSwQAAALAAAABRaYm2Hws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0SuCAcAAAA+AAAAE6MAqhiCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MzkyMn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIiwidXNhZ2UiOnsiaW5wdXRUb2tlbnMiOjIyNDEsIm91dHB1dFRva2VucyI6Niwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjIyNDd9fcd35Hw=",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,35 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"user-input"
],
"name": "pdf/bedrock-user-input",
"recordedAt": "2026-07-22T18:15:48.408Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAtgAAAFJ/wBIFCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNCIsInJvbGUiOiJhc3Npc3RhbnQifURlAvAAAADGAAAAV/Z4BkULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk8ifU1V/fQAAADWAAAAV5aYkccLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSUQtIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMDEyMzQ1In1Rr1g8AAAAoAAAAFfgCoSoCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZiJ9UwQMPQAAAM8AAABX+2hkNAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIzOTEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWSJ9ZmoyCwAAAJAAAABWNiwMuAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbCJ9wtmmXgAAAIgAAABR+NhFWAs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmciLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifa8D/doAAADvAAAATl7C4/ALOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjo0NTQ1fSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXYiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MTYxNCwib3V0cHV0VG9rZW5zIjo2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTYyMH19db4j2Q==",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,53 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"tool",
"tool-result"
],
"name": "pdf/gemini-tool-result",
"recordedAt": "2026-07-22T18:21:59.606Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"read_pdf\",\"args\": {\"path\": \"verification.pdf\"},\"id\": \"58shgmez\"},\"thoughtSignature\": \"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"args\":{\"path\":\"verification.pdf\"}},\"thoughtSignature\":\"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"response\":{\"name\":\"read_pdf\",\"content\":\"PDF read successfully\"},\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCHID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 123,\"candidatesTokenCount\": 8,\"totalTokenCount\": 184,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 123}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EqECCp4CARFNMg9obBl8O6iU9lawUIWiE+1vztZm9NtaT9FuyJz343hd9ruz+xPco4Q1DY1GF81ZiSI2ElBkt8Wfwsqtix9LNGSMvbZhhk/ZnB54t05M/Dft1kujcMvEdZUWUI/jWaJ349tO1bKVH9MacG5+gl0n4y8DwyQZSV3xIcet547drSkcA/TM03RB+yj1/dcLHsvUjmv9EnO897vZgO2Dk4tbZ2NyCtOeQ3JKVhUTLg2pjkGk+POCNiOdESWiUzxdQKw9LiV6nnzi071tXNiMeVimq6d7xAzRVNapI2uXynvn9Uk3eyn85purOFa8cKriK9oD6vcyGMqgd9+gu2m3to0IHqd7o+2YSr1m5qV1xT1R2/WRQEtb1b1AuOAU6w==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 1277,\"candidatesTokenCount\": 8,\"totalTokenCount\": 1338,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 1102},{\"modality\": \"TEXT\",\"tokenCount\": 175}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\n"
}
}
]
}
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"user-input"
],
"name": "pdf/gemini-user-input",
"recordedAt": "2026-07-22T18:20:55.140Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCH\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 2,\"totalTokenCount\": 127,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 8,\"totalTokenCount\": 133,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EokECoYEARFNMg8L4fpLqaX8tIQZcvw2vLt3WsFjGqpuJGgna0/AGczwuzndRcf3LGIEaliCf4ijVOb1AG4/VPBh1kMzfjeAyHhvWIe4yQVoBwI7BjpFyLie+SnGTXQXKKy5ygRqRLFsV6DcAixNXXBHJw2x/2Nhtriryqs4fhWrL/P7ppHC10sMnTwN6Mw5x20NKwgT+rrw6lvYmQe9rdQsBJ6Zmp0GpPlwZZiAgzvwPfVoNwHSGb54xe/T9wjISjwWNgpedhbsIBDRZFDwruS4x57KBKeMPO69GLfeMP8PJ7rpR0HgT7nRbrl/OdykG/jqSMTvoRSqxawsD+Yr/DukgGatyfB5Ic+X4RhD07URpkGTAu/cakBtzhSmM/hpzKU9m/cId1UCjopLTtonUqSAKkroPdp8kIYw0MI2OZCNVwbDrdClUPmjRKfcTkcC2jNj1rS+WDFbm+mo+SP3rDSvvCdyJuiXHGKiM2EhbYnu42aHVC6w7eAe4Gv3Fq/0faW47r0ihbiAohFB9XUA+fD07g83EjIuc9Q6BRVTTcBfoRkrR/yFZKt3qwPq02W6rPD13/1wAnMtabNcxePMMGk7Dlxwng9yPS0NEge2KD+miOj9SC4aTvOTq2451tfK1x3UZqqb205zGOPbjizhH/CA/PGkG84hdkAG4mrUK0rEHqeWwRXDsxpyfto=\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 530,\"candidatesTokenCount\": 8,\"totalTokenCount\": 653,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 520},{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7 -2
View File
@@ -59,7 +59,12 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
...request.messages,
Message.assistant(state.assistantContent),
...dispatched.map(([call, dispatched]) =>
Message.tool({ id: call.id, name: call.name, result: dispatched.result }),
Message.tool({
id: call.id,
name: call.name,
result: dispatched.result,
providerMetadata: call.providerMetadata,
}),
),
],
})
@@ -78,7 +83,7 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
const stepState = (events: ReadonlyArray<LLMEvent>) => {
const assistantContent: ContentPart[] = []
const toolCalls: ToolCallPart[] = []
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = { normalized: "unknown" }
let usage: Usage | undefined
let providerMetadata: ProviderMetadata | undefined
+1 -1
View File
@@ -191,7 +191,7 @@ describe("llm constructors", () => {
LLMResponse.text({
events: [
{ type: "text-delta", id: "text-0", text: "hi" },
{ type: "finish", reason: "stop" },
{ type: "finish", reason: { normalized: "stop" } },
],
}),
).toBe("hi")
@@ -235,9 +235,37 @@ describe("Anthropic Messages route", () => {
}),
)
// Regression: screenshot/read tool results must stay structured so base64
// image data is not JSON-stringified into `tool_result.content`.
it.effect("lowers image tool-result content as structured image blocks", () =>
it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_choice_none",
model,
tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object", properties: {} } }],
messages: [
Message.user("What is the weather?"),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
],
toolChoice: "none",
cache: "none",
}),
)
expect(prepared.body.tools).toEqual([
{
name: "lookup",
description: "Look things up",
input_schema: { type: "object", properties: {} },
},
])
expect(prepared.body.tool_choice).toEqual({ type: "none" })
}),
)
// Regression: read tool results must stay structured so base64 media data is
// not JSON-stringified into `tool_result.content`.
it.effect("lowers media tool-result content as structured blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
@@ -253,6 +281,7 @@ describe("Anthropic Messages route", () => {
result: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
],
}),
],
@@ -263,6 +292,7 @@ describe("Anthropic Messages route", () => {
expect(expectToolResult(prepared.body).content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
])
}),
)
@@ -292,7 +322,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
@@ -379,6 +409,34 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
{ type: "reasoning", text: "visible", providerMetadata: { anthropic: { signature: "sig_1" } } },
]),
],
}),
)
expect(prepared.body).toMatchObject({
messages: [
{
role: "assistant",
content: [
{ type: "redacted_thinking", data: "opaque_1" },
{ type: "thinking", thinking: "visible", signature: "sig_1" },
],
},
],
})
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -418,12 +476,149 @@ describe("Anthropic Messages route", () => {
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: "stop",
reason: { normalized: "stop", raw: "end_turn" },
providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
})
}),
)
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "redacted_thinking", data: "opaque_1" } },
{ 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: 2 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find((event) => event.type === "reasoning-start")).toMatchObject({
providerMetadata: { anthropic: { redactedData: "opaque_1" } },
})
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
{ type: "text", text: "Hello" },
])
}),
)
it.effect("round-trips streamed redacted thinking with tool use into a continuation request", () =>
Effect.gen(function* () {
// Anthropic types `redacted_thinking.data` as an opaque string. Its
// contents are provider-owned and must be replayed without inspection.
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "redacted_thinking", data: redactedData },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model,
messages: [
Message.user("Say hello."),
response.message,
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
],
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Say hello." }] },
{
role: "assistant",
content: [
{ type: "redacted_thinking", data: redactedData },
{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: "sunny",
is_error: undefined,
cache_control: undefined,
},
],
},
])
}),
)
it.effect("maps context-window truncation to length", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "message_delta",
delta: { stop_reason: "model_context_window_exceeded" },
usage: { output_tokens: 1 },
},
),
),
),
)
expect(response.finishReason).toEqual({ normalized: "length", raw: "model_context_window_exceeded" })
}),
)
it.effect("preserves pause_turn while normalizing it to stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
),
),
),
)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "pause_turn" })
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -473,10 +668,16 @@ describe("Anthropic Messages route", () => {
providerExecuted: undefined,
providerMetadata: undefined,
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: "tool_use" },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "tool-calls",
reason: { normalized: "tool-calls", raw: "tool_use" },
providerMetadata: undefined,
usage,
},
@@ -634,10 +835,20 @@ describe("Anthropic Messages route", () => {
name: "web_search",
result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
providerExecuted: true,
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
// The complete payload rides in provider metadata as irreducible replay
// state for later stateless requests.
providerMetadata: {
anthropic: {
blockType: "web_search_tool_result",
result: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
},
},
})
expect(response.text).toBe("Found it.")
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
})
}),
)
@@ -756,7 +967,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("continues a conversation with user image content", () =>
it.effect("continues a conversation with user media content", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
@@ -766,6 +977,7 @@ describe("Anthropic Messages route", () => {
Message.user([
{ type: "text", text: "What is in this image?" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
]),
],
}),
@@ -781,6 +993,7 @@ describe("Anthropic Messages route", () => {
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
],
@@ -13,12 +13,8 @@ const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
// call wouldn't deterministically prove cache mapping works. Override with
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
const model = AmazonBedrock.configure({
credentials: {
region: RECORDING_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
sessionToken: process.env.AWS_SESSION_TOKEN,
},
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: RECORDING_REGION,
}).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0")
const cacheRequest = LLM.request({
@@ -36,7 +32,7 @@ const recorded = recordedTests({
prefix: "bedrock-converse-cache",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
// Two identical requests in one cassette — replay walks the cassette in
// recording order so the second call replays the cached-hit interaction.
})
@@ -45,10 +41,20 @@ describe("Bedrock Converse cache recorded", () => {
recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(cacheRequest)
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
expect(first.usage?.cacheWriteInputTokens ?? 0).toBeGreaterThan(0)
expect(first.usage?.inputTokens).toBe(
(first.usage?.nonCachedInputTokens ?? 0) +
(first.usage?.cacheReadInputTokens ?? 0) +
(first.usage?.cacheWriteInputTokens ?? 0),
)
const second = yield* LLMClient.generate(cacheRequest)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
expect(second.usage?.inputTokens).toBe(
(second.usage?.nonCachedInputTokens ?? 0) +
(second.usage?.cacheReadInputTokens ?? 0) +
(second.usage?.cacheWriteInputTokens ?? 0),
)
}),
)
})
@@ -154,6 +154,36 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(baseRequest, {
tools: [
{
name: "lookup",
description: "Lookup data",
inputSchema: { type: "object", properties: { query: { type: "string" } } },
},
],
toolChoice: ToolChoice.make({ type: "none" }),
}),
)
expect(prepared.body.toolConfig).toMatchObject({
tools: [
{
toolSpec: {
name: "lookup",
description: "Lookup data",
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
},
},
],
})
expect(prepared.body.toolConfig?.toolChoice).toBeUndefined()
}),
)
it.effect("lowers assistant tool-call + tool-result message history", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
@@ -260,7 +290,10 @@ describe("Bedrock Converse route", () => {
// `metadata` (carries usage). We consolidate them into a single
// terminal `finish` event with both.
expect(finishes).toHaveLength(1)
expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
expect(finishes[0]).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
})
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
@@ -269,6 +302,56 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("maps truncation and malformed output stop reasons", () =>
Effect.gen(function* () {
const reasons = [
["model_context_window_exceeded", "length"],
["malformed_model_output", "error"],
["malformed_tool_use", "error"],
] as const
for (const [raw, normalized] of reasons) {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
)
expect(response.finishReason).toEqual({ normalized, raw })
}
}),
)
it.effect("adds cache reads and writes to Bedrock input usage", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
[
"metadata",
{
usage: {
inputTokens: 5,
outputTokens: 2,
totalTokens: 12,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
},
},
],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.usage).toMatchObject({
inputTokens: 10,
nonCachedInputTokens: 5,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
outputTokens: 2,
totalTokens: 12,
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -299,7 +382,10 @@ describe("Bedrock Converse route", () => {
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
])
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "tool_use" },
})
}),
)
@@ -325,7 +411,7 @@ describe("Bedrock Converse route", () => {
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
)
@@ -549,10 +635,12 @@ describe("Bedrock Converse route", () => {
LLM.request({
id: "req_doc",
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Summarize these documents." },
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" },
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==", filename: "data.csv" },
]),
],
}),
@@ -563,10 +651,9 @@ describe("Bedrock Converse route", () => {
{
role: "user",
content: [
// Filename round-trips when supplied.
{ text: "Summarize these documents." },
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
// Falls back to a stable placeholder when filename is missing.
{ document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } },
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
],
},
],
@@ -574,6 +661,96 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("requires names for document media", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("document media requires a filename")
}),
)
it.effect("passes named document-only messages through for provider validation", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
cache: "none",
messages: [
Message.user({
type: "media",
mediaType: "application/pdf",
data: "UERGREFUQQ==",
filename: "report.pdf",
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
},
])
}),
)
it.effect("lowers document media in tool results", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Read successfully" },
{
type: "file",
uri: "data:application/pdf;base64,UERGREFUQQ==",
mime: "application/pdf",
name: "report",
},
],
},
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ toolUse: { toolUseId: "call_1", name: "read", input: { path: "report.pdf" } } }],
},
{
role: "user",
content: [
{
toolResult: {
toolUseId: "call_1",
status: "success",
content: [
{ text: "Read successfully" },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
],
},
},
],
},
])
}),
)
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
+123 -21
View File
@@ -70,6 +70,7 @@ describe("Gemini route", () => {
Message.user([
{ type: "text", text: "What is in this image?" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=" },
]),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
@@ -81,7 +82,11 @@ describe("Gemini route", () => {
contents: [
{
role: "user",
parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
parts: [
{ text: "What is in this image?" },
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
{
role: "model",
@@ -90,7 +95,12 @@ describe("Gemini route", () => {
{
role: "user",
parts: [
{ functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } },
{
functionResponse: {
name: "lookup",
response: { name: "lookup", content: '{"forecast":"sunny"}' },
},
},
],
},
],
@@ -110,7 +120,7 @@ describe("Gemini route", () => {
}),
)
it.effect("continues image tool results as inline vision input without base64 text", () =>
it.effect("continues media tool results as inline model input without base64 text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({
@@ -125,6 +135,7 @@ describe("Gemini route", () => {
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
],
},
}),
@@ -141,9 +152,12 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
},
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
],
},
])
@@ -174,8 +188,13 @@ describe("Gemini route", () => {
{
role: "user",
parts: [
{ functionResponse: { name: "read", response: { name: "read", content: "" } } },
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
{
functionResponse: {
name: "read",
response: { name: "read", content: "" },
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
},
},
],
},
])
@@ -214,11 +233,11 @@ describe("Gemini route", () => {
}),
)
it.effect("omits tools when tool choice is none", () =>
it.effect("keeps tools and sends function calling mode NONE", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_no_tools",
id: "req_tool_choice_none",
model,
prompt: "Say hello.",
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
@@ -226,8 +245,10 @@ describe("Gemini route", () => {
}),
)
expect(prepared.body).toEqual({
expect(prepared.body).toMatchObject({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }],
toolConfig: { functionCallingConfig: { mode: "NONE" } },
})
}),
)
@@ -352,10 +373,16 @@ describe("Gemini route", () => {
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "stop", raw: "STOP" },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "stop",
reason: { normalized: "stop", raw: "STOP" },
usage,
},
])
@@ -372,7 +399,10 @@ describe("Gemini route", () => {
parts: [
{ text: "thinking", thought: true },
{ text: "", thought: true, thoughtSignature: "thought_sig" },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
{
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
thoughtSignature: "tool_sig",
},
],
},
finishReason: "STOP",
@@ -398,7 +428,10 @@ describe("Gemini route", () => {
id: "reasoning-0",
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
expect(toolCall).toMatchObject({
id: "tool_0",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
})
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex((event) => event.type === "tool-call"),
)
@@ -416,6 +449,13 @@ describe("Gemini route", () => {
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "tool_0",
name: "lookup",
result: "done",
resultType: "text",
providerMetadata: toolCall?.providerMetadata,
}),
],
}),
)
@@ -424,7 +464,22 @@ describe("Gemini route", () => {
role: "model",
parts: [
{ text: "thinking", thought: true, thoughtSignature: "thought_sig" },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
{
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
thoughtSignature: "tool_sig",
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "provider_call",
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
])
@@ -480,10 +535,16 @@ describe("Gemini route", () => {
providerExecuted: undefined,
providerMetadata: undefined,
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: "STOP" },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "tool-calls",
reason: { normalized: "tool-calls", raw: "STOP" },
usage,
},
])
@@ -498,7 +559,7 @@ describe("Gemini route", () => {
content: {
role: "model",
parts: [
{ functionCall: { name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } },
],
},
@@ -513,10 +574,19 @@ describe("Gemini route", () => {
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "tool_0" } },
},
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" },
})
}),
)
@@ -536,9 +606,41 @@ describe("Gemini route", () => {
)
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
expect(length.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "length", raw: "MAX_TOKENS" },
})
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
expect(filtered.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "content-filter", raw: "SAFETY" },
})
}),
)
it.effect("maps current blocking and invalid-output finish reasons", () =>
Effect.gen(function* () {
const reasons = [
["MODEL_ARMOR", "content-filter"],
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
["IMAGE_RECITATION", "content-filter"],
["LANGUAGE", "content-filter"],
["UNEXPECTED_TOOL_CALL", "error"],
["NO_IMAGE", "error"],
["IMAGE_OTHER", "unknown"],
["TOO_MANY_TOOL_CALLS", "error"],
["MISSING_THOUGHT_SIGNATURE", "error"],
["MALFORMED_RESPONSE", "error"],
] as const
for (const [raw, normalized] of reasons) {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: raw }] })),
),
)
expect(response.finishReason).toEqual({ normalized, raw })
}
}),
)
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMResponse } from "../../src"
import { LLM, LLMEvent, LLMResponse, Model } from "../../src"
import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
@@ -11,22 +11,28 @@ import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop
const cases = [
{
name: "OpenRouter",
model: OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
model: Model.update(
OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
{ compatibility: { reasoningField: "reasoning" } },
),
requires: ["OPENROUTER_API_KEY"],
cassette: "openrouter-reasoning",
structured: true,
},
{
name: "Vercel AI Gateway",
model: OpenAICompatible.configure({
provider: "vercel-ai-gateway",
baseURL: "https://ai-gateway.vercel.sh/v1",
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
model: Model.update(
OpenAICompatible.configure({
provider: "vercel-ai-gateway",
baseURL: "https://ai-gateway.vercel.sh/v1",
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
{ compatibility: { reasoningField: "reasoning" } },
),
requires: ["AI_GATEWAY_API_KEY"],
cassette: "vercel-ai-gateway-reasoning",
structured: true,
+84 -4
View File
@@ -92,6 +92,45 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
messages: [
Message.assistant([
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { reasoningField: "reasoning" } },
},
{ type: "text", text: "Hello" },
]),
Message.assistant("Done"),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
{ role: "assistant", content: "Done", vendor_reasoning: "" },
])
}),
)
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
}),
).pipe(Effect.flip)
expect(error.message).toContain("reserved field content")
}),
)
it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
@@ -530,10 +569,16 @@ describe("OpenAI Chat route", () => {
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "stop", raw: "stop" },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "stop",
reason: { normalized: "stop", raw: "stop" },
usage,
},
])
@@ -570,6 +615,35 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("parses and replays a configured custom reasoning field", () =>
Effect.gen(function* () {
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
const response = yield* LLMClient.generate(LLM.updateRequest(request, { model: custom })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { vendor_reasoning: "thinking" } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "vendor_reasoning" },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: custom, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
])
}),
)
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
Effect.gen(function* () {
const details = [
@@ -969,8 +1043,14 @@ describe("OpenAI Chat route", () => {
providerExecuted: undefined,
providerMetadata: undefined,
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
{ type: "finish", reason: "tool-calls", usage: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: "tool_calls" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "tool-calls", raw: "tool_calls" }, usage: undefined },
])
}),
)
@@ -232,7 +232,10 @@ describe("OpenAI-compatible Chat route", () => {
expect(response.text).toBe("Hello!")
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "stop" },
})
}),
)
})
@@ -5,6 +5,7 @@ import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart,
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as XAI from "../../src/providers/xai"
import * as OpenAIResponses from "../../src/protocols/openai-responses"
import * as ProviderShared from "../../src/protocols/shared"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
@@ -16,6 +17,8 @@ const model = OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" })
const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
const request = LLM.request({
id: "req_1",
model,
@@ -524,7 +527,77 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
it.effect("lowers PDF tool-result content as structured input_file array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_pdf",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{
type: "file",
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
mime: "application/pdf",
name: "report.pdf",
},
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
])
}),
)
it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: xaiModel,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{
type: "file",
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
mime: "application/pdf",
name: "report.pdf",
},
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
])
}),
)
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
@@ -783,13 +856,13 @@ describe("OpenAI Responses route", () => {
{
type: "step-finish",
index: 0,
reason: "stop",
reason: { normalized: "stop", raw: undefined },
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage,
},
{
type: "finish",
reason: "stop",
reason: { normalized: "stop", raw: undefined },
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage,
},
@@ -797,6 +870,39 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("maps incomplete response reasons", () =>
Effect.gen(function* () {
const generate = (incompleteDetails: object) =>
LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.incomplete",
response: { id: "resp_incomplete", incomplete_details: incompleteDetails },
}),
),
),
)
const length = yield* generate({ reason: "max_output_tokens" })
const contentFilter = yield* generate({ reason: "content_filter" })
const unknown = yield* generate({})
const custom = yield* generate({ reason: "provider_limit" })
expect([
length.finishReason,
contentFilter.finishReason,
unknown.finishReason,
custom.finishReason,
]).toEqual([
{ normalized: "length", raw: "max_output_tokens" },
{ normalized: "content-filter", raw: "content_filter" },
{ normalized: "unknown", raw: undefined },
{ normalized: "unknown", raw: "provider_limit" },
])
}),
)
// OpenAI's documented stream orders output text within one message item; no
// provider-valid same-kind overlap is evidenced, so done boundaries close it.
it.effect("closes sequential output messages before starting the next", () =>
@@ -847,8 +953,8 @@ describe("OpenAI Responses route", () => {
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
@@ -939,8 +1045,8 @@ describe("OpenAI Responses route", () => {
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
}),
)
@@ -1323,10 +1429,16 @@ describe("OpenAI Responses route", () => {
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: undefined },
usage,
providerMetadata: undefined,
},
{
type: "finish",
reason: "tool-calls",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: undefined,
usage,
},
@@ -1366,7 +1478,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
expect(response.finishReason.normalized).toBe("tool-calls")
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}),
)
@@ -1393,7 +1505,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
@@ -1526,20 +1638,64 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers user image content", () =>
it.effect("lowers user image and PDF content", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_media",
model,
messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }],
content: [
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
],
},
])
}),
)
it.effect("uses xAI inline file encoding for user PDFs", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: xaiModel,
messages: [
Message.user({
type: "media",
mediaType: "application/pdf",
data: "data:application/pdf;base64,JVBERi0xLjQ=",
filename: "report.pdf",
}),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
],
},
])
}),
@@ -1551,11 +1707,11 @@ describe("OpenAI Responses route", () => {
LLM.request({
id: "req_media",
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })],
messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("OpenAI Responses does not support media type application/pdf")
expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar")
}),
)
@@ -4,6 +4,8 @@ import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
describe("OpenRouter", () => {
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
@@ -54,6 +56,42 @@ describe("OpenRouter", () => {
}),
)
it.effect("preserves the upstream provider finish reason", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }],
}),
),
),
)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("fails on a mid-stream provider error", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
error: { code: 502, message: "Provider disconnected" },
}),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.message).toContain("Provider disconnected")
}),
)
it.effect("preserves manually supplied reasoning details", () =>
Effect.gen(function* () {
const details = [
@@ -0,0 +1,207 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMResponse, Message, ToolDefinition, type Model } from "../../src"
import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers"
import { LLMClient } from "../../src/route"
import { Tool } from "../../src/tool"
import { runTools } from "../lib/tool-runtime"
import { recordedTests } from "../recorded-test"
const CODE = "ORCHID-7391"
const PDF =
"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" })
const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" })
const google = Google.configure({ apiKey: process.env.GOOGLE_API_KEY ?? "fixture" })
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
const bedrock = AmazonBedrock.configure({
apiKey: process.env.AWS_BEDROCK_API_KEY ?? "fixture",
region: process.env.AWS_REGION ?? "us-east-1",
})
const targets: ReadonlyArray<{
readonly id: string
readonly name: string
readonly provider: string
readonly protocol: string
readonly requires: string
readonly filename: string
readonly maxTokens: number
readonly model: Model
}> = [
{
id: "openai",
name: "OpenAI Responses gpt-4o-mini",
provider: "openai",
protocol: "openai-responses",
requires: "OPENAI_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
model: openai.responses("gpt-4o-mini"),
},
{
id: "anthropic",
name: "Anthropic Haiku 4.5",
provider: "anthropic",
protocol: "anthropic-messages",
requires: "ANTHROPIC_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
model: anthropic.model("claude-haiku-4-5-20251001"),
},
{
id: "gemini",
name: "Gemini 3.5 Flash",
provider: "google",
protocol: "gemini",
requires: "GOOGLE_API_KEY",
filename: "verification.pdf",
maxTokens: 256,
model: google.model("gemini-3.5-flash"),
},
{
id: "xai",
name: "xAI Grok 4.5",
provider: "xai",
protocol: "openai-responses",
requires: "XAI_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
model: xai.responses("grok-4.5"),
},
{
id: "bedrock",
name: "Bedrock Claude Haiku 4.5",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: "AWS_BEDROCK_API_KEY",
filename: "verification",
maxTokens: 40,
model: bedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0"),
},
]
const recorded = recordedTests({ prefix: "pdf", tags: ["pdf"] })
const prompt = "Return only the verification code from the PDF."
const readPdf = ToolDefinition.make({
name: "read_pdf",
description: "Read the attached PDF.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
})
const readPdfRuntime = Tool.make({
description: readPdf.description,
parameters: Schema.Struct({ path: Schema.String }),
success: Schema.String,
execute: () => Effect.succeed("PDF read successfully"),
toModelOutput: () => [
{ type: "text", text: "PDF read successfully" },
{
type: "file",
uri: `data:application/pdf;base64,${PDF}`,
mime: "application/pdf",
name: "verification.pdf",
},
],
})
const expectCode = (response: LLMResponse) => {
expect(response.finishReason.normalized).toBe("stop")
expect(response.text.toUpperCase()).toContain(CODE)
}
describe("PDF recorded", () => {
for (const target of targets) {
recorded.effect.with(
`reads a user PDF with ${target.name}`,
{
id: `${target.id}-user-input`,
provider: target.provider,
protocol: target.protocol,
requires: [target.requires],
tags: ["user-input"],
},
Effect.gen(function* () {
expectCode(
yield* LLMClient.generate(
LLM.request({
id: `recorded_pdf_${target.id}_user_input`,
model: target.model,
cache: "none",
generation: { maxTokens: target.maxTokens, temperature: 0 },
messages: [
Message.user([
{ type: "media", mediaType: "application/pdf", data: PDF, filename: target.filename },
{ type: "text", text: prompt },
]),
],
}),
),
)
}),
)
recorded.effect.with(
`reads a PDF tool result with ${target.name}`,
{
id: `${target.id}-tool-result`,
provider: target.provider,
protocol: target.protocol,
requires: [target.requires],
tags: ["tool", "tool-result"],
},
Effect.gen(function* () {
if (target.id === "gemini") {
const events = Array.from(
yield* runTools({
request: LLM.request({
id: "recorded_pdf_gemini_tool_result",
model: target.model,
system:
"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.",
prompt: "Use read_pdf with path verification.pdf and return the verification code.",
cache: "none",
generation: { maxTokens: target.maxTokens, temperature: 0 },
}),
tools: { read_pdf: readPdfRuntime },
}).pipe(Stream.runCollect),
)
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "stop" } })
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
return
}
expectCode(
yield* LLMClient.generate(
LLM.request({
id: `recorded_pdf_${target.id}_tool_result`,
model: target.model,
system: "Read the PDF returned by the tool and follow the user's response format exactly.",
cache: "none",
generation: { maxTokens: target.maxTokens, temperature: 0 },
messages: [
Message.user(prompt),
Message.assistant([{ type: "tool-call", id: "call_pdf_1", name: readPdf.name, input: {} }]),
Message.tool({
id: "call_pdf_1",
name: readPdf.name,
resultType: "content",
result: [
{ type: "text", text: "PDF read successfully" },
{
type: "file",
uri: `data:application/pdf;base64,${PDF}`,
mime: "application/pdf",
name: target.filename,
},
],
}),
],
tools: [readPdf],
}),
),
)
}),
)
}
})
+5 -5
View File
@@ -125,8 +125,8 @@ const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
export const expectFinish = (
events: ReadonlyArray<LLMEvent>,
reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
reason: FinishReason,
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
export const expectWeatherToolCall = (response: LLMResponse) =>
expect(response.toolCalls).toMatchObject([
@@ -136,10 +136,10 @@ export const expectWeatherToolCall = (response: LLMResponse) =>
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
const finishes = events.filter(LLMEvent.is.finish)
expect(finishes).toHaveLength(1)
expect(finishes[0]?.reason).toBe("stop")
expect(finishes[0]?.reason.normalized).toBe("stop")
const stepFinishes = events.filter(LLMEvent.is.stepFinish)
expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
expect(stepFinishes.map((event) => event.reason.normalized)).toEqual(["tool-calls", "stop"])
const toolCalls = events.filter(LLMEvent.is.toolCall)
expect(toolCalls).toHaveLength(1)
@@ -503,7 +503,7 @@ export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
continue
}
if (event.type === "finish") {
summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
summary.push({ type: "finish", reason: event.reason.normalized, usage: usageSummary(event.usage) })
}
}
return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
+15 -7
View File
@@ -14,11 +14,11 @@ describe("LLMResponse reducer", () => {
LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }),
LLMEvent.textDelta({ id: "t1", text: "Answer" }),
LLMEvent.textEnd({ id: "t1" }),
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }),
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 5 } }),
]
const response = LLMResponse.fromEvents(events)
expect(response?.finishReason).toBe("stop")
expect(response?.finishReason).toEqual({ normalized: "stop" })
expect(response?.usage).toMatchObject({ outputTokens: 5 })
expect(response?.events).toEqual(events)
expect(response?.events.map((event) => event.type)).toEqual([
@@ -62,18 +62,26 @@ describe("LLMResponse reducer", () => {
test("uses terminal usage when present and keeps prior usage when finish omits it", () => {
const withFinishUsage = LLMResponse.fromEvents([
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }),
])
const withoutFinishUsage = LLMResponse.fromEvents([
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: "stop" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 })
expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 })
})
test("preserves the raw finish reason", () => {
const response = LLMResponse.fromEvents([
LLMEvent.finish({ reason: { normalized: "unknown", raw: "provider_limit" } }),
])
expect(response?.finishReason).toEqual({ normalized: "unknown", raw: "provider_limit" })
})
test("assembles tool-call content only after the completed tool call event", () => {
const pending = reduce([
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
@@ -88,7 +96,7 @@ describe("LLMResponse reducer", () => {
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }),
LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }),
LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }),
LLMEvent.finish({ reason: "tool-calls" }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
])
expect(response?.message.content).toEqual([
+6 -2
View File
@@ -48,8 +48,12 @@ describe("llm schema", () => {
})
test("finish constructors accept usage input", () => {
expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage)
expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
expect(
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage,
).toBeInstanceOf(Usage)
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(
Usage,
)
})
test("content part tagged union exposes guards", () => {
+45
View File
@@ -183,6 +183,51 @@ describe("LLMClient tools", () => {
}),
)
it.effect("preserves provider metadata on dispatched tool results", () =>
Effect.gen(function* () {
const tool = Tool.make({
description: "Return text.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const providerMetadata = { google: { functionCallId: "provider_call" } }
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
)
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "tool",
result: { type: "text", value: "hello" },
output: { structured: "hello", content: [{ type: "text", text: "hello" }] },
providerMetadata,
}),
])
const failed = yield* ToolRuntime.dispatch(
{},
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
)
expect(failed.events).toEqual([
LLMEvent.toolError({
id: "call_2",
name: "missing",
message: "Unknown tool: missing",
providerMetadata,
}),
LLMEvent.toolResult({
id: "call_2",
name: "missing",
result: { type: "error", value: "Unknown tool: missing" },
providerMetadata,
}),
])
}),
)
it.effect("uses the narrow default projection for encoded typed success", () =>
Effect.gen(function* () {
const text = Tool.make({
@@ -97,6 +97,7 @@ export async function setupTimeline(
locale?: string
deviceScaleFactor?: number
seedHistory?: boolean
protocol?: "v1" | "v2"
} = {},
) {
const sessions = input.sessions ?? [session()]
@@ -114,6 +115,7 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20,
})
await mockOpenCodeServer(page, {
protocol: input.protocol,
directory,
project: project(),
provider: provider(),
@@ -32,6 +32,23 @@ for (const expanded of [false, true]) {
})
}
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
const id = "prt_shell_running_command"
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: { shellToolPartsExpanded: false },
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await tool.locator('[data-slot="collapsible-trigger"]').click()
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
const reasoningID = "prt_reasoning_hidden"
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
})
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
})
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBeUndefined()
})
test("passes through non-event fetches", async ({ page }) => {
+276 -2
View File
@@ -4,6 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
export interface MockServerConfig {
protocol?: "v1" | "v2"
provider: unknown
directory: string
project: unknown
@@ -53,8 +54,20 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/global/event" || path === "/event" || path === "/api/event") {
const events = config.events?.()
return sse(
route,
path === "/api/event"
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
: events,
config.eventRetry,
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
@@ -83,10 +96,129 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
},
data: [],
})
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
})
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
if (path === "/api/path")
return json(route, {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
})
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/question/request")
return json(route, {
location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path === "/api/session") {
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => !directory || session.directory === directory)
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path in staticRoutes) return json(route, staticRoutes[path])
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1])
@@ -107,6 +239,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (currentMessagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: pageData.items.map(currentMessage).reverse(),
cursor: { next: cursor },
})
}
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("before") ?? undefined
@@ -129,6 +279,115 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
function location(config: MockServerConfig) {
return {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory },
}
}
function currentPermission(value: unknown) {
const permission = value as Record<string, unknown>
if (permission.action) return permission
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
return {
id: permission.id,
sessionID: permission.sessionID,
action: permission.permission,
resources: permission.patterns ?? [],
save: permission.always,
metadata: permission.metadata,
source:
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
}
}
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {}
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID ?? "project",
agent: session.agent ?? "build",
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: "created" in time && typeof time.created === "number" ? time.created : 0,
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
...(session.time && typeof session.time === "object" && "archived" in session.time
? { archived: session.time.archived }
: {}),
},
title: session.title ?? session.id,
location: {
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
},
subpath: session.path,
revert: session.revert,
}
}
function currentMessage(value: unknown) {
const item = value as {
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
parts: Array<Record<string, unknown> & { type: string }>
}
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user",
time: item.info.time,
text: item.parts
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n"),
}
}
return {
id: item.info.id,
type: "assistant",
time: item.info.time,
agent: item.info.agent ?? "build",
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
cost: item.info.cost,
tokens: item.info.tokens,
error: item.info.error,
content: item.parts.flatMap<unknown>((part) => {
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
if (part.type !== "tool") return []
const state = part.state as Record<string, unknown>
return [
{
type: "tool",
id: part.id,
name: part.tool,
time: state.time ?? { created: item.info.time.created },
state:
state.status === "pending"
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
: state.status === "completed"
? {
status: "completed",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [{ type: "text", text: state.output ?? "" }],
}
: state.status === "error"
? {
status: "error",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [],
error: { type: "ToolError", message: state.error ?? "Tool failed" },
}
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
},
]
}),
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
@@ -149,3 +408,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
function currentEvent(input: unknown) {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
+29 -9
View File
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
export type SseConnectionRecord = {
id: number
url: string
path: "/global/event" | "/event"
path: "/global/event" | "/event" | "/api/event"
headers: Record<string, string>
openedAt: number
endedAt?: number
@@ -93,6 +93,21 @@ export async function installSseTransport<T>(
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
`data: ${JSON.stringify(payload)}\n\n`,
].join("")
const currentEvent = (input: unknown) => {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location:
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
const acknowledge = (
connection: Connection,
bytes: number,
@@ -140,15 +155,13 @@ export async function installSseTransport<T>(
output.forEach((chunk) => connection.controller.enqueue(chunk))
return acknowledge(connection, input.bytes.length, output.length)
}
const encoded = input.deliveries.map((delivery) => ({
delivery,
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
}))
const encoded = input.deliveries.map((delivery) => {
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
})
encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) {
const bytes = encoder.encode(
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
)
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
}
@@ -161,7 +174,10 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
if (
url.origin !== server ||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
)
return originalFetch(request)
const id = ++nextConnectionID
@@ -177,6 +193,10 @@ export async function installSseTransport<T>(
record.controller = controller
connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
if (url.pathname === "/api/event")
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
request.signal.addEventListener(
"abort",
() => {
+1
View File
@@ -53,6 +53,7 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+37 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => {
@@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
})
})
describe("adaptServerEvent", () => {
test("preserves V2 events while adapting permission requests for existing consumers", () => {
const current = {
id: "evt_1",
created: 1,
type: "permission.v2.asked",
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
current,
})
})
})
describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({
directory: "/repo",
@@ -34,6 +52,24 @@ describe("coalesceServerEvents", () => {
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
})
test("merges adjacent current text deltas", () => {
const current = (id: string, value: string) =>
adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "hello ") },
{ directory: "/repo", payload: current("evt_2", "world") },
])
expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
+143 -51
View File
@@ -1,21 +1,60 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event }
export type ServerEvent = Event & { current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
>
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.v2.asked") {
return {
id: event.id,
type: "permission.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
permission: event.data.action,
patterns: event.data.resources,
always: event.data.save ?? [],
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
: undefined,
},
current: event,
} as ServerEvent
}
if (event.type === "permission.v2.replied")
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.asked")
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.replied")
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.rejected")
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
@@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => {
const current = currentDelta(event.payload.current)
if (current) {
const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current)
if (
previous &&
prior &&
previous.directory === event.directory &&
currentDeltaKey(prior) === currentDeltaKey(current)
) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
const data =
current.type === "session.compaction.delta"
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment }
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
return
}
output.push(event)
return
}
if (event.payload.type !== "message.part.delta") {
output.push(event)
return
@@ -71,12 +138,52 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
return output
}
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if (
event?.type === "session.text.delta" ||
event?.type === "session.reasoning.delta" ||
event?.type === "session.tool.input.delta" ||
event?.type === "session.compaction.delta"
)
return event
}
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
function currentDeltaFragment(event: CurrentDelta) {
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
}
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
start()
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
protocol: Promise<ServerProtocol>
url: string
client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
currentApi: ServerApi
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined
}
createClient: (
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
) => ReturnType<typeof createSdkForServer>
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const abort = new AbortController()
@@ -91,13 +198,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const emitter = createGlobalEmitter<{
[key: string]: Event
[key: string]: ServerEvent
}>()
type Queued = QueuedServerEvent
@@ -142,21 +251,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let run: Promise<void> | undefined
let started = false
let generation = 0
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
@@ -168,35 +262,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (isStreamClosed(error, attempt?.signal)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
const kind = await protocol
const events =
kind === "v1"
? (await eventSdk.global.event({ signal: attempt.signal })).stream
: eventApi.event.subscribe({ signal: attempt.signal })
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
for await (const event of events) {
streamErrorLogged = false
if (event.payload.type !== "sync") {
const directory = event.directory ?? "global"
const payload = event.payload as Event
if (enqueueServerEvent(queue, { directory, payload })) schedule()
}
const legacy = "payload" in event
if (legacy && event.payload.type === "sync") continue
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
@@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted || !started || generation !== active) return
@@ -233,18 +315,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
started = false
generation++
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
@@ -258,12 +333,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
fetch: platform.fetch,
throwOnError: true,
})
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
const legacy = (directory?: string) =>
createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
return {
server,
scope,
protocol,
url: server.http.url,
client: sdk,
api,
currentApi,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
@@ -279,7 +366,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
}
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
@@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
@@ -329,6 +415,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
scope: serverSDK.scope,
directory,
client,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter,
get url() {
return serverSDK.url
+128 -25
View File
@@ -17,6 +17,11 @@ import {
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore, produce } from "solid-js/store"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
@@ -1002,7 +1007,7 @@ function HomeServerRow(props: {
)
}
function HomeProjectList(props: {
type HomeProjectListProps = {
server: ServerConnection.Any
projects: LocalProject[]
selected: HomeProjectSelection
@@ -1013,29 +1018,82 @@ function HomeProjectList(props: {
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
language: ReturnType<typeof useLanguage>
}) {
}
function HomeProjectList(props: HomeProjectListProps) {
const global = useGlobal()
let listRef!: HTMLDivElement
const projects = () => global.ensureServerCtx(props.server).projects
return (
<div class="flex min-w-0 flex-col gap-1">
<For each={props.projects}>
{(project) => (
<HomeProjectRow
project={project}
server={props.server}
selected={
props.selected.server === ServerConnection.key(props.server) &&
props.selected.directory === project.worktree
}
unseenCount={props.unseenCount(props.server, project)}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</For>
</div>
<DragDropProvider
sensors={(defaults) => [
...defaults.filter((sensor) => sensor !== PointerSensor),
PointerSensor.configure({
activationConstraints: (event) =>
event.pointerType === "touch"
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
}),
]}
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
if (props.selected.server !== ServerConnection.key(props.server))
props.selectProject(props.server, source.id.toString())
}}
>
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
{/* Keyed on worktree strings: the enriched project objects are
recreated on every store or sync update, so iterating them directly
remounts all rows — killing any in-flight drag activation (the
row's sortable unregisters on unmount) and discarding animations.
String keys keep row elements alive and move them on reorder. */}
<For each={props.projects.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
</For>
</div>
</DragDropProvider>
)
}
function HomeProjectSlot(
props: HomeProjectListProps & {
worktree: string
index: () => number
},
) {
const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
return (
<Show when={project()}>
{(item) => (
<HomeProjectRow
project={item()}
server={props.server}
index={props.index}
serverSelected={props.selected.server === ServerConnection.key(props.server)}
selected={
props.selected.server === ServerConnection.key(props.server) && props.selected.directory === props.worktree
}
unseenCount={props.unseenCount(props.server, item())}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</Show>
)
}
@@ -1115,6 +1173,8 @@ function HomeRecentlyClosedRow(props: {
function HomeProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean
unseenCount: number
selectProject: (server: ServerConnection.Any, directory: string) => void
@@ -1128,6 +1188,15 @@ function HomeProjectRow(props: {
const platform = usePlatform()
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const [state, setState] = createStore({ menuOpen: false })
const sortable = useSortable({
get id() {
return props.project.worktree
},
get index() {
return props.index()
},
})
let pointerDownSelected: boolean | undefined
const canRevealInFileManager = () =>
platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
const fileManagerActionLabel = () =>
@@ -1144,15 +1213,49 @@ function HomeProjectRow(props: {
)
}
return (
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
<div
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
>
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
sortable.isDragSource(),
}}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
disabled={serverUnreachable()}
onClick={() => props.selectProject(props.server, props.project.worktree)}
onPointerDown={(event) => {
// Same-server mouse selection happens on pointerdown (like tabs),
// but only ever selects; selectProject toggles, and deselecting here
// would fire on every drag before the threshold is met. Cross-server
// selection waits for click so reordering a remote server's projects
// does not focus that server and load its session index. Touch is
// excluded so flick-scrolling the list cannot select rows.
pointerDownSelected = undefined
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
if (!props.selected) props.selectProject(props.server, props.project.worktree)
}}
onClick={(event) => {
// The drag sensor calls preventDefault on post-drag clicks; never
// toggle selection as part of a reorder.
if (event.defaultPrevented) return
// Keyboard activation and touch taps keep the original toggle.
if (event.detail === 0 || pointerDownSelected === undefined) {
props.selectProject(props.server, props.project.worktree)
return
}
// Mouse: pointerdown already selected unselected rows; a plain click
// on an already-selected row toggles it off.
if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
pointerDownSelected = undefined
}}
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
@@ -13,7 +13,6 @@ export function useSessionTabAvatarState(
const global = useGlobal()
const notification = useNotification()
const permission = usePermission()
const permissionState = createMemo(() => permission.ensureServerState(server()))
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const sync = createMemo(() => {
const conn = connection()
@@ -22,9 +21,10 @@ export function useSessionTabAvatarState(
const hasPermissions = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
const permissionState = permission.ensureServerState(server())
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
return !permissionState().autoResponds(item, directory())
return !permissionState.autoResponds(item, directory())
})
})
const hasQuestions = createMemo(() => {
@@ -34,9 +34,11 @@ export function useSessionTabAvatarState(
return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const unread = createMemo(
() => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0,
)
const notificationState = createMemo(() => {
if (!connection()) return
return notification.ensureServerState(server())
})
const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0)
const loading = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test"
import { createApiForServer, createSdkForServer } from "./server"
import { createCompatibleApi } from "./server-compat"
function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
const requests: Request[] = []
const fetcher = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "PATCH") {
return Response.json({
id: "ses_1",
slug: "ses_1",
projectID: "project",
directory: "/repo",
title: "Session",
version: "1",
time: { created: 1, updated: 1 },
})
}
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
return new Response(undefined, { status: 204 })
if (request.method === "POST" && request.url.endsWith("/prompt")) {
return Response.json({
admittedSeq: 1,
id: "msg_1",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
data: { text: "hello" },
delivery: "steer",
})
}
if (request.method === "GET") return Response.json([])
return new Response(undefined, { status: 204 })
},
{ preconnect: globalThis.fetch.preconnect },
)
const server = { url: "http://localhost:4096" }
const api = createCompatibleApi({
protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
current: createApiForServer({ server, fetch: fetcher }),
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
directory: "/repo",
})
return { api, requests }
}
describe("createCompatibleApi", () => {
test("routes V1 archive through the legacy session update", async () => {
const { api, requests } = setup("v1")
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
const url = new URL(requests[0]!.url)
expect(url.pathname).toBe("/session/ses_1")
expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
expect(requests[0]!.method).toBe("PATCH")
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
})
test("converts current prompts to the V1 prompt contract", async () => {
const { api, requests } = setup("v1")
await api.session.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
agent: "build",
model: { providerID: "provider", modelID: "model" },
})
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
expect(await requests[0]!.json()).toMatchObject({
messageID: "msg_1",
agent: "build",
model: { providerID: "provider", modelID: "model" },
parts: [{ type: "text", text: "hello" }],
})
})
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
})
test("resolves protocol detection once across implementation methods", async () => {
let detections = 0
const resolved = Promise.resolve<"v1" | "v2">("v2")
const protocol = new Proxy(resolved, {
get(target, property) {
if (property !== "then") return Reflect.get(target, property, target)
detections++
return target.then.bind(target)
},
})
const { api } = setup(protocol)
await api.session.archive({ sessionID: "ses_1" })
await api.session.list()
expect(detections).toBe(1)
})
test("uses the global V1 session search endpoint", async () => {
const { api, requests } = setup("v1")
await api.session.list({ parentID: null, search: "session", limit: 50 })
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
})
})
+495
View File
@@ -0,0 +1,495 @@
import type { ServerApi } from "./server"
import type { ServerProtocol } from "./server-protocol"
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import type {
Project,
ProjectCurrent,
SessionApi,
SessionCommandInput,
SessionCommandOutput,
SessionCompactInput,
SessionCompactOutput,
SessionInfo,
SessionPromptInput,
SessionPromptOutput,
SessionShellInput,
SessionShellOutput,
} from "@opencode-ai/client/promise"
type LegacyClient = OpencodeClient
type LegacyFor = (directory?: string) => LegacyClient
type CompatibleSessionApi = Omit<
SessionApi,
"prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
> & {
prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
type LegacyPrompt = {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
}
type LegacyLocation = { directory?: string }
type CompatibleInput = {
protocol: Promise<ServerProtocol>
current: ServerApi
legacy: LegacyFor
directory?: string
}
function mime(uri: string) {
const match = /^data:([^;,]+)/.exec(uri)
return match?.[1] ?? "application/octet-stream"
}
function sessionInfo(session: Session): SessionInfo {
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID,
agent: session.agent,
model: session.model && {
id: session.model.id,
providerID: session.model.providerID,
variant: session.model.variant,
},
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: session.time,
title: session.title,
location: { directory: session.directory, workspaceID: session.workspaceID },
subpath: session.path,
revert: session.revert && {
messageID: session.revert.messageID,
partID: session.revert.partID,
snapshot: session.revert.snapshot,
},
}
}
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
const v1 = createV1Api(input)
return lazyApi(
input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
input.current,
)
}
function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
const cache = new Map<PropertyKey, unknown>()
return new Proxy(shape, {
get(target, property, receiver) {
const sample = Reflect.get(target, property, receiver)
if (typeof sample === "function") {
return (...args: unknown[]) =>
implementation.then((value) => {
const method = Reflect.get(value, property)
if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
return Reflect.apply(method, value, args)
})
}
if (sample === null || typeof sample !== "object") return sample
if (cache.has(property)) return cache.get(property)
const nested = lazyApi(
implementation.then((value) => {
const result = Reflect.get(value, property)
if (result === null || typeof result !== "object") {
throw new Error(`API namespace unavailable: ${String(property)}`)
}
return result
}),
sample,
)
cache.set(property, nested)
return nested
},
})
}
function createV1Api(input: CompatibleInput): CompatibleApi {
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
const located = <T>(data: T, value?: { directory?: string }) => ({
location: {
directory: directory(value) ?? "",
project: { id: "", directory: directory(value) ?? "" },
},
data,
})
return {
...input.current,
session: {
...input.current.session,
async list(
value?: Parameters<ServerApi["session"]["list"]>[0],
options?: Parameters<ServerApi["session"]["list"]>[1],
) {
if (!value?.directory && value?.search !== undefined) {
const result = await legacy().experimental.session.list(
{
roots: value.parentID === null ? true : undefined,
search: value.search,
limit: value.limit,
},
options,
)
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
}
const result = await legacy({ directory: value?.directory }).session.list({
directory: value?.directory,
roots: value?.parentID === null ? true : undefined,
search: value?.search,
limit: value?.limit,
})
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
},
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
const result = await legacy(value?.location ?? undefined).session.create({
directory: directory(value?.location ?? undefined),
})
if (!result.data) throw new Error("Failed to create session")
return sessionInfo(result.data)
},
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
const result = await legacy().session.get(value)
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
return sessionInfo(result.data)
},
async active() {
const result = await legacy().session.status()
return Object.fromEntries(
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
),
)
},
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
},
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
const result = await legacy().session.fork(value)
if (!result.data) throw new Error("Failed to fork session")
return sessionInfo(result.data)
},
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
await legacy().session.abort(value)
},
async prompt(value: SessionPromptInput & LegacyPrompt) {
await legacy().session.promptAsync({
sessionID: value.sessionID,
messageID: value.id ?? undefined,
agent: value.agent,
model: value.model,
variant: value.variant,
parts: [
{ type: "text", text: value.text },
...(value.files ?? []).map((file) => ({
type: "file" as const,
mime: mime(file.uri),
url: file.uri,
filename: file.name,
})),
...(value.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
: undefined,
})),
],
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
type: "user",
data: { text: value.text },
delivery: value.delivery ?? "steer",
}
},
async command(value: SessionCommandInput) {
await legacy().session.command({
sessionID: value.sessionID,
messageID: value.id ?? undefined,
command: value.command,
arguments: value.arguments ?? "",
agent: value.agent ?? undefined,
model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
variant: value.model?.variant,
parts: value.files?.map((file) => ({
type: "file" as const,
mime: mime(file.uri),
url: file.uri,
filename: file.name,
})),
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
type: "user",
data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
delivery: value.delivery ?? "steer",
}
},
async shell(value: SessionShellInput & LegacyPrompt) {
await legacy().session.shell({
sessionID: value.sessionID,
command: value.command,
agent: value.agent,
model: value.model,
})
},
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
if (!value.model) throw new Error("A model is required to compact a V1 session")
await legacy().session.summarize({
sessionID: value.sessionID,
providerID: value.model.providerID,
modelID: value.model.modelID,
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
type: "compaction",
}
},
revert: {
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
await legacy().session.revert(value)
return { messageID: value.messageID }
},
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
await legacy().session.unrevert(value)
},
commit: input.current.session.revert.commit,
},
},
project: {
...input.current.project,
async list() {
return ((await legacy().project.list()).data ?? []) as Project[]
},
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
const result = await legacy(value?.location).project.current()
if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
},
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
const result = await legacy({ directory: project?.worktree }).project.update({
...value,
directory: project?.worktree,
})
if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
return result.data as Project
},
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item }))
},
},
path: {
...input.current.path,
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Path unavailable")
return result.data
},
},
vcs: {
...input.current.vcs,
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
const result = await legacy(value?.location).vcs.get()
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
},
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
},
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
const result = await legacy(value.location).vcs.diff({
mode: value.mode === "working" ? "git" : value.mode,
context: value.context,
})
return located(
(result.data ?? []).map((file) => ({
file: file.file,
patch: file.patch ?? "",
additions: file.additions,
deletions: file.deletions,
status: file.status ?? "modified",
})),
value.location,
)
},
},
file: {
...input.current.file,
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
return located(result.data ?? [], value?.location)
},
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
const result = await legacy(value.location).find.files({
query: value.query,
type: value.type,
limit: value.limit,
})
return located(
(result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
value.location,
)
},
},
integration: {
...input.current.integration,
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
(method, index) =>
method.type === "api"
? { type: "key" as const, label: method.label }
: { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
)
return located(
{
id: value.integrationID,
name: value.integrationID,
methods,
connections: [],
},
value.location,
)
},
connect: {
...input.current.integration.connect,
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
await legacy(value.location).auth.set({
providerID: value.integrationID,
auth: { type: "api", key: value.key },
})
},
},
oauth: {
...input.current.integration.oauth,
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
const method = Number(value.methodID)
const result = await legacy(value.location).provider.oauth.authorize(
{ providerID: value.integrationID, method, inputs: value.inputs },
{ throwOnError: true },
)
if (!result.data) throw new Error("Failed to start OAuth authorization")
return located(
{
attemptID: `${value.integrationID}:${method}`,
url: result.data.url,
instructions: result.data.instructions,
mode: result.data.method,
time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
},
value.location,
)
},
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method, code: value.code },
{ throwOnError: true },
)
},
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method },
{ throwOnError: true },
)
return located(
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
value.location,
)
},
},
},
pty: {
...input.current.pty,
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
},
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
const result = await legacy(value?.location).pty.create({
command: value?.command,
args: value?.args ? [...value.args] : undefined,
cwd: value?.cwd,
title: value?.title,
env: value?.env,
})
if (!result.data) throw new Error("Failed to create terminal")
return located(result.data, value?.location)
},
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
return located(result.data, value.location)
},
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
const result = await legacy(value.location).pty.update({
ptyID: value.ptyID,
title: value.title,
size: value.size,
})
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
return located(result.data, value.location)
},
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
return located(result.data, value.location)
},
},
permission: {
...input.current.permission,
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
await legacy().permission.respond({
sessionID: value.sessionID,
permissionID: value.requestID,
response: value.reply,
})
},
},
question: {
...input.current.question,
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
await legacy().question.reply({
requestID: value.requestID,
answers: value.answers.map((answer) => [...answer]),
})
},
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
await legacy().question.reject({ requestID: value.requestID })
},
},
}
}
+34 -4
View File
@@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
describe("checkServerHealth", () => {
test("returns healthy response with version", async () => {
const fetch = (async () =>
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(request?.pathname).toBe("/api/health")
})
test("falls back to the V1 health endpoint", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
paths.push(url.pathname)
if (url.pathname === "/api/health") return new Response(undefined, { status: 404 })
return Response.json({ healthy: true, version: "1.18.4" })
}) as unknown as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
expect(paths).toEqual(["/api/health", "/global/health"])
})
test("falls back when the current health response is malformed", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
paths.push(url.pathname)
if (url.pathname === "/api/health") return Response.json({})
return Response.json({ healthy: true, version: "1.18.4" })
}) as unknown as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
expect(paths).toEqual(["/api/health", "/global/health"])
})
test("allows slow servers thirty seconds by default", async () => {
@@ -142,7 +172,7 @@ describe("checkServerHealth", () => {
retryDelayMs: 1,
})
expect(count).toBe(3)
expect(count).toBe(6)
expect(result).toEqual({ healthy: false })
})
})
+23 -5
View File
@@ -1,6 +1,7 @@
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
import { authTokenFromCredentials, createSdkForServer } from "./server"
import { ClientError, OpenCode } from "@opencode-ai/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
@@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) {
function retryable(error: unknown, signal?: AbortSignal) {
if (signal?.aborted) return false
if (error instanceof ClientError) return error.reason === "Transport"
if (!(error instanceof Error)) return false
if (error.name === "AbortError" || error.name === "TimeoutError") return false
if (error instanceof TypeError) return true
@@ -82,15 +84,31 @@ export async function checkServerHealth(
.then(() => attempt(count + 1))
.catch(() => ({ healthy: false }))
}
const attempt = (count: number): Promise<ServerHealth> =>
createSdkForServer({
server,
const attempt = async (count: number): Promise<ServerHealth> => {
const current = await OpenCode.make({
baseUrl: server.url,
fetch,
signal,
headers: server.password
? {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
: undefined,
})
.health.get({ signal })
.then((x) =>
typeof x.healthy === "boolean"
? { data: { healthy: x.healthy, version: x.version } }
: { error: new Error("Invalid health response") },
)
.catch((error) => ({ error }))
if ("data" in current && current.data) return current.data
if (signal?.aborted) return { healthy: false }
return createSdkForServer({ server, fetch, signal })
.global.health()
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
.catch((error) => next(count, error))
}
return attempt(0).finally(() => timeout?.clear?.())
}
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { detectServerProtocol } from "./server-protocol"
const server = { url: "http://localhost:4096" }
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
const mockFetch = (run: (input: string | URL | Request) => Promise<Response>) =>
Object.assign(run, { preconnect: globalThis.fetch.preconnect })
describe("detectServerProtocol", () => {
test("prefers the legacy health endpoint when both API generations exist", async () => {
const fetcher = mockFetch((input) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" }))
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
})
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
})
test("recognizes V2 health by its process identifier", async () => {
const fetcher = mockFetch((input) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
if (path === "/global/health") return Promise.resolve(json({}, 404))
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
})
expect(await detectServerProtocol(server, fetcher)).toBe("v2")
})
test("recognizes the transitional V1 API health response", async () => {
const fetcher = mockFetch((input) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
if (path === "/global/health") return Promise.resolve(json({}, 404))
return Promise.resolve(json({ healthy: true }))
})
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
})
})
+35
View File
@@ -0,0 +1,35 @@
import type { ServerConnection } from "@/context/server"
import { authTokenFromCredentials } from "./server"
export type ServerProtocol = "v1" | "v2"
function headers(server: ServerConnection.HttpBase) {
if (!server.password) return
return {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
}
async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) {
const response = await fetch(new URL(path, server.url), {
headers: headers(server),
signal: AbortSignal.timeout(5_000),
})
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return
const value: unknown = await response.json()
if (!value || typeof value !== "object") return
return value
}
export async function detectServerProtocol(
server: ServerConnection.HttpBase,
fetch: typeof globalThis.fetch,
): Promise<ServerProtocol> {
const legacy = await probe(server, fetch, "/global/health").catch(() => undefined)
if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1"
const current = await probe(server, fetch, "/api/health").catch(() => undefined)
if (current && "pid" in current && typeof current.pid === "number") return "v2"
if (current && "healthy" in current && current.healthy === true) return "v1"
return "v2"
}
+21
View File
@@ -1,4 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
@@ -39,3 +40,23 @@ export function createSdkForServer({
baseUrl: server.url,
})
}
export function createApiForServer(input: {
server: ServerConnection.HttpBase
fetch?: typeof globalThis.fetch
}): OpenCodeClient {
return OpenCode.make({
baseUrl: input.server.url,
fetch: input.fetch,
headers: input.server.password
? {
Authorization: `Basic ${authTokenFromCredentials({
username: input.server.username,
password: input.server.password,
})}`,
}
: undefined,
})
}
export type ServerApi = OpenCodeClient
Binary file not shown.
+1 -2
View File
@@ -11,7 +11,6 @@
"bin"
],
"exports": {
"./daemon": "./src/daemon.ts",
"./run": "./src/run/index.ts",
"./server-process": "./src/server-process.ts"
},
@@ -23,7 +22,7 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@agentclientprotocol/sdk": "1.2.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
+8
View File
@@ -3,6 +3,7 @@ import { $ } from "bun"
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
import { fileURLToPath } from "url"
import { UpdateArtifact } from "../../../script/update-artifact"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
@@ -81,3 +82,10 @@ await publishDistribution({
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
})
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
distribution: "npm",
version: Script.version,
metadata: {},
})
+2 -2
View File
@@ -5,6 +5,7 @@ import {
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type DeleteSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
@@ -13,7 +14,6 @@ import {
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
@@ -28,12 +28,12 @@ export function create(client: OpenCodeClient, connection: AgentSideConnection)
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)),
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)),
prompt: (params: PromptRequest) => run(service.prompt(params)),
cancel: (params: CancelNotification) => run(service.cancel(params)),
} satisfies Agent
+14 -20
View File
@@ -28,7 +28,7 @@ export type TurnControl = {
type ToolState = {
readonly name: string
input: ToolInput
structured: Record<string, unknown>
metadata: Record<string, unknown>
content: ToolContent
}
@@ -38,7 +38,7 @@ export type TurnStart =
| { readonly type: "compaction"; readonly id: string }
function emptyToolState(): ToolState {
return { name: "tool", input: {}, structured: {}, content: [] }
return { name: "tool", input: {}, metadata: {}, content: [] }
}
export async function streamTurn(input: {
@@ -47,7 +47,7 @@ export async function streamTurn(input: {
readonly sessionID: string
readonly cwd: string
readonly start: TurnStart
readonly userMessageID?: string | null
readonly writeTextFile: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
}): Promise<PromptResponse> {
@@ -120,7 +120,7 @@ export async function streamTurn(input: {
}
if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
@@ -151,15 +151,13 @@ export async function streamTurn(input: {
if (event.type === "session.tool.progress") {
const current = tools.get(event.data.callID)
if (!current) continue
current.structured = event.data.structured
current.content = event.data.content
current.metadata = event.data.metadata
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
content: current.content,
cwd: input.cwd,
}),
})
@@ -170,11 +168,12 @@ export async function streamTurn(input: {
tools.delete(event.data.callID)
await syncEditedFiles({
connection: input.connection,
writeTextFile: input.writeTextFile,
sessionID: input.sessionID,
cwd: input.cwd,
toolName: current.name,
toolInput: current.input,
structured: event.data.structured,
metadata: event.data.metadata ?? {},
}).catch(() => {})
await update({
sessionUpdate: "tool_call_update",
@@ -182,9 +181,8 @@ export async function streamTurn(input: {
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
}),
})
continue
@@ -198,8 +196,8 @@ export async function streamTurn(input: {
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: current.structured,
content: current.content,
metadata: event.data.metadata ?? current.metadata,
content: event.data.content ?? current.content,
error: event.data.error.message,
cwd: input.cwd,
}),
@@ -231,7 +229,7 @@ export async function streamTurn(input: {
if (!started) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID)
return response(undefined, undefined, "interrupted", true, undefined)
}
}
const terminal = await completed
@@ -246,7 +244,6 @@ export async function streamTurn(input: {
terminal,
control.cancelled,
finish,
input.userMessageID,
)
} catch (error) {
streamController.abort()
@@ -342,9 +339,8 @@ async function replayMessage(
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
metadata: part.state.metadata,
content: part.state.content,
result: part.state.result,
}),
},
})
@@ -358,7 +354,6 @@ async function replayMessage(
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.input },
content: part.state.content,
cwd,
}),
},
@@ -373,7 +368,7 @@ async function replayMessage(
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
metadata: part.state.metadata,
content: part.state.content,
error: part.state.error.message,
cwd,
@@ -400,7 +395,6 @@ function response(
terminal: "succeeded" | "failed" | "interrupted",
cancelled: boolean,
finish: SessionMessageAssistant["finish"],
messageID: string | null | undefined,
): PromptResponse {
const error = assistant?.error ?? executionError
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
@@ -423,7 +417,7 @@ function response(
}
: undefined
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} }
return { stopReason, ...(usage ? { usage } : {}), _meta: {} }
}
function resolveStopReason(input: {
+5 -4
View File
@@ -53,15 +53,16 @@ export async function replyPermission(input: {
export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly writeTextFile: boolean
readonly sessionID: string
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly structured: Readonly<Record<string, unknown>>
readonly metadata: Readonly<Record<string, unknown>>
}) {
if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.structured.files)
? input.structured.files.flatMap((file): string[] => {
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.metadata.files)
? input.metadata.files.flatMap((file): string[] => {
if (!file || typeof file !== "object") return []
const path = Reflect.get(file, "file")
return typeof path === "string" ? [path] : []
+20 -15
View File
@@ -16,6 +16,8 @@ import type {
CancelNotification,
CloseSessionRequest,
CloseSessionResponse,
DeleteSessionRequest,
DeleteSessionResponse,
ForkSessionRequest,
ForkSessionResponse,
InitializeRequest,
@@ -33,12 +35,10 @@ import type {
ResumeSessionResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content"
@@ -47,7 +47,8 @@ import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Catalog = {
readonly providers: ConfigOptionProvider[]
@@ -83,12 +84,12 @@ export interface Interface {
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
deleteSession(input: DeleteSessionRequest): Promise<DeleteSessionResponse>
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
setSessionModel(input: SetSessionModelRequest): Promise<SetSessionModelResponse>
prompt(input: PromptRequest): Promise<PromptResponse>
cancel(input: CancelNotification): Promise<void>
}
@@ -98,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
const capabilities = { writeTextFile: false }
const catalog = (cwd: string) => {
const cached = catalogs.get(cwd)
@@ -157,6 +159,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return {
initialize: async (params) => {
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -173,10 +176,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
loadSession: true,
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
},
authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: InstallationVersion },
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
}
},
authenticate: async (params) => {
@@ -216,6 +219,14 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
},
deleteSession: async (params) => {
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
sessions.delete(params.sessionId)
registeredMcp.delete(params.sessionId)
return {}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
@@ -270,13 +281,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
return {}
},
setSessionModel: async (params) => {
const state = await requireSession(params.sessionId)
const selected = requireModel(state.catalog, params.modelId)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
return {}
},
prompt: async (params) => {
const state = await requireSession(params.sessionId)
if (active.has(state.id)) {
@@ -295,7 +299,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessionID: state.id,
cwd: state.cwd,
start: prepared.start,
userMessageID: params.messageId,
writeTextFile: capabilities.writeTextFile,
control,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
}).finally(() => {
@@ -479,6 +483,7 @@ async function registerMcpServers(
function mcpConfig(server: McpServer) {
if ("type" in server) {
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
return {
type: "remote" as const,
url: server.url,
+13 -24
View File
@@ -1,5 +1,6 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
import { readDisplayText } from "@opencode-ai/tui/mini/tool"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
@@ -100,11 +101,12 @@ export function completedToolUpdate(input: {
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly result?: unknown
readonly metadata?: Readonly<Record<string, unknown>>
}): ToolCallUpdate {
const normalized = toolContent(input.content)
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
// Read's model content is a JSON page envelope; show the clean text instead.
const firstText = input.content.find((part) => part.type === "text")
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
const primary =
read === undefined
@@ -128,8 +130,7 @@ export function completedToolUpdate(input: {
status: "completed",
content: [...primary, ...diff, ...images],
rawOutput: {
structured: input.structured,
...(input.result === undefined ? {} : { result: input.result }),
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
},
}
}
@@ -138,8 +139,8 @@ export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly content?: ToolContent
readonly metadata?: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd?: string
}): ToolCallUpdate {
@@ -150,8 +151,11 @@ export function errorToolUpdate(input: {
title: toolTitle(input.toolName, input.input, undefined),
locations: toLocations(input.toolName, input.input, input.cwd),
rawInput: rawInput(input.toolName, input.input, input.cwd),
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: { structured: input.structured, error: input.error },
content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: {
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
error: input.error,
},
}
}
@@ -164,21 +168,6 @@ function toolContent(content: ToolContent): ToolCallContent[] {
})
}
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
if (typeof structured.content === "string") {
if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
}
if (!Array.isArray(structured.entries)) return undefined
return structured.entries
.flatMap((entry): string[] => {
if (typeof entry === "string") return [entry]
if (!entry || typeof entry !== "object") return []
const path = Reflect.get(entry, "path")
return typeof path === "string" ? [path] : []
})
.join("\n")
}
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
return fallback || toolName
+4 -3
View File
@@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
},
commands: [
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
@@ -139,11 +140,11 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.withDefault(false),
),
replay: Flag.boolean("replay").pipe(
Flag.withDescription("Replay session history on resume and after resize"),
Flag.withDefault(true),
Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"),
Flag.optional,
),
replayLimit: Flag.integer("replay-limit").pipe(
Flag.withDescription("Cap visible replay to the newest N messages"),
Flag.withDescription("Limit replay to the newest N messages (default: 200)"),
Flag.optional,
),
model: Flag.string("model").pipe(
@@ -9,6 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -44,6 +45,7 @@ export default Runtime.handler(Commands, (input) =>
const runPromise = Effect.runPromiseWith(context)
const service = server.service
yield* run({
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
server: {
endpoint: server.endpoint,
service: service
@@ -53,7 +55,11 @@ export default Runtime.handler(Commands, (input) =>
}
: undefined,
},
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
args: {
continue: input.continue,
sessionID: Option.getOrUndefined(input.session),
prompt: Option.getOrUndefined(input.prompt),
},
config: {
path: config.path,
get: () => runPromise(config.get()),
+2 -2
View File
@@ -28,8 +28,8 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
model: Option.getOrUndefined(input.model),
agent: Option.getOrUndefined(input.agent),
prompt: Option.getOrUndefined(input.prompt),
replay: input.replay,
replayLimit: Option.getOrUndefined(input.replayLimit),
replay: Option.getOrUndefined(input.replay) ?? resolved.mini?.replay ?? true,
replayLimit: Option.getOrUndefined(input.replayLimit) ?? resolved.mini?.replay_limit,
demo: input.demo,
tuiConfig: resolved,
config: {
+10 -8
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env bun
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/util/observability"
import { Client } from "@opencode-ai/util/client"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
@@ -53,12 +52,12 @@ const Handlers = Runtime.handlers(Commands, {
})
Effect.logInfo("cli starting", {
version: InstallationVersion,
channel: InstallationChannel,
local: InstallationLocal,
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
local: OPENCODE_LOCAL,
args: process.argv.slice(2),
}).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })),
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
@@ -74,7 +73,10 @@ Effect.logInfo("cli starting", {
Observability.layer({
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
}).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))),
client: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
}),
),
Effect.provide(NodeServices.layer),
Effect.scoped,
+1 -58
View File
@@ -2,7 +2,6 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { setTimeout } from "node:timers/promises"
import { waitForCatalogReady } from "./services/catalog"
import { readStdin } from "./util/io"
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
@@ -214,63 +213,7 @@ function parseModel(value?: string) {
}
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
return async (input) => {
if (input.model)
await waitForCatalogReady({
sdk: input.client,
directory: input.location.directory,
workspace: input.location.workspaceID,
model: { providerID: input.model.providerID, modelID: input.model.id },
signal: input.signal,
})
return {
model: input.model,
agent: requestedAgent
? await validateAgent(
input.client,
input.location.directory,
input.location.workspaceID,
requestedAgent,
input.signal,
)
: input.agent,
}
}
}
async function validateAgent(
sdk: OpenCodeClient,
directory: string,
workspace: string | undefined,
name?: string,
signal?: AbortSignal,
) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline && !signal?.aborted) {
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
const agent = agents?.data.find((item) => item.id === name)
if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
if (agent) return name
await setTimeout(25, undefined, { signal }).catch(() => {})
}
if (signal?.aborted) return
if (!agents) {
warning("failed to list agents. Falling back to default agent")
return
}
warning(`agent "${name}" not found. Falling back to default agent`)
}
function warning(message: string) {
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
return async (input) => ({ model: input.model, agent: requestedAgent ?? input.agent })
}
function fail(message: string): never {
@@ -10,6 +10,7 @@ import {
Reference,
Skill,
} from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
const key = Symbol.for("opencode.plugin.v2.promise")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
@@ -23,4 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.promise")
Provider,
Reference,
Skill,
Tool,
}
+51 -28
View File
@@ -10,7 +10,7 @@ import type {
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { readFile } from "node:fs/promises"
import { toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { nonEmptyToolContent, toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { UI } from "./ui"
type Model = {
@@ -55,7 +55,7 @@ type ToolState = StartedPart & {
raw?: string
provider?: unknown
providerState?: SessionMessageAssistantTool["providerState"]
structured: Record<string, JsonValue>
metadata: Record<string, JsonValue>
content: LLMToolContent[]
}
@@ -211,10 +211,17 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (!promoted && event.type === "session.execution.failed") {
prePromotionError = event.data.error
if (finalizing) return
continue
}
if (
!promoted &&
finalizing &&
(event.type === "session.execution.succeeded" || event.type === "session.execution.interrupted")
)
return
if (!promoted) continue
if (finalizing) continue
if (finalizing && !event.type.startsWith("session.execution.")) continue
if (event.type === "session.step.started") {
const part = {
@@ -299,7 +306,7 @@ export async function runNonInteractivePrompt(input: Input) {
assistantMessageID: event.data.assistantMessageID,
tool: event.data.name,
input: {},
structured: {},
metadata: {},
content: [],
})
continue
@@ -327,7 +334,7 @@ export async function runNonInteractivePrompt(input: Input) {
raw: current?.raw,
provider: { executed: event.data.executed, state: event.data.state },
providerState: event.data.state,
structured: {},
metadata: {},
content: [],
})
continue
@@ -335,8 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) {
current.structured = event.data.structured
current.content = event.data.content
current.metadata = event.data.metadata
}
continue
}
@@ -353,9 +359,8 @@ export async function runNonInteractivePrompt(input: Input) {
state: {
status: "completed",
input: current.input,
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
@@ -372,9 +377,8 @@ export async function runNonInteractivePrompt(input: Input) {
output: toolOutputText(current.tool, event.data.content),
title: current.tool,
metadata: {
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw,
@@ -391,6 +395,8 @@ export async function runNonInteractivePrompt(input: Input) {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message
const metadata = event.data.metadata ?? current.metadata
const content = event.data.content ?? nonEmptyToolContent(current.content)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
@@ -401,10 +407,9 @@ export async function runNonInteractivePrompt(input: Input) {
state: {
status: "error",
input: current.input,
structured: current.structured,
content: current.content,
metadata,
content,
error: event.data.error,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
@@ -420,7 +425,6 @@ export async function runNonInteractivePrompt(input: Input) {
input: current.input,
error,
metadata: {
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw,
@@ -432,15 +436,14 @@ export async function runNonInteractivePrompt(input: Input) {
renderedTools.add(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, current.content).trim())
if (content && toolOutputText(current.tool, content).trim())
await input.renderTool({
...tool,
state: {
status: "completed",
input: current.input,
structured: current.structured,
content: current.content,
result: event.data.result,
metadata,
content,
},
})
await input.renderToolError(tool)
@@ -588,14 +591,14 @@ export async function runNonInteractivePrompt(input: Input) {
input: item.state.input,
output: toolOutputText(item.name, item.state.content),
title: item.name,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
metadata: { metadata: item.state.metadata, content: item.state.content },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
}
: {
status: "error",
input: item.state.input,
error: item.state.error.message,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
metadata: { metadata: item.state.metadata, content: item.state.content },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
},
}
@@ -605,8 +608,16 @@ export async function runNonInteractivePrompt(input: Input) {
await input.renderTool(item)
continue
}
if (toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
if (item.state.content && toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({
...item,
state: {
status: "completed",
input: item.state.input,
metadata: item.state.metadata,
content: item.state.content,
},
})
}
await input.renderToolError(item)
UI.error(item.state.error.message)
@@ -618,7 +629,10 @@ export async function runNonInteractivePrompt(input: Input) {
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
}
}
return projected.found
return {
found: projected.found,
responded: projected.messages.some((message) => message.type === "assistant"),
}
}
const interrupt = () => {
@@ -708,9 +722,18 @@ export async function runNonInteractivePrompt(input: Input) {
const waiting = input.client.session.wait({ sessionID: input.sessionID })
await Promise.race([waiting, completed.then(() => waiting)])
finalizing = true
controller.abort()
const found = await reconcile()
if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
const projected = await reconcile()
if (
!projected.responded &&
!interrupted &&
!permissionRejected &&
!formCancelled &&
!emittedError &&
!prePromotionError
) {
await completed
}
if (!projected.found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" }
emittedError = true
process.exitCode = 1
@@ -771,7 +794,7 @@ function fallbackTool(event: {
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},
structured: {},
metadata: {},
content: [],
}
}
+6 -45
View File
@@ -5,7 +5,6 @@ import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "../services/catalog"
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive"
@@ -95,9 +94,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
prepare: async (next) => {
const selected =
next.model ??
(await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data))
(options.variant
? await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data)
: undefined)
const model = selected
? {
providerID: selected.providerID,
@@ -107,25 +108,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
: undefined
if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
if (model) {
await waitForCatalogReady({
sdk: client,
directory: next.location.directory,
workspace: next.location.workspaceID,
model: { providerID: model.providerID, modelID: model.id },
})
const available = await client.model.list({
location: { directory: next.location.directory, workspace: next.location.workspaceID },
})
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id))
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
}
return {
model,
agent: input.agent
? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent)
: next.agent,
}
return { model, agent: next.agent }
},
}).catch((error) => {
if (!(error instanceof RunTargetError)) throw error
@@ -190,28 +173,6 @@ export function parseRunModel(value?: string) {
}
}
async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) {
if (!name) return
const agents = await client.agent
.list({ location: { directory, workspace } })
.then((result) => result.data)
.catch(() => undefined)
if (!agents) {
warning("failed to list agents. Falling back to default agent")
return
}
const agent = agents.find((item) => item.id === name)
if (!agent) {
warning(`agent "${name}" not found. Falling back to default agent`)
return
}
if (agent.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
return name
}
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
const file = path.resolve(directory, input)
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
+14 -4
View File
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
@@ -69,13 +69,23 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const instanceID = randomUUID()
const server = yield* start(
{
client: process.env.OPENCODE_CLIENT ?? "cli",
app: {
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
hostname,
port,
password,
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path: process.env.OPENCODE_DB,
path:
process.env.OPENCODE_DB ??
(["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
@@ -167,7 +177,7 @@ const register = Effect.fnUntraced(function* (
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const info = {
id,
version: InstallationVersion,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
-43
View File
@@ -1,43 +0,0 @@
import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise"
// Location plugins initialize asynchronously, so explicit model selection must
// wait for that exact model before prompt admission. The execution path owns
// the authoritative error if readiness times out.
export async function waitForCatalogReady(input: {
sdk: OpenCodeClient
directory: string
workspace?: string
model: { providerID: string; modelID: string }
timeoutMs?: number
signal?: AbortSignal
}) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && !input.signal?.aborted) {
const models = await input.sdk.model
.list(
{ location: { directory: input.directory, workspace: input.workspace } },
{ signal: input.signal },
)
.then((result) => result.data)
.catch((error) => {
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
await wait(25, input.signal)
}
}
function wait(delay: number, signal?: AbortSignal) {
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
if (signal.aborted) return Promise.resolve()
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal?.removeEventListener("abort", done)
resolve()
}
})
}
@@ -1,6 +1,6 @@
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { OPENCODE_VERSION } from "../version"
import { Effect, Redacted } from "effect"
import { Env } from "../env"
import { ServiceConfig } from "./service-config"
@@ -32,9 +32,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== InstallationVersion)
if (health.version !== OPENCODE_VERSION)
process.stderr.write(
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`,
)
return { endpoint } satisfies Resolved
}
+10 -10
View File
@@ -1,5 +1,5 @@
import { Global } from "@opencode-ai/util/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../version"
import { Hash } from "@opencode-ai/util/hash"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect"
@@ -24,26 +24,26 @@ type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = InstallationChannel) {
export function filename(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return "service.json"
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
export function defaultPort(channel = InstallationChannel) {
export function defaultPort(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
}
export function legacyFilename(channel = InstallationChannel) {
export function legacyFilename(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "local") return
return `service-${Hash.fast(channel)}.json`
}
export function versionBelongsToChannel(
version: string | undefined,
channel = InstallationChannel,
installedVersion = InstallationVersion,
channel = OPENCODE_CHANNEL,
installedVersion = OPENCODE_VERSION,
) {
if (version === undefined) return false
if (version === installedVersion) return true
@@ -55,8 +55,8 @@ export function versionBelongsToChannel(
export const migrateRegistration = Effect.fnUntraced(function* (
legacy: string,
file: string,
channel = InstallationChannel,
installedVersion = InstallationVersion,
channel = OPENCODE_CHANNEL,
installedVersion = OPENCODE_VERSION,
) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
@@ -92,7 +92,7 @@ const paths = Effect.gen(function* () {
legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
legacyRegistrationFiles: [
...(legacy ? [path.join(global.state, legacy)] : []),
...(name !== "service.json" && InstallationChannel !== "local" ? [path.join(global.state, "service.json")] : []),
...(name !== "service.json" && OPENCODE_CHANNEL !== "local" ? [path.join(global.state, "service.json")] : []),
],
configFile: path.join(global.config, name),
}
@@ -103,7 +103,7 @@ export const options = Effect.fnUntraced(function* () {
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return {
file,
version: InstallationVersion,
version: OPENCODE_VERSION,
command: [...selfCommand(), "serve", "--service"],
}
})
@@ -3,7 +3,7 @@
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { OPENCODE_VERSION } from "../version"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { go } from "@opencode-ai/tui/logo"
@@ -356,12 +356,12 @@ function UpdateFooter(props: {
] as const)
: []),
["to", colors.muted],
[InstallationVersion, colors.accent],
[OPENCODE_VERSION, colors.accent],
)
const completedHeader = phrase(
["OpenCode", colors.muted, true],
["updated to", colors.muted],
[InstallationVersion, colors.accent],
[OPENCODE_VERSION, colors.accent],
)
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
const outcomeStatus = () =>
+11 -15
View File
@@ -1,10 +1,6 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import {
InstallationChannel,
InstallationLocal,
InstallationVersion,
} from "@opencode-ai/util/installation/version"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
@@ -52,7 +48,7 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -99,8 +95,8 @@ export const layer = Layer.effect(
const response = yield* Effect.tryPromise({
try: () =>
fetch(
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`,
{ headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) },
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
@@ -138,13 +134,13 @@ export const layer = Layer.effect(
const check = Effect.fn("cli.updater.check")(function* () {
if (
InstallationLocal ||
OPENCODE_LOCAL ||
["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
)
return yield* Effect.logInfo("update check skipped", {
reason: InstallationLocal ? "local-install" : "disabled",
version: InstallationVersion,
channel: InstallationChannel,
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
})
const policy = yield* readPolicy()
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
@@ -152,15 +148,15 @@ export const layer = Layer.effect(
return yield* Effect.gen(function* () {
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: InstallationVersion,
current: OPENCODE_VERSION,
latest: version,
})
const next = action(InstallationVersion, version, policy)
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
})
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
+1 -1
View File
@@ -74,7 +74,7 @@ export async function resolveSessionTarget(input: {
session,
location,
model: prepared.model,
agent: prepared.agent,
agent: prepared.agent ?? session.agent,
resume: selected !== undefined,
}
}
+8
View File
@@ -0,0 +1,8 @@
declare const OPENCODE_VERSION: string
declare const OPENCODE_CHANNEL: string
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
export const OPENCODE_LOCAL = channel === "local"
+18 -21
View File
@@ -214,12 +214,11 @@ describe("acp event behavior", () => {
}),
)
send(
durableEvent("session.tool.progress", {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { phase: 1 },
content: [{ type: "text", text: "working" }],
metadata: { phase: 1 },
}),
)
send(
@@ -227,9 +226,8 @@ describe("acp event behavior", () => {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { exit: 0 },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
result: { code: 0 },
executed: true,
}),
)
@@ -251,12 +249,11 @@ describe("acp event behavior", () => {
}),
)
send(
durableEvent("session.tool.progress", {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
structured: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
metadata: { bytes: 0 },
}),
)
send(
@@ -265,6 +262,8 @@ describe("acp event behavior", () => {
assistantMessageID: "msg_tools",
callID: "call_fail",
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
executed: true,
}),
)
@@ -311,12 +310,10 @@ describe("acp event behavior", () => {
locations: [{ path: resolve("/workspace", "sub") }],
rawInput: { command: "printf done", workdir: "sub" },
})
expect(updates[2]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "working" } }],
})
expect(updates[2]?.update).not.toHaveProperty("content")
expect(updates[3]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[7]?.update).toMatchObject({
kind: "read",
@@ -325,7 +322,7 @@ describe("acp event behavior", () => {
{ type: "content", content: { type: "text", text: "opening" } },
{ type: "content", content: { type: "text", text: "not found" } },
],
rawOutput: { structured: { bytes: 0 }, error: "not found" },
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
})
expect(response.stopReason).toBe("end_turn")
} finally {
@@ -377,7 +374,7 @@ describe("acp event behavior", () => {
{ type: "content", content: { type: "text", text: "done" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[8]?.update).toMatchObject({
toolCallId: "call_running",
@@ -437,6 +434,7 @@ describe("acp event behavior", () => {
sessionID: "ses_cancel",
cwd: "/workspace",
start: { type: "input", id: "input_cancel" },
writeTextFile: false,
control,
submit: async (signal) => {
await fixture.client.session.prompt(
@@ -479,6 +477,7 @@ describe("acp event behavior", () => {
sessionID: "ses_cancel_admission",
cwd: "/workspace",
start: { type: "input", id: "input_cancel_admission" },
writeTextFile: false,
control,
submit: (signal) =>
fixture.client.session.prompt(
@@ -564,7 +563,7 @@ function turn(input: {
sessionID: input.sessionID,
cwd: "/workspace",
start: { type: "input", id: input.inputID },
userMessageID: `client_${input.inputID}`,
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
@@ -614,12 +613,11 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
metadata: { exit: 0 },
content: [
{ type: "text", text: "done" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
],
result: { code: 0 },
},
},
{
@@ -630,8 +628,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "running",
input: { command: "pwd" },
structured: {},
content: [{ type: "text", text: "/workspace" }],
metadata: {},
},
},
{
@@ -642,7 +639,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "error",
input: { filePath: "/workspace/missing.ts" },
structured: { bytes: 0 },
metadata: { bytes: 0 },
content: [{ type: "text", text: "partial" }],
error: { type: "tool.error", message: "failed hard" },
},
@@ -675,7 +672,7 @@ function replayToolMessage(id: string) {
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
},
},
+2 -3
View File
@@ -85,7 +85,6 @@ test("acp prompt resolves after ordered turn updates", async () => {
try {
const id = "msg_prompt"
const userMessageID = "client-message"
const response = await streamTurn({
client,
connection: {
@@ -97,7 +96,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id },
userMessageID,
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
})
@@ -112,7 +111,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
},
},
])
expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } })
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
} finally {
events?.close()
await server.stop(true)
@@ -14,6 +14,7 @@ describe("acp initialize/auth subprocess", () => {
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
@@ -1,5 +1,6 @@
import type {
CloseSessionResponse,
DeleteSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
@@ -60,6 +61,20 @@ describe("acp lifecycle subprocess", () => {
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}, 60_000)
test("delete capability and delete request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
const initialized = await initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
const session = await newSession(acp, fixture.home)
expect(
expectOk(await acp.request<DeleteSessionResponse>("session/delete", { sessionId: session.sessionId })),
).toEqual({})
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
}, 60_000)
test("resume capability advertisement", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
@@ -4,6 +4,7 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { streamTurn } from "../../src/acp/event"
import { syncEditedFiles } from "../../src/acp/permission"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
@@ -12,6 +13,27 @@ type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission
type Fixture = ReturnType<typeof createSseFixture>
describe("acp permission behavior", () => {
test("does not sync edits when writeTextFile was not advertised", async () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
await syncEditedFiles({
connection: {
writeTextFile: async (input) => {
writes.push(input)
return {}
},
},
writeTextFile: false,
sessionID: "ses_no_write",
cwd: "/workspace",
toolName: "edit",
toolInput: { filePath: "/workspace/file.ts" },
metadata: {},
})
expect(writes).toEqual([])
})
test("forwards allow-once and allow-always selections to the generated client", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
@@ -171,7 +193,7 @@ describe("acp permission behavior", () => {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
structured: { files: [{ file: "file.ts" }], replacements: 1 },
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }],
executed: true,
}),
@@ -264,7 +286,7 @@ describe("acp permission behavior", () => {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }],
executed: true,
}),
@@ -465,6 +487,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
sessionID,
cwd,
start: { type: "input", id: inputID },
writeTextFile: true,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
})
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
describe("acp service directory behavior", () => {
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
@@ -134,7 +134,6 @@ describe("acp service directory behavior", () => {
configId: "mode",
value: "plan",
})
await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" })
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
@@ -148,7 +147,6 @@ describe("acp service directory behavior", () => {
).toEqual([
{ model: { providerID: "test", id: secondModel.id } },
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
{ model: { providerID: "test", id: testModel.id, variant: "high" } },
])
expect(
fixture.requests
@@ -225,6 +225,33 @@ describe("acp service lifecycle", () => {
"/api/session/missing/interrupt",
])
})
test("deletes sessions from backing and local storage", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_delete") })
}
if (request.method === "DELETE" && request.path === "/api/session/ses_delete") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({})
expect(fixture.requests).toContainEqual({
method: "DELETE",
path: "/api/session/ses_delete",
query: {},
body: undefined,
})
const missing = await fixture.service
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
.catch((error: unknown) => error)
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
})
})
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
@@ -45,17 +45,14 @@ describe("acp service prompt routing and usage", () => {
const commandResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-command",
prompt: [{ type: "text", text: "/review now" }],
})
const skillResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-skill",
prompt: [{ type: "text", text: "/verify" }],
})
const compactResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-compact",
prompt: [{ type: "text", text: "/compact" }],
})
@@ -154,13 +151,11 @@ describe("acp service prompt routing and usage", () => {
const response = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-message",
prompt: [{ type: "text", text: "hello" }],
})
expect(response).toEqual({
stopReason: "end_turn",
userMessageId: "client-message",
usage: {
inputTokens: 100,
outputTokens: 40,
+33 -30
View File
@@ -63,7 +63,7 @@ describe("acp tools", () => {
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
],
structured: {},
metadata: {},
}).content,
).toEqual([
{
@@ -93,7 +93,7 @@ describe("acp tools", () => {
content: "created",
},
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
structured: {},
metadata: {},
}).content,
).toEqual([
{
@@ -103,20 +103,22 @@ describe("acp tools", () => {
])
})
test("uses clean structured read content instead of model-facing formatting", () => {
test("unwraps read's JSON page envelope instead of showing model-facing formatting", () => {
expect(
completedToolUpdate({
toolCallId: "tool-read",
toolName: "read",
input: { path: "/tmp/file.ts" },
content: [{ type: "text", text: "<content>1: first\n2: second</content>" }],
structured: {
type: "text-page",
content: "first\nsecond",
mime: "text/plain",
offset: 1,
truncated: false,
},
content: [
{
type: "text",
text: JSON.stringify(
{ type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false },
null,
2,
),
},
],
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
@@ -125,13 +127,17 @@ describe("acp tools", () => {
toolCallId: "tool-list",
toolName: "read",
input: { path: "/tmp" },
content: [],
structured: {
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
},
content: [
{
type: "text",
text: JSON.stringify({
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
}),
},
],
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
})
@@ -171,7 +177,7 @@ describe("acp tools", () => {
newString: "after",
},
content: [{ type: "text", text: "Edit applied successfully." }],
structured: { output: "Edit applied successfully." },
metadata: { output: "Edit applied successfully." },
}),
).toEqual({
toolCallId: "tool-1",
@@ -189,7 +195,7 @@ describe("acp tools", () => {
},
],
rawOutput: {
structured: { output: "Edit applied successfully." },
metadata: { output: "Edit applied successfully." },
},
})
})
@@ -209,7 +215,7 @@ describe("acp tools", () => {
})
})
test("builds completed raw output with structured data and optional result", () => {
test("builds completed raw output with optional metadata", () => {
const attachments = [
{
type: "file",
@@ -225,12 +231,10 @@ describe("acp tools", () => {
toolName: "read",
input: {},
content: [],
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
metadata: { output: "done", metadata: { exit: 0 }, attachments },
}).rawOutput,
).toEqual({
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
metadata: { output: "done", metadata: { exit: 0 }, attachments },
})
expect(
@@ -239,9 +243,8 @@ describe("acp tools", () => {
toolName: "read",
input: {},
content: [],
structured: { output: "done" },
}).rawOutput,
).toEqual({ structured: { output: "done" } })
).toEqual({})
})
test("extracts image attachments only from data URLs", () => {
@@ -255,7 +258,7 @@ describe("acp tools", () => {
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
],
structured: {},
metadata: {},
}).content,
).toEqual([
{
@@ -272,7 +275,7 @@ describe("acp tools", () => {
toolName: "read",
input: { filePath: "/tmp/a" },
content: [{ type: "text", text: "partial output" }],
structured: { path: "/tmp/a" },
metadata: { path: "/tmp/a" },
error: "failed",
}),
).toEqual({
@@ -286,7 +289,7 @@ describe("acp tools", () => {
{ type: "content", content: { type: "text", text: "partial output" } },
{ type: "content", content: { type: "text", text: "failed" } },
],
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" },
})
})
})
+2 -2
View File
@@ -131,7 +131,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
})
}),
)
@@ -139,7 +139,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
})
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
} finally {

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