Compare commits

...
Author SHA1 Message Date
Aiden Cline 3dffa01054 chore: merge v2 2026-07-23 17:41:37 -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
Aiden Clineandopencode-agent[bot] 1957e167dc fix(core): return write diffs 2026-07-23 17:11:35 +00:00
Aiden Clineandopencode-agent[bot] a8d2e8fa81 fix(tui): highlight written lines 2026-07-23 16:54:23 +00:00
Aiden Clineandopencode-agent[bot] d0aae842a1 fix(tui): preview written file content 2026-07-23 16:47:10 +00: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
DaxandGitHub 9c38358197 refactor: centralize client identity (#38148) 2026-07-21 14:01:04 -04:00
Simon KleeandGitHub dd6c95fdc7 mini: add quiet transcript settings (#38152) 2026-07-21 19:36:34 +02:00
Aiden ClineandGitHub 6f4b9504e5 feat(codemode): support custom async iterators (#38141) 2026-07-21 12:18:46 -05:00
Aiden ClineandGitHub dbfbb13ccc fix(core): authorize patch move destinations (#38133) 2026-07-21 12:18:23 -05:00
526 changed files with 23383 additions and 8552 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'
+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",
+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(
+49 -35
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,
@@ -101,13 +113,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,7 +126,12 @@ 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,
@@ -145,7 +159,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 }),
])
@@ -283,7 +297,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 +330,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 +357,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 +455,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"])
@@ -535,7 +545,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 +553,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 +562,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
@@ -673,7 +685,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 }),
})
}
+18 -13
View File
@@ -52,6 +52,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 +284,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 +392,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)
@@ -437,21 +441,22 @@ const mapFinishReason = (reason: string): FinishReason => {
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 },
})
}
+27 -12
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,
@@ -441,6 +454,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 +470,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
+61 -36
View File
@@ -25,6 +25,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 +71,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,14 +149,17 @@ 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),
@@ -179,7 +186,7 @@ export interface ParserState {
readonly usage?: Usage
readonly finishReason?: FinishReason
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 +234,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 +266,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 +293,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 +337,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 +380,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 +398,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 {
@@ -446,10 +463,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>) => {
@@ -518,7 +543,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)
@@ -635,12 +660,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,
+73 -35
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"
+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}`)
})
+4 -3
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.
-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,
}),
],
}
}
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
+6 -1
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,
}),
),
],
})
@@ -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({
@@ -634,7 +664,14 @@ 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" })
@@ -756,7 +793,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 +803,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 +819,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(
@@ -269,6 +299,39 @@ describe("Bedrock Converse route", () => {
}),
)
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(
@@ -549,10 +612,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 +628,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 +638,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(
+69 -14
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" } },
})
}),
)
@@ -372,7 +393,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 +422,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 +443,13 @@ describe("Gemini route", () => {
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "tool_0",
name: "lookup",
result: "done",
resultType: "text",
providerMetadata: toolCall?.providerMetadata,
}),
],
}),
)
@@ -424,7 +458,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" },
},
},
],
},
])
@@ -498,7 +547,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,7 +562,13 @@ 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" })
@@ -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,
@@ -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>(
@@ -570,6 +609,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 = [
@@ -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({
@@ -797,6 +870,32 @@ 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({})
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
"length",
"content-filter",
"unknown",
])
}),
)
// 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", () =>
@@ -1526,20 +1625,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 +1694,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")
}),
)
@@ -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).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: "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],
}),
),
)
}),
)
}
})
+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()),
+5 -2
View File
@@ -28,10 +28,13 @@ 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: {
update: (update) => runServicePromise(config.update(update)),
},
}),
)
}),
+7 -5
View File
@@ -6,7 +6,7 @@ import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/util/observability"
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"
@@ -52,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,6 +74,8 @@ Effect.logInfo("cli starting", {
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
client: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
}),
),
Effect.provide(NodeServices.layer),
+3 -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"
@@ -22,6 +21,7 @@ export type MiniCommandInput = {
replayLimit?: number
demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"]
config?: MiniFrontendInput["config"]
}
type Model = MiniFrontendInput["model"]
@@ -119,6 +119,7 @@ export async function runMini(input: MiniCommandInput) {
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
config: input.config,
})
})
if (result.exitCode !== 0) process.exit(result.exitCode)
@@ -212,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" },
})
})
})
+6 -1
View File
@@ -131,11 +131,16 @@ 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", turn_summary: "hide", splash: "hide", mono: true }
})
}),
)
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
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 {
await Bun.$`rm -rf ${directory}`
@@ -16,7 +16,30 @@ export default defineScript({
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
const explicitDirectory = path.join(artifacts, "explicit-model")
yield* Effect.promise(() => Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))))
/** @param {string} directory @param {string | undefined} model */
const mini = (directory, model) => [
"env",
`PWD=${directory}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
`--preload=${preload}`,
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
...(model ? ["--model", model] : []),
]
yield* llm.queue(
Llm.toolCall({
@@ -42,26 +65,7 @@ export default defineScript({
"-y",
"30",
"--",
"env",
`PWD=${path.join(artifacts, "files")}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
`--preload=${preload}`,
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
"--model",
"simulation/gpt-sim-model",
...mini(path.join(artifacts, "files"), undefined),
]),
),
)
@@ -72,7 +76,16 @@ export default defineScript({
if (first.includes("drive mini response complete"))
throw new Error("response rendered before prompt submission")
yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => waitForPane(session, "Default model", 15_000))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Commands"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Switch model"))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Select model"))
yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything..."))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
@@ -134,7 +147,7 @@ export default defineScript({
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt"))
const armed = yield* Effect.promise(() => waitForPane(session, "esc again"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
@@ -144,7 +157,7 @@ export default defineScript({
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
})
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
const status = yield* Effect.promise(() => paneDeadStatus(session))
@@ -153,6 +166,75 @@ export default defineScript({
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
yield* Effect.promise(() =>
tmux([
"respawn-pane",
"-k",
"-t",
session,
"--",
...mini(explicitDirectory, "simulation/gpt-sim-model"),
]),
)
const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
throw new Error("Explicit-model Mini did not exit cleanly")
yield* Effect.promise(async () => {
for (const failure of [
{
args: ["--model", "simulation/definitely-missing"],
capture: "08-unavailable-model.txt",
expected: "Model unavailable: simulation/definitely-missing",
},
{
args: ["--agent", "definitely-missing"],
capture: "09-unavailable-agent.txt",
expected: 'Agent not found: "definitely-missing"',
},
]) {
const child = Bun.spawn(
[
process.execPath,
path.join(root, "packages/cli/src/index.ts"),
"run",
"--server",
registration.url,
...failure.args,
"optimistic selection check",
],
{
cwd: path.join(root, "packages/cli"),
env: {
...process.env,
PWD: path.join(artifacts, "files"),
OPENCODE_PASSWORD: registration.password,
OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
},
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
await Bun.write(path.join(snapshots, failure.capture), stdout + stderr)
if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`)
if (!stderr.includes(failure.expected))
throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`)
}
})
})
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
@@ -188,6 +270,19 @@ function captureVisiblePane(session) {
return tmux(["capture-pane", "-p", "-t", session])
}
/** @param {string} session @param {string} text @param {number} [timeout] */
async function waitForVisiblePane(session, text, timeout = 5_000) {
const deadline = Date.now() + timeout
let last = ""
while (Date.now() < deadline) {
last = await captureVisiblePane(session)
if (last.includes(text)) return last
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
await Bun.sleep(50)
}
throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`)
}
/** @param {string} session */
async function paneAlive(session) {
return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
@@ -18,10 +18,17 @@ describe("CLI frontend import boundaries", () => {
test("exposes only the intentional package entrypoints", async () => {
const run = await import("@opencode-ai/cli/run")
const mini = await import("@opencode-ai/tui/mini")
const tool = await import("@opencode-ai/tui/mini/tool")
const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
expect(Object.keys(tool).sort()).toEqual([
"nonEmptyToolContent",
"readDisplayText",
"toolInlineInfo",
"toolOutputText",
])
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
})
+38 -17
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { OPENCODE_VERSION } from "../src/version"
import path from "node:path"
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
@@ -31,14 +31,14 @@ describe("mini command", () => {
const initial = Bun.serve({
port: 0,
fetch() {
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
},
})
const replacement = Bun.serve({
port: 0,
fetch(request) {
authorization.push(request.headers.get("authorization"))
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
},
})
const controller = new AbortController()
@@ -121,11 +121,12 @@ describe("mini command", () => {
expect(result.stdout).toContain("run Run OpenCode with a message")
})
test("exposes run without legacy attach or command modes", async () => {
test("exposes run without legacy interactive, attach, or command modes", async () => {
const result = await cli(["run", "--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--server string")
expect(result.stdout).not.toContain("--interactive")
expect(result.stdout).not.toContain("--variant")
expect(result.stdout).not.toContain("--attach")
expect(result.stdout).not.toContain("--command")
@@ -138,32 +139,47 @@ describe("mini command", () => {
expect(result.stderr).not.toContain("You must provide a message")
})
test("preserves a run failure exit code", async () => {
let modelRequests = 0
test("passes explicit selections to session creation without catalog preflight", async () => {
const requests: string[] = []
let session: unknown
const server = Bun.serve({
port: 0,
fetch(request) {
async fetch(request) {
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/api/health")
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/model") {
modelRequests++
return Response.json({
location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [],
})
if (url.pathname === "/api/session") {
session = await request.json()
return new Response("boom", { status: 500 })
}
return new Response(undefined, { status: 404 })
},
})
try {
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
const result = await cli([
"run",
"--server",
server.url.toString(),
"--model",
"definitely/missing",
"--agent",
"definitely-missing",
"hi",
])
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("Model unavailable: definitely/missing")
expect(result.stderr).toContain("UnexpectedStatus")
expect(session).toMatchObject({
agent: "definitely-missing",
model: { providerID: "definitely", id: "missing" },
})
expect(requests).not.toContain("/api/model")
expect(requests).not.toContain("/api/agent")
expect(requests).not.toContain("/api/location/wait")
} finally {
server.stop(true)
}
@@ -197,11 +213,16 @@ describe("mini command", () => {
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--server string")
expect(result.stdout).toContain("--prompt string")
expect(result.stdout).toContain("--replay")
expect(result.stdout).toContain("disable with --no-replay")
expect(result.stdout).toContain("--replay-limit integer")
expect(result.stdout).toContain("Limit replay to the newest N messages (default: 200)")
expect(result.stdout).not.toContain("SUBCOMMANDS")
})
test("routes local and explicit-server invocations into mini", async () => {
for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) {
for (const args of [["mini"], ["mini", "--no-replay"], ["mini", "--server", "http://127.0.0.1:1"]]) {
const result = await cli(args)
expect(result.exitCode).toBe(1)
+110 -15
View File
@@ -130,25 +130,25 @@ function failedTool(inputID: string): V2Event[] {
id: "evt_failed_tool_progress",
created: 3,
type: "session.tool.progress",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
metadata: { checkpoint: 1 },
},
},
{
id: "evt_failed_tool_terminal",
created: 4,
type: "session.tool.failed",
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
durable: { aggregateID: "ses_1", seq: 4, version: 2 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
error: { type: "unknown", message: "tool failed" },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
executed: true,
},
},
@@ -156,6 +156,53 @@ function failedTool(inputID: string): V2Event[] {
]
}
function successfulGrep(inputID: string): V2Event[] {
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
return [
prompted(inputID),
{
id: "evt_grep_input",
created: 1,
type: "session.tool.input.started",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
name: "grep",
},
},
{
id: "evt_grep_called",
created: 2,
type: "session.tool.called",
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
input: { pattern: "needle" },
executed: true,
},
},
{
id: "evt_grep_success",
created: 3,
type: "session.tool.success",
durable: { aggregateID: "ses_1", seq: 3, version: 2 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
metadata: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
},
},
settled(),
]
}
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: {
@@ -169,6 +216,7 @@ async function run(input: {
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inputID: string) => SessionMessageInfo[]
wait?: () => Promise<void>
terminalDelay?: number
}) {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
@@ -183,7 +231,10 @@ async function run(input: {
})
continue
}
if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0)
if (value.type.startsWith("session.execution.")) {
if (input.terminalDelay) await Bun.sleep(input.terminalDelay)
setTimeout(wait.resolve, 0)
}
yield value
}
})()
@@ -206,9 +257,7 @@ async function run(input: {
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: input.messages?.(promptID) ?? [
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
],
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
cursor: {},
}),
)
@@ -251,7 +300,7 @@ async function capture(input: Parameters<typeof run>[0]) {
})
try {
await run(input)
return { stdout: stdout.join(""), stderr: stderr.join("") }
return { stdout: stdout.join(""), stderr: stderr.join(""), exitCode: process.exitCode }
} finally {
process.exitCode = exitCode ?? 0
stdoutWrite.mockRestore()
@@ -264,6 +313,32 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
const output = await capture({ format: "json", turn: successfulGrep })
const events = output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
type: "tool_use",
part: {
tool: "grep",
state: {
status: "completed",
output: expect.stringContaining("Found 2 matches"),
metadata: {
metadata: { matches: 2 },
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
},
},
},
})
expect(events[0].part.state.metadata.metadata).toEqual({ matches: 2 })
expect(events[0].part.state.metadata.result).toBeUndefined()
})
test("uses session.wait then reconciles projected output without a terminal event", async () => {
const idle = Promise.withResolvers<void>()
let done = false
@@ -319,6 +394,26 @@ describe("runNonInteractivePrompt", () => {
error: { type: "provider.transport", message: "instructions unavailable" },
}),
])
expect(output.exitCode).toBe(1)
})
test("waits for a terminal failure when idle wins before projection", async () => {
for (const promotedBeforeFailure of [true, false]) {
const output = await capture({
format: "json",
turn: (messageID) => [
...(promotedBeforeFailure ? [prompted(messageID)] : []),
executionFailed("selection unavailable"),
],
messages: (messageID) =>
promotedBeforeFailure ? [{ id: messageID, type: "user", text: "hello", time: { created: 1 } }] : [],
wait: () => Promise.resolve(),
terminalDelay: 10,
})
expect(output.exitCode).toBe(1)
expect(output.stdout).toContain("selection unavailable")
}
})
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
@@ -413,14 +508,14 @@ describe("runNonInteractivePrompt", () => {
],
})
expect(output).toEqual({ stdout: "", stderr: "" })
expect(output).toEqual({ stdout: "", stderr: "", exitCode: 0 })
})
test("renders native failed tool output before the terminal error", async () => {
test("renders a native terminal failure snapshot when live progress was missed", async () => {
const rendered: SessionMessageAssistantTool[] = []
const failed: SessionMessageAssistantTool[] = []
await capture({
turn: failedTool,
turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"),
renderTool: (part) => {
rendered.push(part)
return Promise.resolve()
@@ -436,7 +531,7 @@ describe("runNonInteractivePrompt", () => {
id: "call_failed_tool",
state: {
status: "completed",
structured: { checkpoint: 1 },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
},
},
@@ -446,7 +541,7 @@ describe("runNonInteractivePrompt", () => {
id: "call_failed_tool",
state: {
status: "error",
structured: { checkpoint: 1 },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
error: { message: "tool failed" },
},
@@ -476,7 +571,7 @@ describe("runNonInteractivePrompt", () => {
},
})
expect(events[0].part.state.output).toBeUndefined()
expect(events[0].part.state.metadata.structured).toBeUndefined()
expect(events[0].part.state.metadata.metadata).toBeUndefined()
expect(events[0].part.state.metadata.content).toBeUndefined()
expect(output.stderr).toBe("")
})
+3 -3
View File
@@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Scope } from "effect"
import fs from "node:fs/promises"
@@ -17,7 +17,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
fetch() {
return Response.json({
healthy: true,
version: InstallationVersion,
version: OPENCODE_VERSION,
pid: process.pid,
})
},
@@ -33,7 +33,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
registration,
JSON.stringify({
id,
version: InstallationVersion,
version: OPENCODE_VERSION,
url: server.url.toString(),
pid: process.pid,
}),
+5 -5
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
@@ -332,7 +332,7 @@ test("unresponsive managed port occupancy reports a bounded conflict", async ()
)
const stale = {
id: "stale",
version: InstallationVersion,
version: OPENCODE_VERSION,
url: "http://127.0.0.1:1",
pid: process.pid,
password: "stale",
@@ -368,7 +368,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 })
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }, { status: 503 })
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
@@ -380,7 +380,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
registration,
JSON.stringify({
id: "stale",
version: InstallationVersion,
version: OPENCODE_VERSION,
url: "http://127.0.0.1:1",
pid: 2_147_483_647,
password: "stale",
@@ -397,7 +397,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
await Bun.sleep(8_000)
const info = {
id: "incumbent",
version: InstallationVersion,
version: OPENCODE_VERSION,
url: `http://127.0.0.1:${listener.port}`,
pid: process.pid,
password: "incumbent",
+9
View File
@@ -78,6 +78,15 @@ describe("session target resolver", () => {
expect(order).toEqual(["prepare", "create"])
})
test("uses the agent resolved by the server for a fresh Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
const target = await resolveSessionTarget({ client, prepare })
expect(target.agent).toBe("review")
})
test("does not retry an ambiguous Session creation", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
+8 -4
View File
@@ -75,6 +75,10 @@ export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Tool = sdk.Tool
export const make = sdk.Tool.make`
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Tool = sdk.Tool
@@ -83,10 +87,8 @@ export const RegistrationError = sdk.Tool.RegistrationError
export const make = sdk.Tool.make
export const validateName = sdk.Tool.validateName
export const registrationEntries = sdk.Tool.registrationEntries
export const withPermission = sdk.Tool.withPermission
export const permission = sdk.Tool.permission
export const definition = sdk.Tool.definition
export const settle = sdk.Tool.settle`
export const validateNamespace = sdk.Tool.validateNamespace
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
@@ -100,6 +102,7 @@ const require = __cjs_mod__.createRequire(import.meta.url)
const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
@@ -107,6 +110,7 @@ const __ocPluginModules = ${JSON.stringify({
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-v2-plugin": promisePluginModule,
"opencode:plugin-v2-tool": promiseToolModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
+47 -53
View File
@@ -8,7 +8,7 @@ export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: JsonValue }
export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info"
export type AgentColor = string
export type PermissionV2Effect = "allow" | "deny" | "ask"
@@ -104,6 +104,12 @@ export type SessionMessageProviderState = { [x: string]: JsonValue }
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
export type SessionMessageToolStateRunning = {
status: "running"
input: { [x: string]: JsonValue }
metadata: { [x: string]: JsonValue }
}
export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string }
@@ -163,6 +169,8 @@ export type SessionMessageProviderState7 = { [x: string]: any }
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
@@ -916,6 +924,15 @@ export type SessionToolInputDelta = {
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
}
export type SessionCompactionDelta = {
id: string
created: number
@@ -1094,7 +1111,7 @@ export type TuiCommandExecute = {
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
| (string & {})
}
}
@@ -1385,23 +1402,7 @@ export type SessionToolCalled = {
}
}
export type SessionToolFailed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
result?: any
executed: boolean
resultState?: SessionMessageProviderState7
}
}
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
export type ModelCost = {
tier?: { type: "context"; size: number }
@@ -1823,44 +1824,19 @@ export type SessionPendingUserData1 = {
metadata?: { [x: string]: any }
}
export type SessionMessageToolStateRunning = {
status: "running"
input: { [x: string]: JsonValue }
structured: { [x: string]: JsonValue }
content: Array<LLMToolContent>
}
export type SessionMessageToolStateCompleted = {
status: "completed"
input: { [x: string]: JsonValue }
content: Array<LLMToolContent>
structured: { [x: string]: JsonValue }
result?: JsonValue
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError = {
status: "error"
input: { [x: string]: JsonValue }
content: Array<LLMToolContent>
structured: { [x: string]: JsonValue }
error: SessionStructuredError
result?: JsonValue
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
}
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
}
export type SessionToolSuccess = {
@@ -1868,20 +1844,38 @@ export type SessionToolSuccess = {
created: number
metadata?: { [x: string]: any }
type: "session.tool.success"
durable: { aggregateID: string; seq: number; version: 1 }
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
result?: any
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState6
}
}
export type SessionToolFailed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.failed"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState7
}
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
@@ -1893,6 +1887,7 @@ export type ModelInfo = {
providerID: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
@@ -2219,7 +2214,6 @@ export type SessionEventDurable =
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolProgress
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
+4 -4
View File
@@ -33,7 +33,7 @@ const lookupOrder = Tool.make({
description: "Look up an order by ID",
input: Schema.Struct({ id: Schema.String }),
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
run: ({ id }) => Effect.succeed({ id, status: "open" }),
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
})
const runtime = CodeMode.make({
@@ -55,10 +55,10 @@ const result = await Effect.runPromise(
### `Tool.make`
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is
decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only
shape the model-visible signature. Without `output`, the signature uses `Promise<unknown>`.
decoded before `execute`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas
only shape the model-visible signature. Without `output`, the signature uses `Promise<void>`.
Descriptions and schemas are model-visible contracts. Authorization belongs in `run`.
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as

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