Compare commits

...

81 Commits

Author SHA1 Message Date
opencode-agent[bot] b6f85c2250 fix(core): default custom model capabilities (#38449)
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404 target:linux-x64]) (push) Blocked by required conditions
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404-arm target:linux-arm64]) (push) Blocked by required conditions
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-arm64]) (push) Blocked by required conditions
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-x64]) (push) Blocked by required conditions
publish / build-node-cli (map[host:macos-26 target:darwin-arm64]) (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
typecheck / typecheck (push) Waiting to run
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 23:20:16 -05:00
opencode-agent[bot] 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 Long f1f0f47ee2 fix(core): migrate named agent colors (#38414) 2026-07-22 22:38:13 -04:00
Kit Langton 203b9f59b7 fix(core): load dynamic models for generation (#38401) 2026-07-22 22:26:08 -04:00
Aiden Cline 48bcbd09ef fix(ai): handle incomplete responses without reasons (#38374) 2026-07-22 20:49:08 -05:00
James Long 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 Long 381f6c47b4 docs(tui): add generated V2 theme reference (#38396) 2026-07-22 18:30:31 -04:00
James Long b6e14b5a74 refactor(tui): finish V2 theme migration (#38383) 2026-07-22 17:56:22 -04:00
opencode-agent[bot] 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
opencode-agent[bot] 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
opencode-agent[bot] 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 Long 88f572cfce refactor(tui): migrate selection views to V2 theme (#38001) 2026-07-22 16:19:00 -04:00
Aiden Cline 2271f9b222 feat(ai): support PDF inputs (#38253) 2026-07-22 13:42:29 -05:00
James Long 8a36abd328 feat(tui): add developer debug bar (#38359) 2026-07-22 14:35:28 -04:00
Aiden Cline 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 Langton 7a1f9764a2 feat(core): compact durable tool metadata (#38343) 2026-07-22 12:09:48 -04:00
opencode-agent[bot] 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 Long dba5da7c10 fix(tui): restore shell mode styling (#38231) 2026-07-22 11:23:50 -04:00
opencode-agent[bot] 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
opencode-agent[bot] 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 Langton 5a9ed4d350 fix: make tool progress live-only (#38217) 2026-07-22 10:40:07 -04:00
Simon Klee 4204b9d087 fix(tui): show providers in Mini model search (#38321) 2026-07-22 16:02:17 +02:00
Simon Klee 8fad13365b feat(tui): add mini splash setting (#38317)
Fix #38010
2026-07-22 15:46:50 +02:00
Simon Klee 5841b04fe7 tui: resolve default mini footer agent (#38315) 2026-07-22 15:42:12 +02:00
Simon Klee e12ec8681b fix(tui): render mini markdown in mono mode (#38313) 2026-07-22 15:32:43 +02:00
Simon Klee a3e2cc0dcd feat(tui): add mini agent switching (#38287)
Fixes #36121
2026-07-22 11:14:10 +00:00
Simon Klee 648183cecb fix(tui): quiet hidden mini footer (#38279) 2026-07-22 12:21:43 +02:00
Simon Klee 58d18be590 oc mini v2 (#38278) 2026-07-22 11:50:21 +02:00
Simon Klee dc9fd126a0 test(cli): cover interactive command flags (#38273) 2026-07-22 11:16:14 +02:00
Simon Klee 794137b33b refactor(tui): narrow mini compatibility surfaces (#38262) 2026-07-22 10:35:35 +02:00
Simon Klee 0e08b7330f fix(tui): set mini terminal title on Linux (#38261) 2026-07-22 10:35:07 +02:00
Simon Klee ced3d5e02a fix(tui): submit prompt when resuming session (#38260) 2026-07-22 10:34:39 +02:00
Simon Klee 6e826f3e22 fix(tui): hide project commands from mini palette (#38259) 2026-07-22 10:34:08 +02:00
Simon Klee 8bb1cfaa3b fix: defer catalog validation to session execution (#38258) 2026-07-22 10:32:52 +02:00
Simon Klee 691a7d93c8 feat(tui): add compact command to mini (#38251)
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404 target:linux-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404-arm target:linux-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:macos-26 target:darwin-arm64]) (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-22 07:03:46 +02:00
Simon Klee ca6da05d07 fix(tui): show skill name in mini tool output (#38250) 2026-07-22 07:03:02 +02:00
Aiden Cline 23483ea013 feat(ai): support custom reasoning fields (#38227) 2026-07-21 23:42:59 -05:00
Aiden Cline 69c05ae3fc feat(codemode): assimilate promise thenables (#38237) 2026-07-21 23:32:02 -05:00
opencode-agent[bot] 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 Long 6b9136e797 fix(tui): inherit elevated tool theme (#38224) 2026-07-21 22:45:21 -04:00
opencode-agent[bot] 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 Cline 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 Cline 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
Dax 8de40be6ea feat(plugin): expose app metadata (#38179) 2026-07-21 22:16:25 +00:00
Simon Klee 2ed8fe5960 mini: add hideable footer details (#38192) 2026-07-21 23:28:40 +02:00
Dax 1ea9137c01 fix(core): identify spawned subagents (#38189) 2026-07-21 16:54:01 -04:00
opencode-agent[bot] 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
opencode-agent[bot] 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
opencode-agent[bot] 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 Klee e8d273ae5b mini: add monochrome ASCII mode (#38173) 2026-07-21 22:01:09 +02:00
Aiden Cline 7ec1b6e580 feat(core): deliver CodeMode catalog through instructions (#38003) 2026-07-21 14:46:18 -05:00
Simon Klee c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden Cline 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
Dax 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
opencode-agent[bot] 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
Dax 9c38358197 refactor: centralize client identity (#38148) 2026-07-21 14:01:04 -04:00
Simon Klee dd6c95fdc7 mini: add quiet transcript settings (#38152) 2026-07-21 19:36:34 +02:00
Aiden Cline 6f4b9504e5 feat(codemode): support custom async iterators (#38141) 2026-07-21 12:18:46 -05:00
Aiden Cline dbfbb13ccc fix(core): authorize patch move destinations (#38133) 2026-07-21 12:18:23 -05:00
Simon Klee 99b2e78d75 fix(tui): improve responsive footer details (#38116) 2026-07-21 19:00:21 +02:00
opencode-agent[bot] 023f76f6fc fix(core): refresh model catalog every five minutes (#38142)
Co-authored-by: Victor Navarro <36263538+vimtor@users.noreply.github.com>
2026-07-21 16:34:54 +00:00
Dax 69d7c2add7 feat(util): configure package publishing (#38129) 2026-07-21 12:32:23 -04:00
Shoubhit Dash cf651bc41b feat(cli): port ACP to V2 (#37907) 2026-07-21 21:13:21 +05:30
Aiden Cline 8d80365ef4 refactor(codemode): simplify model-facing wording (#38042) 2026-07-21 10:19:53 -05:00
Aiden Cline 3ea8895299 fix(core): enforce patch EOF anchors (#38039) 2026-07-21 10:15:51 -05:00
Dax 3304e79827 fix(tui): prevent autocomplete search flicker (#38128) 2026-07-21 10:41:36 -04:00
opencode-agent[bot] cf2c1bf425 refactor(core): break plugin host cycle (#38120)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-21 10:35:00 -04:00
Dax e0810753f2 refactor: extract shared util package (#37828) 2026-07-21 10:34:29 -04:00
Dax 7243bd9e12 fix: preserve runtime option defaults (#38118) 2026-07-21 10:30:18 -04:00
Simon Klee dd50d457b0 refactor(tui): simplify mini footer status line (#38112) 2026-07-21 16:08:19 +02:00
Simon Klee 592ef7433a mini: settle on wait and durable pending (#37984) 2026-07-21 15:42:55 +02:00
opencode-agent[bot] fd97d789ef test(app): stabilize offset observer test (#38044)
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404 target:linux-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404-arm target:linux-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:macos-26 target:darwin-arm64]) (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-21 00:26:46 -05:00
Aiden Cline 7111f93836 feat(codemode): support bounded async iteration (#38040) 2026-07-21 00:02:31 -05:00
630 changed files with 25026 additions and 7207 deletions
+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'
+176 -19
View File
@@ -124,16 +124,18 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
@@ -358,52 +360,38 @@
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
},
"devDependencies": {
@@ -418,10 +406,7 @@
"@parcel/watcher-win32-x64": "2.5.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
@@ -706,6 +691,7 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
@@ -771,6 +757,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"effect": "catalog:",
},
@@ -898,6 +885,7 @@
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@@ -972,6 +960,51 @@
"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",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@npmcli/arborist": "catalog:",
"@npmcli/config": "10.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"cross-spawn": "catalog:",
"effect": "catalog:",
"glob": "13.0.5",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"xdg-basedir": "5.1.0",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@typescript/native-preview": "catalog:",
},
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.4",
@@ -1140,6 +1173,8 @@
"@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=="],
"@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=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="],
@@ -2098,6 +2133,10 @@
"@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"],
"@opencode-ai/www": ["@opencode-ai/www@workspace:packages/www"],
@@ -6564,6 +6603,8 @@
"@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=="],
@@ -7834,6 +7875,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=="],
@@ -8682,6 +8733,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=="],
@@ -8966,6 +9083,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=="],
@@ -9116,6 +9271,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=="],
+1 -1
View File
@@ -15,7 +15,7 @@
"dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
+36 -29
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,
@@ -319,12 +333,17 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
return { type: wireType, tool_use_id: part.id, content: part.result.value } 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 +354,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 +452,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"])
+11 -11
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
@@ -437,21 +436,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 },
})
}
+24 -9
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 })
}
@@ -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.
+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,9 @@ 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", () =>
// 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 +253,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 +264,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 +294,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({
@@ -756,7 +758,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 +768,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 +784,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),
)
}),
)
})
@@ -269,6 +269,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 +582,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 +598,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 +608,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(
+64 -11
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/" } }],
},
},
],
},
])
@@ -372,7 +391,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 +420,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 +441,13 @@ describe("Gemini route", () => {
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "tool_0",
name: "lookup",
result: "done",
resultType: "text",
providerMetadata: toolCall?.providerMetadata,
}),
],
}),
)
@@ -424,7 +456,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 +545,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 +560,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({
@@ -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 })
+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
@@ -38,17 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([[0, false]])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
+3 -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,16 +22,18 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
+1 -2
View File
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto"
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
@@ -40,7 +39,7 @@ export async function collectNodeAssets(target: NodeTarget) {
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
{
key: photonWasmAsset,
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
...attentionSoundAssets.map((key) => ({
key,
+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: {},
})
+65
View File
@@ -0,0 +1,65 @@
import {
RequestError,
type Agent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
import { ACPError } from "./error"
import { ACPService } from "./service"
export function create(client: OpenCodeClient, connection: AgentSideConnection) {
const service = ACPService.make({ client, connection })
return {
initialize: (params: InitializeRequest) => run(service.initialize(params)),
authenticate: (params: AuthenticateRequest) => run(service.authenticate(params)),
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(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
}
async function run<A>(promise: Promise<A>) {
try {
return await promise
} catch (error) {
if (error instanceof RequestError) throw error
if (isACPError(error)) throw ACPError.toRequestError(error)
throw ACPError.toRequestError(ACPError.fromUnknown(error))
}
}
function isACPError(error: unknown): error is ACPError.Error {
return (
error instanceof ACPError.SessionNotFoundError ||
error instanceof ACPError.InvalidConfigOptionError ||
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidEffortError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.AuthRequiredError ||
error instanceof ACPError.UnknownAuthMethodError ||
error instanceof ACPError.ServiceFailureError
)
}
export * as ACP from "./agent"
+133
View File
@@ -0,0 +1,133 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
export const DEFAULT_VARIANT_VALUE = "default"
export type ConfigOptionModel = {
id: string
name: string
variants?: ReadonlyArray<string>
}
export type ConfigOptionProvider = {
id: string
name: string
models: ReadonlyArray<ConfigOptionModel>
}
export type ConfigOptionMode = {
id: string
name: string
description?: string
}
export type ModelSelection = {
model: { providerID: string; modelID: string }
variant?: string
}
export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
modes?: readonly ConfigOptionMode[]
currentModeId?: string
}): SessionConfigOption[] {
const variants =
input.providers
.find((provider) => provider.id === input.currentModel.providerID)
?.models.find((model) => model.id === input.currentModel.modelID)?.variants ?? []
const effort =
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
return [
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
...(effort ? [effort] : []),
...(input.modes && input.currentModeId
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
: []),
]
}
export function buildModelSelectOption(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
}): SessionConfigOption {
return {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
options: input.providers.flatMap((provider) =>
provider.models
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
),
}
}
export function buildEffortSelectOption(input: {
variants: readonly string[]
currentVariant?: string
}): SessionConfigOption {
return {
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
}
}
export function buildModeSelectOption(input: {
modes: readonly ConfigOptionMode[]
currentModeId: string
}): SessionConfigOption {
return {
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.currentModeId,
options: input.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
}
}
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
if (!provider) {
const separator = modelId.indexOf("/")
if (separator === -1) return { model: { providerID: modelId, modelID: "" } }
return { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
}
const modelID = modelId.slice(provider.id.length + 1)
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
const separator = modelID.lastIndexOf("/")
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
const model = provider.models.find((item) => item.id === baseModelID)
if (model && variant && model.variants?.includes(variant)) {
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
}
return { model: { providerID: provider.id, modelID } }
}
export function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
}
export * as ACPConfigOption from "./config-option"
+183
View File
@@ -0,0 +1,183 @@
import type { ContentBlock, ContentChunk, ResourceLink } from "@agentclientprotocol/sdk"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
export type PromptPart =
| { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean }
| { readonly type: "file"; readonly url: string; readonly filename?: string; readonly mime: string }
export type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
return content.flatMap(contentBlockToParts)
}
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
switch (block.type) {
case "text": {
const audience = block.annotations?.audience
if (audience?.length === 1 && audience[0] === "assistant") {
return [{ type: "text", text: block.text, synthetic: true }]
}
if (audience?.length === 1 && audience[0] === "user") {
return [{ type: "text", text: block.text, ignored: true }]
}
return [{ type: "text", text: block.text }]
}
case "image":
if (block.data) {
return [
{
type: "file",
url: `data:${block.mimeType};base64,${block.data}`,
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
mime: block.mimeType,
},
]
}
if (block.uri?.startsWith("data:") || block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
return [
{
type: "file",
url: block.uri,
filename: filenameFromUri(block.uri) ?? "image",
mime: block.mimeType,
},
]
}
return []
case "resource_link":
return [resourceLinkToPart(block)]
case "resource":
if ("text" in block.resource) {
try {
const parsed = new URL(block.resource.uri)
if (parsed.protocol === "file:") {
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
const decoded = (() => {
try {
return fileURLToPath(parsed)
} catch {
return decodeURIComponent(parsed.pathname)
}
})()
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
}
} catch {}
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
}
if (!block.resource.mimeType) return []
return [
{
type: "file",
url: block.resource.uri.startsWith("data:")
? block.resource.uri
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
filename: filenameFromUri(block.resource.uri) ?? "file",
mime: block.resource.mimeType,
},
]
default:
return []
}
}
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
return parts.flatMap((part): ContentChunk[] => {
if (part.type === "text") {
if (!part.text) return []
return [
{
content: {
type: "text",
text: part.text,
...(part.synthetic ? { annotations: { audience: ["assistant" as const] } } : {}),
...(!part.synthetic && part.ignored ? { annotations: { audience: ["user" as const] } } : {}),
},
},
]
}
if (part.type === "reasoning") {
return part.text ? [{ content: { type: "text", text: part.text } }] : []
}
if (part.url.startsWith("file://")) {
return [
{
content: {
type: "resource_link",
uri: part.url,
name: part.filename ?? "file",
mimeType: part.mime,
},
},
]
}
if (!part.url.startsWith("data:")) return []
const match = /^data:([^;]+);base64,(.*)$/.exec(part.url)
if (!match?.[1] || match[2] === undefined) return []
const mime = match[1]
const data = match[2]
if (mime.startsWith("image/")) {
return [
{
content: {
type: "image",
mimeType: mime,
data,
uri: pathToFileURL(part.filename ?? "image").href,
},
},
]
}
return [
{
content: {
type: "resource",
resource:
mime.startsWith("text/") || mime === "application/json"
? {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: mime,
text: Buffer.from(data, "base64").toString("utf8"),
}
: {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: mime,
blob: data,
},
},
},
]
})
}
function resourceLinkToPart(link: ResourceLink): PromptPart {
if (link.uri.startsWith("file://")) {
return {
type: "file",
url: link.uri,
filename: link.name || filenameFromUri(link.uri) || "file",
mime: link.mimeType ?? "text/plain",
}
}
if (link.uri.startsWith("zed://") && URL.canParse(link.uri)) {
const pathname = new URL(link.uri).searchParams.get("path")
if (pathname)
return {
type: "file",
url: pathToFileURL(pathname).href,
filename: link.name || path.basename(pathname) || "file",
mime: link.mimeType ?? "text/plain",
}
}
return { type: "text", text: link.uri }
}
function filenameFromUri(uri: string | undefined): string | undefined {
if (!uri || uri.startsWith("data:")) return undefined
if (URL.canParse(uri)) return path.basename(new URL(uri).pathname) || undefined
return path.basename(uri) || undefined
}
export * as ACPContent from "./content"
+86
View File
@@ -0,0 +1,86 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()("ACPSessionNotFoundError", {
sessionId: Schema.String,
}) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPInvalidConfigOptionError",
{ configId: Schema.String },
) {}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
effort: Schema.String,
}) {}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
mode: Schema.String,
}) {}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPUnknownAuthMethodError",
{ methodId: Schema.String },
) {}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
errorName: Schema.optional(Schema.String),
}) {}
export type Error =
| SessionNotFoundError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| AuthRequiredError
| UnknownAuthMethodError
| ServiceFailureError
export function toRequestError(error: Error): RequestError {
switch (error._tag) {
case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
case "ACPInvalidConfigOptionError":
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
case "ACPInvalidModelError":
return RequestError.invalidParams(
{ providerId: error.providerId, modelId: error.modelId },
`model not found: ${error.modelId}`,
)
case "ACPInvalidEffortError":
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
case "ACPInvalidModeError":
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
case "ACPAuthRequiredError":
return RequestError.authRequired({}, "provider authentication required")
case "ACPUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPServiceFailureError":
return RequestError.internalError(
{
...(error.service ? { service: error.service } : {}),
...(error.errorName ? { errorName: error.errorName } : {}),
},
error.safeMessage,
)
}
const exhaustive: never = error
return exhaustive
}
export function fromUnknown(error: unknown, service?: string) {
const errorName = error instanceof Error ? error.name : undefined
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
}
export * as ACPError from "./error"
+441
View File
@@ -0,0 +1,441 @@
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
OpenCodeClient,
SessionMessageAssistant,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ACPError } from "./error"
import { replyPermission, syncEditedFiles } from "./permission"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
type ToolContent,
type ToolInput,
} from "./tool"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
export type TurnControl = {
cancelled: boolean
readonly admission: AbortController
}
type ToolState = {
readonly name: string
input: ToolInput
structured: Record<string, unknown>
content: ToolContent
}
export type TurnStart =
| { readonly type: "input"; readonly id: string }
| { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string }
function emptyToolState(): ToolState {
return { name: "tool", input: {}, structured: {}, content: [] }
}
export async function streamTurn(input: {
readonly client: OpenCodeClient
readonly connection: Connection
readonly sessionID: string
readonly cwd: string
readonly start: TurnStart
readonly userMessageID?: string | null
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
}): Promise<PromptResponse> {
const streamController = new AbortController()
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission")
const control = input.control
let started = false
let assistantMessageID: string | undefined
let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>()
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
const consume = async () => {
while (!streamController.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.v2.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
await replyPermission({
client: input.client,
connection: input.connection,
event,
sessionID: input.sessionID,
cwd: input.cwd,
tool,
})
continue
}
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
await input.client.form
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
if (matchesStart(event, input.start)) {
started = true
continue
}
if (!started) continue
if (event.type === "session.step.started") {
assistantMessageID = event.data.assistantMessageID
continue
}
if (event.type === "session.text.delta") {
assistantMessageID = event.data.assistantMessageID
await update({
sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
}
if (event.type === "session.reasoning.delta") {
assistantMessageID = event.data.assistantMessageID
await update({
sessionUpdate: "agent_thought_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
}
if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.callID,
toolName: event.data.name,
state: { input: {} },
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.called") {
assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.callID) ?? emptyToolState()
current.input = event.data.input
tools.set(event.data.callID, current)
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
}),
})
continue
}
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
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
content: current.content,
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await syncEditedFiles({
connection: input.connection,
sessionID: input.sessionID,
cwd: input.cwd,
toolName: current.name,
toolInput: current.input,
structured: event.data.structured,
}).catch(() => {})
await update({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: event.data.structured,
content: event.data.content,
result: event.data.result,
}),
})
continue
}
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await update({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: event.data.metadata ?? current.structured,
content: event.data.content ?? current.content,
error: event.data.error.message,
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.step.ended") {
assistantMessageID = event.data.assistantMessageID
finish = event.data.finish
continue
}
if (event.type === "session.execution.succeeded") return "succeeded" as const
if (event.type === "session.execution.interrupted") return "interrupted" as const
if (event.type === "session.execution.failed") {
executionError = event.data.error
return "failed" as const
}
}
return "interrupted" as const
}
const completed = consume()
try {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
})
if (control.cancelled) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (!started) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID)
}
}
const terminal = await completed
const assistant = assistantMessageID
? await input.client.session
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
.catch(() => undefined)
: undefined
return response(
assistant?.type === "assistant" ? assistant : undefined,
executionError,
terminal,
control.cancelled,
finish,
input.userMessageID,
)
} catch (error) {
streamController.abort()
await completed.catch(() => {})
throw error
} finally {
streamController.abort()
await stream.return?.(undefined).catch(() => {})
}
}
export async function replayMessages(
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
cwd: string,
messages: readonly SessionMessageInfo[],
) {
for (const message of messages) await replayMessage(connection, sessionID, cwd, message).catch(() => {})
}
async function replayMessage(
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
cwd: string,
message: SessionMessageInfo,
) {
if (message.type === "user") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "user_message_chunk",
messageId: message.id,
content: { type: "text", text: message.text },
},
})
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
type: "file",
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
filename: file.name,
mime: file.mime,
}))
for (const chunk of partsToContentChunks(files)) {
await connection.sessionUpdate({
sessionId: sessionID,
update: { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk },
})
}
return
}
if (message.type !== "assistant") return
for (const part of message.content) {
if (part.type === "text") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "agent_message_chunk",
messageId: message.id,
content: { type: "text", text: part.text },
},
})
continue
}
if (part.type === "reasoning") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: message.id,
content: { type: "text", text: part.text },
},
})
continue
}
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.status === "streaming" ? {} : part.state.input },
cwd,
}),
},
})
switch (part.state.status) {
case "completed":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
content: part.state.content,
result: part.state.result,
}),
},
})
break
case "running":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.input },
content: part.state.content,
cwd,
}),
},
})
break
case "error":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
content: part.state.content,
error: part.state.error.message,
cwd,
}),
},
})
break
case "streaming":
break
}
}
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
if (start.type === "input") return event.type === "session.input.promoted" && event.data.inputID === start.id
if (start.type === "compaction")
return event.type === "session.compaction.admitted" && event.data.inputID === start.id
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
}
function response(
assistant: SessionMessageAssistant | undefined,
executionError: { readonly type: string; readonly message: string } | undefined,
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()
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
throw new ACPError.ServiceFailureError({
safeMessage: error.message || "OpenCode prompt failed",
service: "session",
errorName: error.type,
})
}
const tokens = assistant?.tokens
const usage = tokens
? {
inputTokens: tokens.input,
outputTokens: tokens.output,
totalTokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write,
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
}
: undefined
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} }
}
function resolveStopReason(input: {
readonly terminal: "succeeded" | "failed" | "interrupted"
readonly cancelled: boolean
readonly finish: SessionMessageAssistant["finish"]
readonly error?: string
}): PromptResponse["stopReason"] {
if (input.cancelled || input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
if (input.finish === "length") return "max_tokens"
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
return "end_turn"
}
export * as ACPEvent from "./event"
+179
View File
@@ -0,0 +1,179 @@
import type { AgentSideConnection, PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { Patch } from "@opencode-ai/util/patch"
import { Result } from "effect"
import { isAbsolute, resolve } from "node:path"
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.v2.asked" }>
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
type Tool = { readonly name: string; readonly input: ToolInput }
const options: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
export async function replyPermission(input: {
readonly client: OpenCodeClient
readonly connection: Connection
readonly event: PermissionEvent
readonly sessionID: string
readonly cwd: string
readonly tool?: Tool
}) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const result = await input.connection
.requestPermission({
sessionId: input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.event.data.source?.callID ?? input.event.data.id,
toolName,
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}),
},
options,
})
.catch(() => undefined)
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
const reply = selected === "once" || selected === "always" ? selected : "reject"
await input.client.permission.reply({
sessionID: input.sessionID,
requestID: input.event.data.id,
reply,
})
}
export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly sessionID: string
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly structured: 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 (!file || typeof file !== "object") return []
const path = Reflect.get(file, "file")
return typeof path === "string" ? [path] : []
})
: []
const path = filePath(input.toolInput)
const paths = [...new Set([...files, ...(path ? [path] : [])])]
await Promise.all(
paths.map(async (path) => {
const target = resolvePath(path, input.cwd)
const file = Bun.file(target)
if (!(await file.exists())) return
await input.connection.writeTextFile?.({ sessionId: input.sessionID, path: target, content: await file.text() })
}),
)
}
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const tool = toolName.toLocaleLowerCase()
if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
const path = filePath(input)
if (!path) return []
const oldText = await readText(path, cwd)
if (tool === "write") {
const content = stringValue(input.content)
return content === undefined ? [] : [{ type: "diff", path, oldText, newText: content }]
}
if (tool !== "edit") return []
const oldString = stringValue(input.oldString)
const newString = stringValue(input.newString)
if (oldString === undefined || newString === undefined) return []
const newText =
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
return [{ type: "diff", path, oldText, newText }]
}
async function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const patchText = stringValue(input.patchText)
if (!patchText) return []
try {
const parsed = Patch.parse(patchText)
if (Result.isFailure(parsed)) return []
return await Promise.all(
parsed.success.map(async (hunk): Promise<ToolCallContent> => {
const oldText = hunk.type === "add" ? "" : await readText(hunk.path, cwd)
if (hunk.type === "add") {
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
return { type: "diff", path: hunk.path, oldText, newText }
}
if (hunk.type === "delete") return { type: "diff", path: hunk.path, oldText, newText: "" }
return {
type: "diff",
path: hunk.movePath ?? hunk.path,
oldText,
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
}
}),
)
} catch {
return []
}
}
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
if (previews.length > 1) return `${previews.length} files`
switch (toolName.toLocaleLowerCase()) {
case "external_directory":
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
case "webfetch":
return stringValue(input.url)
case "websearch":
return stringValue(input.query)
case "grep":
case "glob":
return stringValue(input.pattern)
case "read":
case "edit":
case "write":
case "patch":
case "apply_patch":
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
default:
return undefined
}
}
function permissionLocations(
toolName: string,
input: ToolInput,
resources: ReadonlyArray<string>,
cwd: string,
previews: ReadonlyArray<ToolCallContent>,
): ToolCallLocation[] {
const paths = previews.flatMap((preview) => (preview.type === "diff" ? [preview.path] : []))
if (paths.length > 0) return [...new Set(paths)].map((path) => ({ path }))
const locations = toLocations(toolName, input, cwd)
if (locations.length > 0) return locations
return resources.filter((resource) => resource !== "*").map((path) => ({ path }))
}
function readText(path: string, cwd: string) {
return Bun.file(resolvePath(path, cwd))
.text()
.catch(() => "")
}
function filePath(input: ToolInput) {
return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath)
}
function resolvePath(path: string, cwd: string) {
return isAbsolute(path) ? path : resolve(cwd, path)
}
export * as ACPPermission from "./permission"
+531
View File
@@ -0,0 +1,531 @@
import {
isSessionNotFoundError,
type CommandInfo,
type ModelInfo,
type ModelRef,
type OpenCodeClient,
type SessionInfo,
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import type {
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
AuthMethod,
CancelNotification,
CloseSessionRequest,
CloseSessionResponse,
ForkSessionRequest,
ForkSessionResponse,
InitializeRequest,
InitializeResponse,
ListSessionsRequest,
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
McpServer,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
ResumeSessionRequest,
ResumeSessionResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
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"
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
readonly defaultModel: ModelRef
readonly modes: Array<{ id: string; name: string; description?: string }>
readonly defaultModeID: string
readonly commands: CommandInfo[]
readonly skills: SkillInfo[]
}
type Attached = {
readonly id: string
readonly cwd: string
catalog: Catalog
model: ModelRef
modeID: string
}
type PreparedPrompt = {
readonly start: TurnStart
readonly text: string
readonly files: Array<{ readonly uri: string; readonly name?: string }>
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: CommandInfo
readonly skill?: SkillInfo
}
export interface Interface {
initialize(input: InitializeRequest): Promise<InitializeResponse>
authenticate(input: AuthenticateRequest): Promise<AuthenticateResponse>
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
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>
}
export function make(input: { readonly client: OpenCodeClient; readonly connection: Connection }): Interface {
const sessions = new Map<string, Attached>()
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
const catalog = (cwd: string) => {
const cached = catalogs.get(cwd)
if (cached) return cached
const loaded = loadCatalog(input.client, cwd).catch((error) => {
catalogs.delete(cwd)
throw error
})
catalogs.set(cwd, loaded)
return loaded
}
const requireSession = async (sessionID: string) => {
const current = sessions.get(sessionID)
if (current) return current
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd)
const state: Attached = {
id: session.id,
cwd,
catalog: currentCatalog,
model: session.model ?? currentCatalog.defaultModel,
modeID: session.agent ?? currentCatalog.defaultModeID,
}
sessions.set(session.id, state)
await registerMcpServers(input.client, registeredMcp, state, mcpServers)
await input.connection.sessionUpdate({
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
...state.catalog.commands,
...state.catalog.skills.filter(
(skill) => !state.catalog.commands.some((command) => command.name === skill.name),
),
].map((command) => ({ name: command.name, description: command.description ?? "" })),
},
})
return state
}
const replay = async (state: Attached) => {
await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
}
const configOptions = (state: Attached) =>
buildConfigOptions({
providers: state.catalog.providers,
currentModel: { providerID: state.model.providerID, modelID: state.model.id },
currentVariant: state.model.variant,
modes: state.catalog.modes,
currentModeId: state.modeID,
})
return {
initialize: async (params) => {
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
id: AuthMethodID,
}
if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
authMethod._meta = {
"terminal-auth": { command: "opencode", args: ["auth", "login"], label: "OpenCode Login" },
}
}
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
},
authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
}
},
authenticate: async (params) => {
if (params.methodId !== AuthMethodID) throw new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
return {}
},
newSession: async (params) => {
const currentCatalog = await catalog(params.cwd)
const created = await input.client.session.create({
location: { directory: params.cwd },
agent: currentCatalog.defaultModeID,
model: currentCatalog.defaultModel,
})
const state = await attach(created, params.cwd, params.mcpServers)
return { sessionId: state.id, configOptions: configOptions(state) }
},
loadSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers)
await replay(state)
return { configOptions: configOptions(state) }
},
listSessions: async (params) => {
const page = await input.client.session.list({
...(params.cwd ? { directory: params.cwd } : {}),
order: "desc",
limit: 100,
...(params.cursor ? { cursor: params.cursor } : {}),
})
return {
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: session.title,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
sessions.delete(params.sessionId)
registeredMcp.delete(params.sessionId)
const turn = active.get(params.sessionId)
if (turn) {
turn.cancelled = true
turn.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
return {}
},
forkSession: async (params) => {
const forked = await input.client.session.fork({ sessionID: params.sessionId })
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state)
return { sessionId: state.id, configOptions: configOptions(state) }
},
setSessionConfigOption: async (params) => {
const state = await requireSession(params.sessionId)
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
}
case "effort": {
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (!model?.variants.some((variant) => variant.id === params.value))
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
break
}
case "mode":
await selectMode(input.client, state, params.value)
break
default:
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
return { configOptions: configOptions(state) }
},
setSessionMode: async (params) => {
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)) {
throw new ACPError.ServiceFailureError({
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
service: "session",
})
}
const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() }
active.set(state.id, control)
const response = await streamTurn({
client: input.client,
connection: input.connection,
sessionID: state.id,
cwd: state.cwd,
start: prepared.start,
userMessageID: params.messageId,
control,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
}).finally(() => {
if (active.get(state.id) === control) active.delete(state.id)
})
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
return response
},
cancel: async (params) => {
const current = active.get(params.sessionId)
if (current) {
current.cancelled = true
current.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
},
}
}
function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
const parts = promptContentToParts(prompt)
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
const slash = detectSlashCommand(text)
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
const skill = slash ? catalog.skills.find((item) => item.name === slash.name) : undefined
const start = turnStart(messageID, slash, skill)
return { start, text, files, synthetic, slash, command, skill }
}
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
if (prompt.synthetic.length > 0) {
await client.session.synthetic({
sessionID: session.id,
text: prompt.synthetic.join("\n\n"),
description: "ACP embedded context",
delivery: "steer",
resume: false,
})
}
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
if (prompt.skill) return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id })
if (prompt.command) {
return client.session.command(
{
sessionID: session.id,
id: prompt.start.id,
command: prompt.command.name,
arguments: prompt.slash?.args,
files: prompt.files,
delivery: "steer",
},
{ signal },
)
}
return client.session.prompt(
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
{ signal },
)
}
function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: SkillInfo | undefined): TurnStart {
if (slash?.name === "compact") return { type: "compaction", id: messageID }
if (skill) return { type: "skill", id: messageID }
return { type: "input", id: messageID }
}
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const location = { directory: cwd }
// Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
const [modelResult, defaultResult, agentResult, commandResult, skillResult] = await Promise.all([
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
client.skill.list({ location }),
])
const models = modelResult.data.filter((model) => model.enabled)
const defaultModel = defaultResult.data ?? models[0]
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
if (defaultModel && defaultAgent) {
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant:
defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
skills: skillResult.data.filter((skill) => skill.slash !== false),
}
}
missing = defaultModel ? "No primary agents are available" : "No models are available"
await Bun.sleep(25)
}
throw new Error(missing)
}
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
return Array.from(new Set(models.map((model) => model.providerID)))
.toSorted()
.map((providerID) => ({
id: providerID,
name: providerID,
models: models
.filter((model) => model.providerID === providerID)
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
}))
}
function requireModel(catalog: Catalog, modelID: string): ModelRef {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
)
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
return { providerID: model.providerID, id: model.id, variant: selected.variant }
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
state.modeID = modeID
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
}
async function getSession(client: OpenCodeClient, sessionID: string) {
return client.session.get({ sessionID }).catch((error) => {
if (isSessionNotFoundError(error)) throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
throw error
})
}
async function messages(client: OpenCodeClient, sessionID: string) {
const result: SessionMessageInfo[] = []
let cursor: string | undefined
do {
const page = cursor
? await client.message.list({ sessionID, limit: 200, cursor })
: await client.message.list({ sessionID, limit: 200, order: "asc" })
result.push(...page.data)
cursor = page.cursor.next ?? undefined
} while (cursor)
return result
}
async function registerMcpServers(
client: OpenCodeClient,
registered: Map<string, Set<string>>,
session: Attached,
servers: readonly McpServer[],
) {
const current = registered.get(session.id) ?? new Set<string>()
registered.set(session.id, current)
await Promise.all(
servers.flatMap((server) => {
const config = mcpConfig(server)
const key = `${server.name}:${stableStringify(config)}`
if (current.has(key)) return []
current.add(key)
return [
client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => {
current.delete(key)
throw error
}),
]
}),
)
}
function mcpConfig(server: McpServer) {
if ("type" in server) {
return {
type: "remote" as const,
url: server.url,
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
oauth: false as const,
}
}
return {
type: "local" as const,
command: [server.command, ...server.args],
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
}
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
if (!value || typeof value !== "object") return JSON.stringify(value)
return `{${Object.entries(value)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`
}
async function sendUsageUpdate(client: OpenCodeClient, connection: Connection, session: Attached, used?: number) {
if (!used) return
const model = session.catalog.models.find(
(item) => item.providerID === session.model.providerID && item.id === session.model.id,
)
if (!model?.limit.context) return
const info = await client.session.get({ sessionID: session.id })
await connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "usage_update",
used,
size: model.limit.context,
cost: { amount: info.cost, currency: "USD" },
},
})
}
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
const value = text.trim()
if (!value.startsWith("/")) return undefined
const [name, ...rest] = value.slice(1).split(/\s+/)
if (!name) return undefined
return { name, args: rest.join(" ").trim() }
}
export * as ACPService from "./service"
+222
View File
@@ -0,0 +1,222 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
>
export function toToolKind(toolName: string): ToolKind {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "apply_patch":
case "patch":
case "write":
return "edit"
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
case "read":
return "read"
case "task":
case "subagent":
return "think"
default:
return "other"
}
}
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell": {
const workdir = shellWorkdir(input, cwd)
return workdir ? [{ path: workdir }] : []
}
case "read":
case "edit":
case "write":
case "patch":
case "apply_patch":
return locationFrom(input.filePath ?? input.filepath)
case "external_directory":
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return locationFrom(input.path)
default:
return []
}
}
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly cwd?: string
}): ToolCall {
return {
toolCallId: input.toolCallId,
title: toolTitle(input.toolName, input.state.input, input.state.title),
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly content?: ToolContent
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
...(input.content?.length ? { content: toolContent(input.content) } : {}),
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly result?: unknown
}): ToolCallUpdate {
const normalized = toolContent(input.content)
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
const primary =
read === undefined
? normalized.filter((part) => !images.includes(part))
: [{ type: "content" as const, content: { type: "text" as const, text: read } }]
const oldText = stringValue(input.input.oldString)
const newText = stringValue(input.input.newString)
const diff: ToolCallContent[] =
oldText === undefined || newText === undefined
? []
: [
{
type: "diff",
path: stringValue(input.input.path) ?? stringValue(input.input.filePath) ?? "",
oldText,
newText,
},
]
return {
toolCallId: input.toolCallId,
status: "completed",
content: [...primary, ...diff, ...images],
rawOutput: {
structured: input.structured,
...(input.result === undefined ? {} : { result: input.result }),
},
}
}
export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
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 },
}
}
function toolContent(content: ToolContent): ToolCallContent[] {
return content.flatMap((part): ToolCallContent[] => {
if (part.type === "text") return [{ type: "content", content: { type: "text", text: part.text } }]
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/.exec(part.uri)
if (!match?.[1]?.startsWith("image/") || match[2] === undefined) return []
return [{ type: "content", content: { type: "image", mimeType: match[1], data: match[2] } }]
})
}
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
}
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
if (!isShell(toolName) || input.cwd || input.workdir) return input
const workdir = shellWorkdir(input, cwd)
return workdir ? { ...input, cwd: workdir } : input
}
function shellWorkdir(input: ToolInput, cwd?: string) {
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
if (!explicit) return cwd
return isAbsolute(explicit) ? explicit : resolve(cwd ?? process.cwd(), explicit)
}
function isShell(toolName: string) {
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}
function locationFrom(...values: unknown[]): ToolCallLocation[] {
return Array.from(
new Set(
values.flatMap((value): string[] => {
if (Array.isArray(value))
return value.filter((item): item is string => typeof item === "string" && item.length > 0)
const path = stringValue(value)
return path ? [path] : []
}),
),
(path) => ({ path }),
)
}
export function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPTool from "./tool"
+2
View File
@@ -32,8 +32,10 @@ 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" }),
Spec.make("api", {
description: "Make a request to the running server",
params: {
+36
View File
@@ -0,0 +1,36 @@
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect } from "effect"
import { ACP } from "../../acp/agent"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Standalone } from "../../services/standalone"
export default Runtime.handler(
Commands.commands.acp,
Effect.fn("cli.acp")(function* () {
process.env.OPENCODE_CLIENT = "acp"
const endpoint = yield* Standalone.start()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const input = new WritableStream<Uint8Array>({
write: (chunk) =>
new Promise<void>((resolve, reject) => {
process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()))
}),
})
const output = new ReadableStream<Uint8Array>({
start(controller) {
process.stdin.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)))
process.stdin.on("end", () => controller.close())
process.stdin.on("error", (error) => controller.error(error))
},
})
const stream = ndJsonStream(input, output)
const connection = new AgentSideConnection((connection) => ACP.create(client, connection), stream)
process.stdin.resume()
yield* Effect.promise(() => connection.closed)
// EOF owns this stdio process; exiting also closes the private server's lease pipe.
yield* Effect.sync(() => process.exit(0))
}),
)
@@ -1,7 +1,7 @@
import { Cause, Effect, Exit, Option } from "effect"
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { AppProcess } from "@opencode-ai/core/process"
import { AppProcess } from "@opencode-ai/util/process"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
+10 -4
View File
@@ -1,5 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -8,7 +8,8 @@ import { Context, Effect, FileSystem, Option } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/core/npm"
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()),
@@ -3,7 +3,7 @@ import path from "node:path"
import { readFile, stat, writeFile } from "node:fs/promises"
import { Effect, Option } from "effect"
import { applyEdits, modify } from "jsonc-parser"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
@@ -32,6 +32,9 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
tuiConfig: resolved,
config: {
update: (update) => runServicePromise(config.update(update)),
},
}),
)
}),
+1 -1
View File
@@ -1,6 +1,6 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
+2 -2
View File
@@ -1,10 +1,10 @@
import { Effect, FileSystem, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
+13 -10
View File
@@ -4,17 +4,18 @@ import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/core/observability"
import { Observability } from "@opencode-ai/util/observability"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
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"
import { Config } from "./config"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
connect: () => import("./commands/handlers/auth/connect"),
@@ -51,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),
@@ -73,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),
+1 -1
View File
@@ -1,6 +1,6 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
+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 {
+210 -24
View File
@@ -5,6 +5,7 @@ import type {
LocationRef,
OpenCodeClient,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
@@ -75,6 +76,9 @@ export async function runNonInteractivePrompt(input: Input) {
const messageID = SessionMessage.ID.create()
const starts = new Map<string, StartedPart>()
const tools = new Map<string, ToolState>()
const renderedText = new Map<string, string>()
const renderedReasoning = new Map<string, string>()
const renderedTools = new Set<string>()
let submitted = false
let promoted = false
let emittedError = false
@@ -82,6 +86,8 @@ export async function runNonInteractivePrompt(input: Input) {
let formCancelled = false
let interrupted = false
let v1InvalidOutput = false
let prePromotionError: { message: string; [key: string]: unknown } | undefined
let finalizing = false
let admission: AbortController | undefined
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
@@ -104,6 +110,17 @@ export async function runNonInteractivePrompt(input: Input) {
UI.empty()
}
const writeReasoning = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
if (emit("reasoning", timestamp, { part })) return
const text = part.text.trim()
if (!text) return
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) return void process.stdout.write(line + EOL)
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
}
const flushStep = () => {
if (!pendingStep) return
const value = pendingStep
@@ -181,6 +198,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.input.promoted") {
if (event.data.inputID === messageID) {
promoted = true
prePromotionError = undefined
continue
}
}
@@ -191,7 +209,19 @@ export async function runNonInteractivePrompt(input: Input) {
) {
return
}
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 && !event.type.startsWith("session.execution.")) continue
if (event.type === "session.step.started") {
const part = {
@@ -219,12 +249,16 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.text.started") {
flushStep()
starts.set("text", { id: partID(event.id), timestamp: time })
starts.set(`text\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
id: partID(event.id),
timestamp: time,
})
continue
}
if (event.type === "session.text.ended") {
const started = starts.get("text")
starts.delete("text")
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
const started = starts.get(`text\u0000${key}`)
starts.delete(`text\u0000${key}`)
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@@ -233,18 +267,23 @@ export async function runNonInteractivePrompt(input: Input) {
text: event.data.text,
time: { start: started?.timestamp ?? time, end: time },
}
renderedText.set(key, event.data.text)
writeText(part, time)
continue
}
if (event.type === "session.reasoning.started") {
flushStep()
starts.set("reasoning", { id: partID(event.id), timestamp: time })
starts.set(`reasoning\u0000${contentKey(event.data.assistantMessageID, event.data.ordinal)}`, {
id: partID(event.id),
timestamp: time,
})
continue
}
if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get("reasoning")
starts.delete("reasoning")
const key = contentKey(event.data.assistantMessageID, event.data.ordinal)
const started = starts.get(`reasoning\u0000${key}`)
starts.delete(`reasoning\u0000${key}`)
const part = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@@ -254,17 +293,8 @@ export async function runNonInteractivePrompt(input: Input) {
metadata: event.data.state,
time: { start: started?.timestamp ?? time, end: time },
}
if (emit("reasoning", time, { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (!process.stdout.isTTY) {
process.stdout.write(line + EOL)
continue
}
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
renderedReasoning.set(key, event.data.text)
writeReasoning(part, time)
continue
}
@@ -360,6 +390,7 @@ export async function runNonInteractivePrompt(input: Input) {
},
}
tools.delete(key)
renderedTools.add(key)
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
continue
}
@@ -367,6 +398,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 structured = event.data.metadata ?? current.structured
const content = event.data.content ?? current.content
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
@@ -377,8 +410,8 @@ export async function runNonInteractivePrompt(input: Input) {
state: {
status: "error",
input: current.input,
structured: current.structured,
content: current.content,
structured,
content,
error: event.data.error,
result: event.data.result,
},
@@ -405,16 +438,17 @@ export async function runNonInteractivePrompt(input: Input) {
},
}
tools.delete(key)
renderedTools.add(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, current.content).trim())
if (toolOutputText(current.tool, content).trim())
await input.renderTool({
...tool,
state: {
status: "completed",
input: current.input,
structured: current.structured,
content: current.content,
structured,
content,
result: event.data.result,
},
})
@@ -480,6 +514,125 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const projectedMessages = async () => {
const messages: SessionMessageInfo[] = []
let cursor: string | undefined
while (true) {
const page = await input.client.message.list(
cursor
? { sessionID: input.sessionID, limit: 200, cursor }
: { sessionID: input.sessionID, limit: 200, order: "desc" },
)
for (const message of page.data) {
if (message.id === messageID) return { found: true, messages: messages.toReversed() }
messages.push(message)
}
cursor = page.cursor.next ?? undefined
if (!cursor) return { found: false, messages: [] }
}
}
const reconcile = async () => {
const projected = await projectedMessages()
for (const message of projected.messages) {
if (message.type !== "assistant") continue
const timestamp = message.time.completed ?? message.time.created
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
if (item.type === "text") {
const ordinal = textOrdinal++
const key = contentKey(message.id, ordinal)
const rendered = renderedText.get(key) ?? ""
if (rendered === item.text || !item.text.startsWith(rendered)) continue
const text = item.text.slice(rendered.length)
writeText(
{
id: projectedPartID(message.id, `text-${ordinal}`),
sessionID: input.sessionID,
messageID: message.id,
type: "text",
text,
time: { start: message.time.created, end: timestamp },
},
timestamp,
)
renderedText.set(key, item.text)
continue
}
if (item.type === "reasoning") {
const ordinal = reasoningOrdinal++
if (!input.thinking) continue
const key = contentKey(message.id, ordinal)
const rendered = renderedReasoning.get(key) ?? ""
if (rendered === item.text || !item.text.startsWith(rendered)) continue
const text = item.text.slice(rendered.length)
const part = {
id: projectedPartID(message.id, `reasoning-${ordinal}`),
sessionID: input.sessionID,
messageID: message.id,
type: "reasoning",
text,
metadata: item.state,
time: { start: message.time.created, end: timestamp },
}
renderedReasoning.set(key, item.text)
writeReasoning(part, timestamp)
continue
}
const key = toolKey(message.id, item.id)
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
const part: MiniToolPart = {
id: projectedPartID(message.id, `tool-${item.id}`),
sessionID: input.sessionID,
messageID: message.id,
type: "tool",
callID: item.id,
tool: item.name,
state:
item.state.status === "completed"
? {
status: "completed",
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 },
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 },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
},
}
renderedTools.add(key)
if (emit("tool_use", timestamp, { part })) continue
if (item.state.status === "completed") {
await input.renderTool(item)
continue
}
if (toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
}
await input.renderToolError(item)
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
}
}
return {
found: projected.found,
responded: projected.messages.some((message) => message.type === "assistant"),
}
}
const interrupt = () => {
if (interrupted) process.exit(130)
interrupted = true
@@ -559,11 +712,36 @@ export async function runNonInteractivePrompt(input: Input) {
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
: []),
])
await completed
if (input.compatibility === "v1") {
await completed
return
}
const waiting = input.client.session.wait({ sessionID: input.sessionID })
await Promise.race([waiting, completed.then(() => waiting)])
finalizing = true
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
if (!emit("error", Date.now(), { error })) UI.error(error.message)
}
} finally {
process.off("SIGINT", interrupt)
controller.abort()
await stream.return?.(undefined).catch(() => {})
if (input.compatibility === "v1") await stream.return?.(undefined).catch(() => {})
else void stream.return?.(undefined).catch(() => {})
}
}
@@ -595,6 +773,14 @@ function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function contentKey(messageID: string, ordinal: number) {
return `${messageID}\u0000${ordinal}`
}
function projectedPartID(messageID: string, part: string) {
return `prt_${messageID.replace(/^msg_/, "")}_${part}`
}
function fallbackTool(event: {
id: string
created: number
+7 -46
View File
@@ -1,11 +1,10 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { FSUtil } from "@opencode-ai/util/fs-util"
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}`))
+17 -7
View File
@@ -2,10 +2,10 @@ export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
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"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
@@ -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/core/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
}
+36 -19
View File
@@ -1,6 +1,6 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Hash } from "@opencode-ai/core/util/hash"
import { Global } from "@opencode-ai/util/global"
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"
import { randomBytes } from "crypto"
@@ -24,22 +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) {
if (channel === "latest") return "service.json"
if (channel === "local") return "service-local.json"
return `service-${Hash.fast(channel)}.json`
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) {
if (channel === "latest") return 0xc0de
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 = 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
@@ -51,10 +55,9 @@ export function versionBelongsToChannel(
export const migrateRegistration = Effect.fnUntraced(function* (
legacy: string,
file: string,
channel = InstallationChannel,
installedVersion = InstallationVersion,
channel = OPENCODE_CHANNEL,
installedVersion = OPENCODE_VERSION,
) {
if (channel === "latest" || channel === "local") return
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
@@ -64,6 +67,14 @@ export const migrateRegistration = Effect.fnUntraced(function* (
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file: string) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
if (Option.isNone(yield* decodeInfo(text.value).pipe(Effect.option))) return
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password") return key
throw new Error(`Unknown service config key: ${key}`)
@@ -73,27 +84,33 @@ const paths = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const name = filename()
const legacy = legacyFilename()
const file = path.join(global.state, name)
return {
fs,
file,
legacyFile: path.join(global.state, "service.json"),
legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
legacyRegistrationFiles: [
...(legacy ? [path.join(global.state, legacy)] : []),
...(name !== "service.json" && OPENCODE_CHANNEL !== "local" ? [path.join(global.state, "service.json")] : []),
],
configFile: path.join(global.config, name),
}
})
export const options = Effect.fnUntraced(function* () {
const { file, legacyFile } = yield* paths
yield* migrateRegistration(legacyFile, file)
const { file, legacyRegistrationFiles } = yield* paths
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return {
file,
version: InstallationVersion,
version: OPENCODE_VERSION,
command: [...selfCommand(), "serve", "--service"],
}
})
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* paths
const { fs, configFile, legacyConfigFile } = yield* paths
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.catch(() => Effect.succeed({} as Info)),
+2 -2
View File
@@ -1,6 +1,6 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
@@ -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/core/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 = () =>
+13 -17
View File
@@ -1,10 +1,6 @@
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
import {
InstallationChannel,
InstallationLocal,
InstallationVersion,
} from "@opencode-ai/core/installation/version"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
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"
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "node:path"
type Message = { readonly id?: number; readonly result?: unknown; readonly error?: unknown }
const children: Bun.Subprocess[] = []
afterEach(async () => {
await Promise.all(
children.splice(0).map(async (child) => {
child.kill("SIGKILL")
await child.exited
}),
)
})
describe("acp command", () => {
test("is registered", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
})
test("initializes over ndjson and exits on stdin eof", async () => {
const child = spawn()
const stderr = new Response(child.stderr).text()
await child.stdin.write(
new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: 1,
clientCapabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}) + "\n",
),
)
await child.stdin.flush()
const response = await readMessage(child.stdout)
expect(response.id).toBe(1)
expect(response.error).toBeUndefined()
expect(response.result).toMatchObject({
protocolVersion: 1,
agentCapabilities: { loadSession: true },
agentInfo: { name: "OpenCode" },
})
await child.stdin.end()
const exitCode = await child.exited
const errorOutput = await stderr
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`)
children.splice(children.indexOf(child), 1)
}, 30_000)
})
function spawn() {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
cwd: path.join(import.meta.dir, "../.."),
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
children.push(child)
return child
}
async function readMessage(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let output = ""
while (true) {
const result = await Promise.race([
reader.read(),
Bun.sleep(20_000).then(() => {
throw new Error("timed out waiting for ACP response")
}),
])
if (result.done) throw new Error(`ACP exited before responding: ${output}`)
output += decoder.decode(result.value, { stream: true })
const newline = output.indexOf("\n")
if (newline === -1) continue
reader.releaseLock()
const message: unknown = JSON.parse(output.slice(0, newline))
if (!isMessage(message)) throw new Error(`invalid ACP response: ${output.slice(0, newline)}`)
return message
}
}
function isMessage(value: unknown): value is Message {
return typeof value === "object" && value !== null
}
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, "../.."),
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, test } from "bun:test"
import {
buildConfigOptions,
buildEffortSelectOption,
buildModeSelectOption,
buildModelSelectOption,
formatVariantName,
parseModelSelection,
type ConfigOptionProvider,
} from "../../src/acp/config-option"
const providers: ConfigOptionProvider[] = [
{
id: "anthropic",
name: "Anthropic",
models: [
{ id: "claude/sonnet-4", name: "Claude Sonnet 4", variants: ["default", "high", "very-high"] },
{ id: "claude-haiku", name: "Claude Haiku" },
],
},
{ id: "openai", name: "OpenAI", models: [{ id: "gpt-5", name: "GPT-5", variants: ["minimal", "low"] }] },
]
describe("acp config options", () => {
test("builds the model select option with ACP verifier category", () => {
expect(
buildModelSelectOption({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
}),
).toEqual({
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "anthropic/claude/sonnet-4",
options: [
{ value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" },
{ value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" },
{ value: "openai/gpt-5", name: "OpenAI/GPT-5" },
],
})
})
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: "default",
options: [
{ value: "low", name: "Low" },
{ value: "default", name: "Default" },
{ value: "high", name: "High" },
],
})
})
test("effort fallback uses the first variant when default is absent", () => {
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" }).currentValue).toBe(
"minimal",
)
})
test("builds the mode select option with descriptions when present", () => {
expect(
buildModeSelectOption({
currentModeId: "build",
modes: [
{ id: "build", name: "Build", description: "Make code changes" },
{ id: "plan", name: "Plan" },
],
}),
).toEqual({
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: "build",
options: [
{ value: "build", name: "Build", description: "Make code changes" },
{ value: "plan", name: "Plan" },
],
})
})
test("builds full config options with model, effort, and mode in stable order", () => {
const options = buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "very-high",
modes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
currentModeId: "plan",
})
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"])
expect(options[0]?.currentValue).toBe("anthropic/claude/sonnet-4")
expect(options[1]?.currentValue).toBe("very-high")
})
test("full config options omit effort for models without variants", () => {
expect(
buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude-haiku" },
}).map((option) => option.id),
).toEqual(["model"])
})
test("parses provider/model selections", () => {
expect(parseModelSelection("openai/gpt-5", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
})
})
test("parses provider/model/variant selections when the base model exposes that variant", () => {
expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
})
})
test("prefers exact slash-containing model ids before treating the tail as a variant", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
})
})
test("parses trailing variants for slash-containing model ids", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
variant: "high",
})
})
test("keeps unknown trailing segments in the model id when they are not valid variants", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" },
})
})
test("formats variant names for display", () => {
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
})
})
@@ -0,0 +1,74 @@
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import {
alternateValue,
createAcpFixture,
expectOk,
flattenSelectOptions,
initialize,
newSession,
requireSelectOption,
selectConfigOption,
} from "./subprocess"
describe("acp config option subprocess", () => {
test('model option is listed with category "model"', async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const model = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "model")
expect(model.category).toBe("model")
expect(model.currentValue).toBe("test/test-model")
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
}, 60_000)
test("model switch updates currentValue", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const model = requireSelectOption(session.configOptions, "model")
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
expect(nextModel).toBe("test/second-model")
const updated = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: nextModel,
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}, 60_000)
test('effort option is listed with category "thought_level" when selected model supports variants', async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}, 60_000)
test("effort switch updates currentValue", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const nextEffort = alternateValue(requireSelectOption(session.configOptions, "effort"))
const updated = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}, 60_000)
})
+239
View File
@@ -0,0 +1,239 @@
import { describe, expect, test } from "bun:test"
import { pathToFileURL } from "node:url"
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
describe("acp content conversion", () => {
test("plain text block becomes a text part", () => {
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
})
test("assistant-only text audience becomes synthetic", () => {
expect(
contentBlockToParts({
type: "text",
text: "internal",
annotations: { audience: ["assistant"] },
}),
).toEqual([{ type: "text", text: "internal", synthetic: true }])
})
test("user-only text audience becomes ignored", () => {
expect(
contentBlockToParts({
type: "text",
text: "visible to user",
annotations: { audience: ["user"] },
}),
).toEqual([{ type: "text", text: "visible to user", ignored: true }])
})
test("image block with base64 data becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "AAAA",
mimeType: "image/png",
uri: "file:///tmp/screenshot.png",
}),
).toEqual([
{
type: "file",
url: "data:image/png;base64,AAAA",
filename: "screenshot.png",
mime: "image/png",
},
])
})
test("image block with http URI becomes a file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/jpeg",
uri: "http://example.com/assets/photo.jpg",
}),
).toEqual([
{
type: "file",
url: "http://example.com/assets/photo.jpg",
filename: "photo.jpg",
mime: "image/jpeg",
},
])
})
test("resource_link file URL becomes a file part with name and fallback mime", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "file:///tmp/notes.txt",
name: "client-notes.txt",
}),
).toEqual([
{
type: "file",
url: "file:///tmp/notes.txt",
filename: "client-notes.txt",
mime: "text/plain",
},
])
})
test("resource_link zed path becomes a file URL part", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "zed://workspace?path=/tmp/project/src/app.ts",
name: "app.ts",
mimeType: "text/typescript",
}),
).toEqual([
{
type: "file",
url: pathToFileURL("/tmp/project/src/app.ts").href,
filename: "app.ts",
mime: "text/typescript",
},
])
})
test("resource with text becomes a sourced text part", () => {
const result = contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt#L12-L14",
mimeType: "text/plain",
text: "context",
},
})
expect(result).toHaveLength(1)
expect(result[0]?.type).toBe("text")
if (result[0]?.type === "text") {
expect(result[0].text.endsWith("\ncontext")).toBe(true)
expect(result[0].text.includes("context.txt")).toBe(true)
expect(result[0].text.includes("12")).toBe(true)
}
})
test("resource with text uses URI fallback for non-file resources", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "mcp://server/context",
text: "context",
},
}),
).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }])
})
test("resource with text includes file path", () => {
const result = contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt",
mimeType: "text/plain",
text: "context",
},
})
expect(result).toHaveLength(1)
expect(result[0]?.type).toBe("text")
if (result[0]?.type === "text") {
expect(result[0].text.endsWith("\ncontext")).toBe(true)
expect(result[0].text.includes("context.txt")).toBe(true)
}
})
test("resource with blob and mimeType becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/report.pdf",
mimeType: "application/pdf",
blob: "JVBERg==",
},
}),
).toEqual([
{
type: "file",
url: "data:application/pdf;base64,JVBERg==",
filename: "report.pdf",
mime: "application/pdf",
},
])
})
test("data URL resource is preserved as a file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "data:text/plain;base64,aGVsbG8=",
mimeType: "text/plain",
blob: "ignored",
},
}),
).toEqual([
{
type: "file",
url: "data:text/plain;base64,aGVsbG8=",
filename: "file",
mime: "text/plain",
},
])
})
test("unsupported blocks are ignored", () => {
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
expect(
promptContentToParts([
// @ts-expect-error Exercise forward compatibility with an unknown ACP content block.
{ type: "unknown", text: "skip" },
]),
).toEqual([])
})
})
describe("acp replay conversion", () => {
test("replays text audience annotations", () => {
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
{
content: {
type: "text",
text: "cached",
annotations: { audience: ["assistant"] },
},
},
])
})
test("replays file and data URL parts as ACP content", () => {
expect(
partsToContentChunks([
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
]),
).toEqual([
{
content: {
type: "resource_link",
uri: "file:///tmp/readme.md",
name: "readme.md",
mimeType: "text/markdown",
},
},
{
content: {
type: "resource",
resource: {
uri: pathToFileURL("note.txt").href,
mimeType: "text/plain",
text: "hello",
},
},
},
])
})
})
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
import { ACPError } from "../../src/acp/error"
describe("acp errors", () => {
test("maps validation failures to invalid params", () => {
const cases: ACPError.Error[] = [
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
new ACPError.InvalidEffortError({ effort: "extreme" }),
new ACPError.InvalidModeError({ mode: "turbo" }),
]
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([-32602, -32602, -32602, -32602, -32602])
})
test("includes safe validation details", () => {
expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
code: -32602,
data: { sessionId: "ses_123" },
})
expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
code: -32602,
data: { modelId: "gpt-missing" },
})
})
test("maps auth required to the SDK auth error", () => {
const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError())
expect(requestError).toBeInstanceOf(RequestError)
expect(requestError.code).toBe(-32000)
expect(requestError.message).toBe("Authentication required: provider authentication required")
expect(requestError.data).toEqual({})
})
test("maps service failures to safe internal errors", () => {
const requestError = ACPError.toRequestError(
new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
)
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Provider request failed")
expect(requestError.data).toEqual({ service: "provider" })
})
test("wraps unknown defects without leaking raw details", () => {
const requestError = ACPError.toRequestError(
ACPError.fromUnknown(new Error("stack has sk-ant-secret and oauth refresh token")),
)
const serialized = JSON.stringify(requestError.toErrorResponse())
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Internal service failure")
expect(serialized).not.toContain("sk-ant-secret")
expect(serialized).not.toContain("oauth refresh token")
expect(serialized).not.toContain("stack")
})
})
@@ -0,0 +1,686 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { resolve } from "node:path"
import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Fixture = ReturnType<typeof createSseFixture>
describe("acp event behavior", () => {
test("subscribes before admission and isolates sessions and input IDs", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_before",
ordinal: 0,
delta: "before admission",
}),
)
send(durableEvent("session.input.promoted", { sessionID: "ses_b", inputID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_wrong_input",
ordinal: 0,
delta: "wrong input",
}),
)
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: id }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_b",
assistantMessageID: "msg_b",
ordinal: 0,
delta: "other session",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_a",
ordinal: 0,
delta: "accepted",
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_a",
assistantMessageID: "msg_a",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_b" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_a" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_a",
inputID: "input_a",
})
expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
"/api/event",
"/api/session/ses_a/prompt",
])
expect(updates).toEqual([
{
sessionId: "ses_a",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_a",
content: { type: "text", text: "accepted" },
},
},
])
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("preserves text and reasoning order before returning the terminal response", async () => {
const firstUpdate = Promise.withResolvers<void>()
const releaseUpdate = Promise.withResolvers<void>()
const allUpdates = Promise.withResolvers<void>()
const releaseSubmit = Promise.withResolvers<void>()
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
async onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_order", inputID: id }))
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: "think-1",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 1,
delta: "answer",
}),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 2,
delta: "think-2",
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_order" }))
await releaseSubmit.promise
},
})
const connection = {
sessionUpdate: async (update) => {
updates.push(update)
if (updates.length === 1) {
firstUpdate.resolve()
await releaseUpdate.promise
}
if (updates.length === 3) allUpdates.resolve()
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
const result = turn({ fixture, connection, sessionID: "ses_order", inputID: "input_order" })
try {
await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
expect(updates).toHaveLength(1)
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
releaseUpdate.resolve()
await withTimeout(allUpdates.promise, "ordered updates did not finish")
expect(await Promise.race([result.then(() => "resolved"), Promise.resolve("pending")])).toBe("pending")
expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
releaseSubmit.resolve()
const response = await withTimeout(result, "turn did not resolve after admission returned")
expect(
updates.map((item) => {
if (
item.update.sessionUpdate === "agent_message_chunk" ||
item.update.sessionUpdate === "agent_thought_chunk"
) {
return [
item.update.sessionUpdate,
item.update.content.type === "text" ? item.update.content.text : undefined,
]
}
return [item.update.sessionUpdate, undefined]
}),
).toEqual([
["agent_thought_chunk", "think-1"],
["agent_message_chunk", "answer"],
["agent_thought_chunk", "think-2"],
])
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
} finally {
releaseUpdate.resolve()
releaseSubmit.resolve()
await result.catch(() => undefined)
await fixture.stop()
}
})
test("streams tool pending, progress, success, and failure updates", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_tools", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
name: "shell",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
input: { command: "printf done", workdir: "sub" },
executed: false,
}),
)
send(
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { phase: 1 },
content: [{ type: "text", text: "working" }],
}),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { exit: 0 },
content: [{ type: "text", text: "done" }],
result: { code: 0 },
executed: true,
}),
)
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
name: "read",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
input: { filePath: "/workspace/missing.ts" },
executed: false,
}),
)
send(
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
structured: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
}),
)
send(
durableEvent("session.tool.failed", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
executed: true,
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_tools",
inputID: "input_tools",
})
expect(
updates.map((item) => [
item.update.sessionUpdate,
"status" in item.update ? item.update.status : undefined,
"toolCallId" in item.update ? item.update.toolCallId : undefined,
]),
).toEqual([
["tool_call", "pending", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "in_progress", "call_ok"],
["tool_call_update", "completed", "call_ok"],
["tool_call", "pending", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "in_progress", "call_fail"],
["tool_call_update", "failed", "call_fail"],
])
expect(updates[1]?.update).toMatchObject({
title: "printf done",
kind: "execute",
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[3]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
})
expect(updates[7]?.update).toMatchObject({
kind: "read",
locations: [{ path: "/workspace/missing.ts" }],
content: [
{ type: "content", content: { type: "text", text: "opening" } },
{ type: "content", content: { type: "text", text: "not found" } },
],
rawOutput: { structured: { bytes: 0 }, error: "not found" },
})
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("replays user, text, reasoning, and tool messages in order", async () => {
const updates: SessionUpdateParams[] = []
const messages = replayFixtureMessages()
const connection = {
sessionUpdate: async (update) => {
updates.push(update)
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
await replayMessages(connection, "ses_replay", "/workspace", messages)
expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
"user_message_chunk",
"user_message_chunk",
"user_message_chunk",
"agent_message_chunk",
"agent_thought_chunk",
"tool_call",
"tool_call_update",
"tool_call",
"tool_call_update",
"tool_call",
"tool_call_update",
"tool_call",
])
expect(updates[1]?.update).toMatchObject({
content: {
type: "resource_link",
uri: "file:///workspace/note.md",
name: "note.md",
mimeType: "text/markdown",
},
})
expect(updates[2]?.update).toMatchObject({
content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
})
expect(updates[6]?.update).toMatchObject({
toolCallId: "call_done",
status: "completed",
content: [
{ type: "content", content: { type: "text", text: "done" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
})
expect(updates[8]?.update).toMatchObject({
toolCallId: "call_running",
status: "in_progress",
title: "pwd",
locations: [{ path: "/workspace" }],
})
expect(updates[10]?.update).toMatchObject({
toolCallId: "call_failed",
status: "failed",
content: [
{ type: "content", content: { type: "text", text: "partial" } },
{ type: "content", content: { type: "text", text: "failed hard" } },
],
})
})
test("continues replay after a session update callback rejects", async () => {
const attempts: Array<[string, string]> = []
const connection = {
sessionUpdate: async (params) => {
if (params.update.sessionUpdate !== "tool_call" && params.update.sessionUpdate !== "tool_call_update") return
attempts.push([params.update.toolCallId, params.update.sessionUpdate])
if (params.update.toolCallId === "call_first" && params.update.sessionUpdate === "tool_call_update") {
throw new Error("replay send failed")
}
},
} satisfies Pick<AgentSideConnection, "sessionUpdate">
await replayMessages(connection, "ses_replay_failure", "/workspace", [
replayToolMessage("call_first"),
replayToolMessage("call_after"),
])
expect(attempts).toEqual([
["call_first", "tool_call"],
["call_first", "tool_call_update"],
["call_after", "tool_call"],
["call_after", "tool_call_update"],
])
})
test("returns cancelled after an admitted turn is interrupted", async () => {
const submitted = Promise.withResolvers<void>()
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_cancel", inputID: id }))
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
},
})
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionID: "ses_cancel",
cwd: "/workspace",
start: { type: "input", id: "input_cancel" },
control,
submit: async (signal) => {
await fixture.client.session.prompt(
{ sessionID: "ses_cancel", id: "input_cancel", text: "cancel me" },
{ signal },
)
submitted.resolve()
},
})
try {
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
control.cancelled = true
control.admission.abort()
await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
const response = await withTimeout(result, "cancelled turn did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
} finally {
await fixture.stop()
}
})
test("returns cancelled when admission is aborted before promotion", async () => {
const submitted = Promise.withResolvers<void>()
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ signal }) {
submitted.resolve()
return new Promise<void>((resolve) => {
if (signal.aborted) return resolve()
signal.addEventListener("abort", () => resolve(), { once: true })
})
},
})
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionID: "ses_cancel_admission",
cwd: "/workspace",
start: { type: "input", id: "input_cancel_admission" },
control,
submit: (signal) =>
fixture.client.session.prompt(
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
{ signal },
),
})
try {
await withTimeout(submitted.promise, "cancel test prompt was not submitted")
control.cancelled = true
control.admission.abort()
const response = await withTimeout(result, "pre-admission cancellation did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
} finally {
control.cancelled = true
control.admission.abort()
await result.catch(() => undefined)
await fixture.stop()
}
})
test("cancels unsupported session forms so execution can continue", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
send(
ephemeralEvent("form.created", {
form: {
id: "frm_question",
sessionID: "ses_form",
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", title: "Choice", type: "string" }],
},
}),
)
},
onFormCancel({ sessionID, formID, send }) {
send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
send(durableEvent("session.execution.succeeded", { sessionID }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection([]),
sessionID: "ses_form",
inputID: "input_form",
})
expect(response.stopReason).toBe("end_turn")
expect(
fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
).toBe(true)
} finally {
await fixture.stop()
}
})
})
function recordingConnection(updates: SessionUpdateParams[]) {
return {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
}
function turn(input: {
readonly fixture: Fixture
readonly connection: Connection
readonly sessionID: string
readonly inputID: string
}) {
return streamTurn({
client: input.fixture.client,
connection: input.connection,
sessionID: input.sessionID,
cwd: "/workspace",
start: { type: "input", id: input.inputID },
userMessageID: `client_${input.inputID}`,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
})
}
function tokens() {
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
}
function replayFixtureMessages(): SessionMessageInfo[] {
return [
{
id: "msg_user",
type: "user",
text: "hello",
time: { created: 1 },
files: [
{
data: "",
mime: "text/markdown",
name: "note.md",
source: { type: "uri", uri: "file:///workspace/note.md" },
},
{
data: "aGVsbG8=",
mime: "text/plain",
name: "inline.txt",
source: { type: "inline" },
},
],
},
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 2, completed: 3 },
content: [
{ type: "text", text: "answer" },
{ type: "reasoning", text: "thinking" },
{
type: "tool",
id: "call_done",
name: "shell",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
content: [
{ type: "text", text: "done" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
],
result: { code: 0 },
},
},
{
type: "tool",
id: "call_running",
name: "shell",
time: { created: 2, ran: 2 },
state: {
status: "running",
input: { command: "pwd" },
structured: {},
content: [{ type: "text", text: "/workspace" }],
},
},
{
type: "tool",
id: "call_failed",
name: "read",
time: { created: 2, completed: 3 },
state: {
status: "error",
input: { filePath: "/workspace/missing.ts" },
structured: { bytes: 0 },
content: [{ type: "text", text: "partial" }],
error: { type: "tool.error", message: "failed hard" },
},
},
{
type: "tool",
id: "call_streaming",
name: "shell",
time: { created: 2 },
state: { status: "streaming", input: '{"command":' },
},
],
},
]
}
function replayToolMessage(id: string) {
return {
id: `msg_${id}`,
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 1, completed: 2 },
content: [
{
type: "tool",
id,
name: "shell",
time: { created: 1, completed: 2 },
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
content: [{ type: "text", text: "done" }],
},
},
],
} satisfies SessionMessageInfo
}
+124
View File
@@ -0,0 +1,124 @@
import { expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { streamTurn } from "../../src/acp/event"
test("acp prompt resolves after ordered turn updates", async () => {
const encoder = new TextEncoder()
let events: ReadableStreamDefaultController<Uint8Array> | undefined
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
events = controller
send(controller, { id: "evt_connected", type: "server.connected", data: {} })
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
if (url.pathname === "/api/session/ses_test/prompt") {
const body: unknown = await request.json()
if (!body || typeof body !== "object") {
return new Response(null, { status: 400 })
}
const id = Reflect.get(body, "id")
if (typeof id !== "string") return new Response(null, { status: 400 })
queueMicrotask(() => {
if (!events) return
send(events, {
id: "evt_promoted",
created: 1,
type: "session.input.promoted",
data: { sessionID: "ses_test", inputID: id },
})
send(events, {
id: "evt_text",
created: 2,
type: "session.text.delta",
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hello" },
})
send(events, {
id: "evt_step",
created: 3,
type: "session.step.ended",
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
send(events, {
id: "evt_done",
created: 4,
type: "session.execution.succeeded",
data: { sessionID: "ses_test" },
})
})
return Response.json({ data: {} })
}
if (url.pathname === "/api/session/ses_test/message/msg_assistant") {
return Response.json({
data: {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "hello" }],
finish: "stop",
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 4 },
},
})
}
return new Response(null, { status: 404 })
},
})
const client = OpenCode.make({ baseUrl: server.url.toString() })
try {
const id = "msg_prompt"
const userMessageID = "client-message"
const response = await streamTurn({
client,
connection: {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id },
userMessageID,
control: { cancelled: false, admission: new AbortController() },
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
})
expect(updates).toEqual([
{
sessionId: "ses_test",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hello" },
},
},
])
expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } })
} finally {
events?.close()
await server.stop(true)
}
function send(controller: ReadableStreamDefaultController<Uint8Array>, event: unknown) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
})
@@ -0,0 +1,49 @@
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize } from "./subprocess"
describe("acp initialize/auth subprocess", () => {
test("initialize responds with capabilities", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
}, 60_000)
test("auth negotiation is explicit and safe", async () => {
await using fixture = await createAcpFixture()
const secret = "subprocess-auth-secret"
const acp = fixture.spawn({ OPENCODE_AUTH_CONTENT: secret })
const initialized = await initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
expect(expectOk(await acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" }))).toEqual(
{},
)
const rejected = await acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expect(rejected.error?.code).toBe(-32602)
expect(JSON.stringify(rejected.error)).not.toContain(secret)
}, 60_000)
test("initialize without terminal-auth metadata keeps auth command implicit", async () => {
await using fixture = await createAcpFixture()
const initialized = expectOk(
await fixture.spawn().request<InitializeResponse>("initialize", { protocolVersion: 1 }),
)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
}, 60_000)
})
@@ -0,0 +1,85 @@
import type {
CloseSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
} from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption } from "./subprocess"
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
await using fixture = await createAcpFixture()
expect(await fixture.spawn().close()).toBe(0)
}, 60_000)
test("close capability and close request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
const initialized = await initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = await newSession(acp, fixture.home)
expect(
expectOk(await acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId })),
).toEqual({})
}, 60_000)
test("new session succeeds on the first request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
expect((await newSession(acp, fixture.home)).sessionId).toStartWith("ses_")
}, 60_000)
test("loadSession capability and load request return session config options", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
const initialized = await initialize(acp)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
const session = await newSession(acp, fixture.home)
const loaded = expectOk(
await acp.request<LoadSessionResponse>("session/load", {
cwd: fixture.home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
}, 60_000)
test("list request includes a live ACP-created session", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}, 60_000)
test("resume capability advertisement", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}, 60_000)
test("resume request returns session config options", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const resumed = expectOk(
await acp.request<ResumeSessionResponse>("session/resume", {
cwd: fixture.home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}, 60_000)
})
@@ -0,0 +1,499 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { streamTurn } from "../../src/acp/event"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Fixture = ReturnType<typeof createSseFixture>
describe("acp permission behavior", () => {
test("forwards allow-once and allow-always selections to the generated client", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_allow", inputID: id }))
send(
permissionAsked("ses_allow", "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
}),
)
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
metadata: { filePath: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return {
outcome: {
outcome: "selected",
optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always",
},
}
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_allow", "input_allow")
expect(permissionRequests[0]).toMatchObject({
sessionId: "ses_allow",
toolCall: {
toolCallId: "call_once",
status: "pending",
title: "printf hello",
kind: "execute",
locations: [{ path: "/workspace" }],
rawInput: { command: "printf hello", cwd: "/workspace" },
},
options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
],
})
expect(permissionRequests[1]).toMatchObject({
sessionId: "ses_allow",
toolCall: {
toolCallId: "call_always",
status: "pending",
title: "/workspace/file.ts",
kind: "read",
locations: [{ path: "/workspace/file.ts" }],
rawInput: { filePath: "/workspace/file.ts" },
},
})
expect(permissionReplies(fixture)).toEqual([
["perm_once", "once"],
["perm_always", "always"],
])
} finally {
await fixture.stop()
}
})
test("preserves external directory permission context", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_external", inputID: id }))
send(
permissionAsked("ses_external", "perm_external", {
action: "external_directory",
metadata: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_external" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_external", "input_external")
expect(permissionRequests[0]?.toolCall).toMatchObject({
title: "Create external directory",
locations: [{ path: "/tmp/outside" }],
rawInput: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
})
} finally {
await fixture.stop()
}
})
test("previews edits during approval and syncs the completed file", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
const file = path.join(cwd, "file.ts")
await fs.writeFile(file, "before")
const permissionRequests: RequestPermissionRequest[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
name: "edit",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
input: { path: "file.ts", oldString: "before", newString: "after" },
executed: false,
}),
)
send(
permissionAsked("ses_edit", "perm_edit", {
action: "edit",
source: { type: "tool", messageID: "msg_edit", callID: "call_edit" },
}),
)
},
async onPermissionReply({ send }) {
await fs.writeFile(file, "after")
send(
durableEvent("session.tool.success", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
structured: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_edit" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
writeTextFile: async (request) => {
writes.push(request)
return {}
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_edit", "input_edit", cwd)
expect(permissionRequests[0]?.toolCall).toMatchObject({
title: "file.ts",
kind: "edit",
locations: [{ path: "file.ts" }],
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
})
expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
} finally {
await fixture.stop()
await fs.rm(cwd, { recursive: true, force: true })
}
})
test("previews and syncs each file in a patch", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
await Promise.all([
fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
fs.writeFile(path.join(cwd, "second.ts"), "alpha\n"),
])
const patchText = [
"*** Begin Patch",
"*** Update File: first.ts",
"@@",
"-one",
"+two",
"*** Update File: second.ts",
"@@",
"-alpha",
"+beta",
"*** End Patch",
].join("\n")
const permissionRequests: RequestPermissionRequest[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
name: "patch",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
input: { patchText },
executed: false,
}),
)
send(
permissionAsked("ses_patch", "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", callID: "call_patch" },
}),
)
},
async onPermissionReply({ send }) {
await Promise.all([
fs.writeFile(path.join(cwd, "first.ts"), "two\n"),
fs.writeFile(path.join(cwd, "second.ts"), "beta\n"),
])
send(
durableEvent("session.tool.success", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_patch" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
writeTextFile: async (request) => {
writes.push(request)
return {}
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_patch", "input_patch", cwd)
expect(permissionRequests[0]?.toolCall).toMatchObject({
title: "2 files",
kind: "edit",
locations: [{ path: "first.ts" }, { path: "second.ts" }],
content: [
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
],
})
expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
{ sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
{ sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
])
} finally {
await fixture.stop()
await fs.rm(cwd, { recursive: true, force: true })
}
})
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_reject", inputID: id }))
send(permissionAsked("ses_reject", "perm_selected_reject"))
send(permissionAsked("ses_reject", "perm_cancelled"))
send(permissionAsked("ses_reject", "perm_failed"))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_reject" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request): Promise<RequestPermissionResponse> => {
if (request.toolCall.toolCallId === "perm_selected_reject") {
return { outcome: { outcome: "selected", optionId: "reject" } }
}
if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
throw new Error("client permission UI failed")
},
} satisfies Connection
try {
const response = await startTurn(fixture, connection, "ses_reject", "input_reject")
expect(response).toMatchObject({ stopReason: "end_turn" })
expect(permissionReplies(fixture)).toEqual([
["perm_selected_reject", "reject"],
["perm_cancelled", "reject"],
["perm_failed", "reject"],
])
} finally {
await fixture.stop()
}
})
test("serializes permission requests and replies within one session", async () => {
const firstRequested = Promise.withResolvers<void>()
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_serial", inputID: id }))
send(permissionAsked("ses_serial", "perm_1"))
send(permissionAsked("ses_serial", "perm_2"))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
if (request.toolCall.toolCallId === "perm_1") {
firstRequested.resolve()
return releaseFirst.promise
}
return { outcome: { outcome: "selected", optionId: "always" } } as const
},
} satisfies Connection
const result = startTurn(fixture, connection, "ses_serial", "input_serial")
try {
await withTimeout(firstRequested.promise, "first permission was not requested")
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
expect(permissionReplies(fixture)).toEqual([])
releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
await withTimeout(result, "serialized permission turn did not finish")
expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
expect(permissionReplies(fixture)).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
} finally {
releaseFirst.resolve({ outcome: { outcome: "cancelled" } })
await result.catch(() => undefined)
await fixture.stop()
}
})
test("does not let one session's blocked permission stall another session", async () => {
const blockedRequested = Promise.withResolvers<void>()
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
const promptIDs = new Map<string, string>()
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ sessionID, id, send }) {
promptIDs.set(sessionID, id)
if (promptIDs.size !== 2) return
const blockedID = promptIDs.get("ses_blocked")
const freeID = promptIDs.get("ses_free")
if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
send(durableEvent("session.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
send(permissionAsked("ses_blocked", "perm_blocked"))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_free",
assistantMessageID: "msg_free",
ordinal: 0,
delta: "session B continued",
}),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_free",
assistantMessageID: "msg_free",
finish: "stop",
cost: 0,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_free" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_blocked" }))
},
})
const connection = {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => {
blockedRequested.resolve()
return releaseBlocked.promise
},
} satisfies Connection
const blocked = startTurn(fixture, connection, "ses_blocked", "input_blocked")
const free = startTurn(fixture, connection, "ses_free", "input_free")
try {
await withTimeout(blockedRequested.promise, "blocked permission was not requested")
const response = await withTimeout(free, "free session was stalled by another session's permission")
expect(response).toMatchObject({ stopReason: "end_turn" })
expect(updates).toContainEqual({
sessionId: "ses_free",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_free",
content: { type: "text", text: "session B continued" },
},
})
expect(permissionReplies(fixture)).toEqual([])
releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
await withTimeout(blocked, "blocked session did not resume after permission selection")
expect(permissionReplies(fixture)).toEqual([["perm_blocked", "once"]])
} finally {
releaseBlocked.resolve({ outcome: { outcome: "cancelled" } })
await Promise.all([blocked.catch(() => undefined), free.catch(() => undefined)])
await fixture.stop()
}
})
})
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
return streamTurn({
client: fixture.client,
connection,
sessionID,
cwd,
start: { type: "input", id: inputID },
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
})
}
function permissionAsked(
sessionID: string,
id: string,
input: {
readonly action?: string
readonly metadata?: Record<string, unknown>
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
} = {},
) {
return ephemeralEvent("permission.v2.asked", {
id,
sessionID,
action: input.action ?? "shell",
resources: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
...(input.source ? { source: input.source } : {}),
})
}
function permissionReplies(fixture: Fixture) {
return fixture.requests.flatMap((request): Array<[string, string]> => {
const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
if (!match?.[1] || !request.body || typeof request.body !== "object") return []
const reply = Reflect.get(request.body, "reply")
return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
})
}
@@ -0,0 +1,62 @@
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("acp prompt content subprocess", () => {
test("accepts embedded text resource image and file resource link prompt content", async () => {
await using fixture = await createAcpFixture()
await Bun.write(path.join(fixture.home, "README.md"), "# ACP content smoke\n")
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this embedded resource." },
{
type: "resource",
resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
},
],
}),
)
expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this image." },
{
type: "image",
mimeType: "image/png",
data: tinyPng,
},
],
}),
)
const linked = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this linked file." },
{
type: "resource_link",
uri: pathToFileURL(path.join(fixture.home, "README.md")).href,
name: "README.md",
mimeType: "text/markdown",
},
],
}),
)
expect(linked.stopReason).toBe("end_turn")
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(3)
}, 60_000)
})
@@ -0,0 +1,269 @@
import { describe, expect, test } from "bun:test"
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
describe("acp service directory behavior", () => {
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
let created = 0
await using fixture = makeACPFixture({
fetch(request) {
if (request.method !== "POST" || request.path !== "/api/session") return undefined
created++
return Response.json({
data: makeSession(`ses_${created}`, { cwd: created <= 2 ? "/workspace" : "/other" }),
})
},
})
const first = await Promise.all([
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
])
const other = await fixture.service.newSession({ cwd: "/other", mcpServers: [] })
expect(first.map((session) => session.sessionId).toSorted()).toEqual(["ses_1", "ses_2"])
expect(other.sessionId).toBe("ses_3")
expect(currentValue(first[0], "model")).toBe("test/test-model")
expect(currentValue(first[0], "mode")).toBe("build")
expect(
["/api/model", "/api/model/default", "/api/agent", "/api/command", "/api/skill"].map((path) =>
fixture.requests
.filter((request) => request.path === path)
.map((request) => request.query["location[directory]"]),
),
).toEqual([
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
])
expect(
fixture.requests
.filter((request) => request.method === "POST" && request.path === "/api/session")
.map((request) => request.body),
).toEqual([
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/workspace" },
},
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/workspace" },
},
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/other" },
},
])
expect(
fixture.updates.map((item) =>
item.update.sessionUpdate === "available_commands_update"
? item.update.availableCommands.map((command) => command.name)
: [],
),
).toEqual([
["review", "verify"],
["review", "verify"],
["review", "verify"],
])
})
test("does not cache a failed catalog load", async () => {
let modelCalls = 0
await using fixture = makeACPFixture({
fetch(request) {
if (request.path === "/api/model") {
modelCalls++
if (modelCalls === 1) {
return Response.json(
{ name: "ModelsNotReadyError", data: { message: "catalog is warming" } },
{ status: 503 },
)
}
}
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_retry") })
}
return undefined
},
})
const failure = await fixture.service
.newSession({ cwd: "/workspace", mcpServers: [] })
.catch((error: unknown) => error)
expect(failure).toMatchObject({ name: "ModelsNotReadyError" })
const retried = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(retried.sessionId).toBe("ses_retry")
expect(modelCalls).toBe(2)
})
test("switches model, effort, and mode against the warm catalog", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_config") })
}
if (
request.method === "POST" &&
(request.path === "/api/session/ses_config/model" || request.path === "/api/session/ses_config/agent")
) {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const selectedModel = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
const selectedEffort = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "medium",
})
const selectedMode = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
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")
expect(currentValue(selectedModel, "effort")).toBe("low")
expect(currentValue(selectedEffort, "effort")).toBe("medium")
expect(currentValue(selectedMode, "mode")).toBe("plan")
expect(
fixture.requests
.filter((request) => request.path === "/api/session/ses_config/model")
.map((request) => request.body),
).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
.filter((request) => request.path === "/api/session/ses_config/agent")
.map((request) => request.body),
).toEqual([{ agent: "plan" }, { agent: "build" }])
expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(1)
const invalidEffort = await fixture.service
.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "maximum",
})
.catch((error: unknown) => error)
const invalidMode = await fixture.service
.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "missing",
})
.catch((error: unknown) => error)
const invalidConfig = await fixture.service
.setSessionConfigOption({
sessionId: session.sessionId,
configId: "missing",
value: "value",
})
.catch((error: unknown) => error)
expect(invalidEffort).toMatchObject({ _tag: "ACPInvalidEffortError" })
expect(invalidMode).toMatchObject({ _tag: "ACPInvalidModeError" })
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
})
test("converts MCP configs and deduplicates registrations per session and config", async () => {
const local: McpServer = {
name: "tools",
command: "bun",
args: ["server.ts"],
env: [{ name: "TOKEN", value: "x" }],
}
const changed: McpServer = { ...local, args: ["changed.ts"] }
const remote: McpServer = {
type: "http",
name: "docs",
url: "https://example.com/mcp",
headers: [{ name: "Authorization", value: "Bearer x" }],
}
let created = 0
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
created++
return Response.json({ data: makeSession(`ses_${created}`) })
}
if (request.method === "GET" && request.path === "/api/session/ses_1") {
return Response.json({ data: makeSession("ses_1") })
}
if (request.method === "PUT" && request.path.startsWith("/api/mcp/")) {
return new Response(null, { status: 204 })
}
return undefined
},
})
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, local, remote] })
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith("/api/mcp/"))
expect(adds).toHaveLength(4)
expect(adds.filter((request) => request.path === "/api/mcp/tools").map((request) => request.body)).toEqual([
{
config: {
type: "local",
command: ["bun", "server.ts"],
environment: { TOKEN: "x" },
},
},
{
config: {
type: "local",
command: ["bun", "changed.ts"],
environment: { TOKEN: "x" },
},
},
{
config: {
type: "local",
command: ["bun", "server.ts"],
environment: { TOKEN: "x" },
},
},
])
expect(adds.find((request) => request.path === "/api/mcp/docs")?.body).toEqual({
config: {
type: "remote",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer x" },
oauth: false,
},
})
expect(adds.map((request) => request.query)).toEqual([
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
])
})
})
function currentValue(
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
id: string,
) {
return result?.configOptions?.find((option) => option.id === id)?.currentValue
}
+207
View File
@@ -0,0 +1,207 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import {
OpenCode,
type AgentInfo,
type CommandInfo,
type ModelInfo,
type ModelRef,
type SessionInfo,
type SkillInfo,
type TokenUsageInfo,
} from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service"
export type FixtureRequest = {
readonly method: string
readonly path: string
readonly query: Record<string, string>
readonly body: unknown
}
export type FixtureContext = {
readonly requests: FixtureRequest[]
send(event: unknown): void
}
type FixtureHandler = (
request: FixtureRequest,
context: FixtureContext,
) => Response | undefined | Promise<Response | undefined>
type FixtureOptions = {
readonly fetch?: FixtureHandler
readonly models?: readonly ModelInfo[]
readonly defaultModel?: ModelInfo
readonly agents?: readonly AgentInfo[]
readonly commands?: readonly CommandInfo[]
readonly skills?: readonly SkillInfo[]
}
export const testModel = {
id: "test-model",
modelID: "test-model",
providerID: "test",
name: "Test Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "default" }, { id: "high" }],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 100_000, output: 10_000 },
} satisfies ModelInfo
export const secondModel = {
id: "second-model",
modelID: "second-model",
providerID: "test",
name: "Second Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "low" }, { id: "medium" }],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 200_000, output: 20_000 },
} satisfies ModelInfo
export const buildAgent = {
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
export const planAgent = {
id: "plan",
name: "Plan",
description: "Plan first",
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
export const reviewCommand = {
name: "review",
description: "Review changes",
template: "",
} satisfies CommandInfo
export const verifySkill = {
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
} satisfies SkillInfo
export function makeSession(
id: string,
input: {
readonly cwd?: string
readonly agent?: string
readonly model?: ModelRef
readonly cost?: number
readonly tokens?: TokenUsageInfo
readonly time?: SessionInfo["time"]
readonly title?: string
} = {},
): SessionInfo {
return {
id,
projectID: "global",
agent: input.agent ?? "build",
model: input.model ?? { providerID: "test", id: "test-model", variant: "default" },
cost: input.cost ?? 0,
tokens: input.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: input.time ?? { created: 0, updated: 0 },
title: input.title ?? `Session ${id}`,
location: { directory: input.cwd ?? "/workspace" },
}
}
export function makeACPFixture(options: FixtureOptions = {}) {
const requests: FixtureRequest[] = []
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const encoder = new TextEncoder()
let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
const models = options.models ?? [testModel, secondModel]
const context: FixtureContext = {
requests,
send(event) {
if (!eventController) throw new Error("ACP fixture has no active event stream")
eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
},
}
const server = Bun.serve({
port: 0,
async fetch(raw) {
const url = new URL(raw.url)
const request: FixtureRequest = {
method: raw.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
body: raw.method === "GET" || raw.method === "HEAD" ? undefined : await raw.json().catch(() => undefined),
}
requests.push(request)
const response = await options.fetch?.(request, context)
if (response) return response
const directory = request.query["location[directory]"] ?? "/workspace"
const location = { directory, project: { id: "global", directory } }
if (request.path === "/api/event") {
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
return new Response(
new ReadableStream<Uint8Array>({
start(value) {
controller = value
eventController = value
context.send({ id: "evt_connected", type: "server.connected", data: {} })
},
cancel() {
if (eventController === controller) eventController = undefined
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
if (request.path === "/api/model") return Response.json({ location, data: models })
if (request.path === "/api/model/default") {
return Response.json({ location, data: options.defaultModel ?? models[0] ?? null })
}
if (request.path === "/api/agent") {
return Response.json({ location, data: options.agents ?? [buildAgent, planAgent] })
}
if (request.path === "/api/command") {
return Response.json({ location, data: options.commands ?? [reviewCommand] })
}
if (request.path === "/api/skill") {
return Response.json({ location, data: options.skills ?? [verifySkill] })
}
return new Response(null, { status: 404 })
},
})
const service = ACPService.make({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
})
return {
service,
requests,
updates,
async [Symbol.asyncDispose]() {
eventController?.close()
await server.stop(true)
},
}
}
@@ -0,0 +1,232 @@
import { describe, expect, test } from "bun:test"
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
describe("acp service lifecycle", () => {
test("loads and forks with paginated replay while resume does not replay", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "GET" && request.path === "/api/session/ses_loaded") {
return Response.json({
data: makeSession("ses_loaded", {
cwd: "/workspace",
agent: "plan",
model: { providerID: "test", id: secondModel.id, variant: "medium" },
}),
})
}
if (request.method === "GET" && request.path === "/api/session/ses_resume") {
return Response.json({
data: makeSession("ses_resume", {
cwd: "/workspace",
agent: "plan",
model: { providerID: "test", id: secondModel.id, variant: "low" },
}),
})
}
if (request.method === "POST" && request.path === "/api/session/ses_loaded/fork") {
return Response.json({
data: makeSession("ses_fork", {
cwd: "/workspace",
agent: "plan",
model: { providerID: "test", id: secondModel.id, variant: "medium" },
}),
})
}
if (request.method === "GET" && request.path === "/api/session/ses_loaded/message") {
if (request.query.cursor === "messages-2") {
return Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
content: [{ type: "text", text: "hi there" }],
},
],
cursor: {},
})
}
return Response.json({
data: [{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } }],
cursor: { next: "messages-2" },
})
}
if (request.method === "GET" && request.path === "/api/session/ses_fork/message") {
return Response.json({
data: [{ id: "msg_fork", type: "user", text: "forked", time: { created: 2 } }],
cursor: {},
})
}
return undefined
},
})
const loaded = await fixture.service.loadSession({
cwd: "/ignored",
sessionId: "ses_loaded",
mcpServers: [],
})
const resumed = await fixture.service.resumeSession({
cwd: "/ignored",
sessionId: "ses_resume",
mcpServers: [],
})
const forked = await fixture.service.forkSession({
cwd: "/ignored",
sessionId: "ses_loaded",
mcpServers: [],
})
expect(currentValue(loaded, "model")).toBe("test/second-model")
expect(currentValue(loaded, "effort")).toBe("medium")
expect(currentValue(loaded, "mode")).toBe("plan")
expect(currentValue(resumed, "effort")).toBe("low")
expect(forked.sessionId).toBe("ses_fork")
expect(currentValue(forked, "effort")).toBe("medium")
expect(
fixture.updates.filter(
(item) =>
item.update.sessionUpdate === "user_message_chunk" || item.update.sessionUpdate === "agent_message_chunk",
),
).toEqual([
{
sessionId: "ses_loaded",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_user",
content: { type: "text", text: "hello" },
},
},
{
sessionId: "ses_loaded",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg_assistant",
content: { type: "text", text: "hi there" },
},
},
{
sessionId: "ses_fork",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_fork",
content: { type: "text", text: "forked" },
},
},
])
expect(
fixture.requests
.filter((request) => request.path.endsWith("/message"))
.map((request) => ({ path: request.path, query: request.query })),
).toEqual([
{
path: "/api/session/ses_loaded/message",
query: { limit: "200", order: "asc" },
},
{
path: "/api/session/ses_loaded/message",
query: { limit: "200", cursor: "messages-2" },
},
{
path: "/api/session/ses_fork/message",
query: { limit: "200", order: "asc" },
},
])
expect(fixture.requests).toContainEqual({
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: {},
})
})
test("lists server-backed pages and forwards cwd and cursor", async () => {
const firstPage = Array.from({ length: 100 }, (_, index) =>
makeSession(`ses_${100 - index}`, {
cwd: "/workspace",
time: { created: index, updated: 100_000 - index },
title: `Session ${100 - index}`,
}),
)
await using fixture = makeACPFixture({
fetch(request) {
if (request.method !== "GET" || request.path !== "/api/session") return undefined
if (request.query.cursor === "page-2") {
return Response.json({
data: [makeSession("ses_0", { cwd: "/workspace", time: { created: 0, updated: 1 } })],
cursor: {},
})
}
return Response.json({ data: firstPage, cursor: { next: "page-2" } })
},
})
const first = await fixture.service.listSessions({ cwd: "/workspace" })
const second = await fixture.service.listSessions({ cwd: "/workspace", cursor: first.nextCursor })
expect(first.sessions).toHaveLength(100)
expect(first.sessions[0]).toEqual({
sessionId: "ses_100",
cwd: "/workspace",
title: "Session 100",
updatedAt: new Date(100_000).toISOString(),
})
expect(first.nextCursor).toBe("page-2")
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
expect(second.nextCursor).toBeUndefined()
expect(
fixture.requests.filter((request) => request.path === "/api/session").map((request) => request.query),
).toEqual([
{ limit: "100", order: "desc", directory: "/workspace" },
{ limit: "100", order: "desc", directory: "/workspace", cursor: "page-2" },
])
})
test("cancel preserves the attachment while close removes it and interrupts best-effort", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_lifecycle") })
}
if (request.method === "POST" && request.path === "/api/session/ses_lifecycle/model") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path.endsWith("/interrupt")) {
return new Response(null, { status: 500 })
}
return undefined
},
})
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
await fixture.service.cancel({ sessionId: created.sessionId })
const updated = await fixture.service.setSessionConfigOption({
sessionId: created.sessionId,
configId: "effort",
value: "high",
})
expect(currentValue(updated, "effort")).toBe("high")
expect(await fixture.service.closeSession({ sessionId: created.sessionId })).toEqual({})
const missing = await fixture.service
.setSessionConfigOption({
sessionId: created.sessionId,
configId: "effort",
value: "default",
})
.catch((error: unknown) => error)
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: created.sessionId })
expect(await fixture.service.closeSession({ sessionId: "missing" })).toEqual({})
expect(
fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
).toEqual([
"/api/session/ses_lifecycle/interrupt",
"/api/session/ses_lifecycle/interrupt",
"/api/session/missing/interrupt",
])
})
})
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
return result.configOptions?.find((option) => option.id === id)?.currentValue
}
+264
View File
@@ -0,0 +1,264 @@
import { describe, expect, test } from "bun:test"
import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
describe("acp service prompt routing and usage", () => {
test("routes slash commands, skills, and compact through their session endpoints", async () => {
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "GET" && request.path === "/api/session/ses_routes") {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.input.promoted",
data: { sessionID: "ses_routes", inputID: id },
})
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: id.replace(/^msg_/, "evt_"),
type: "session.skill.activated",
data: { sessionID: "ses_routes", skill: "verify" },
})
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.compaction.admitted",
data: { sessionID: "ses_routes", inputID: id },
})
return Response.json({ data: {} })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
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" }],
})
expect([commandResult.stopReason, skillResult.stopReason, compactResult.stopReason]).toEqual([
"end_turn",
"end_turn",
"end_turn",
])
const command = fixture.requests.find((request) => request.path === "/api/session/ses_routes/command")
const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
id: expect.any(String),
command: "review",
arguments: "now",
files: [],
delivery: "steer",
})
expect(skill?.body).toMatchObject({ id: expect.any(String), skill: "verify" })
expect(compact?.body).toMatchObject({ id: expect.any(String) })
expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
})
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
const assistantTokens = {
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_usage") })
}
if (request.method === "POST" && request.path === "/api/session/ses_usage/model") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_usage/prompt") {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.input.promoted",
data: { sessionID: "ses_usage", inputID: id },
})
context.send({
id: "evt_step",
type: "session.step.ended",
data: {
sessionID: "ses_usage",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0.5,
tokens: assistantTokens,
},
})
context.send({
id: "evt_done",
type: "session.execution.succeeded",
data: { sessionID: "ses_usage" },
})
return Response.json({ data: {} })
}
if (request.method === "GET" && request.path === "/api/session/ses_usage/message/msg_assistant") {
return Response.json({
data: {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: secondModel.id },
content: [{ type: "text", text: "done" }],
finish: "stop",
tokens: assistantTokens,
time: { created: 1, completed: 2 },
},
})
}
if (request.method === "GET" && request.path === "/api/session/ses_usage") {
return Response.json({
data: makeSession("ses_usage", {
model: { providerID: "test", id: secondModel.id },
cost: 3.5,
tokens: { input: 120, output: 50, reasoning: 8, cache: { read: 30, write: 4 } },
}),
})
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
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,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
},
_meta: {},
})
expect(fixture.updates.filter((item) => item.update.sessionUpdate === "usage_update")).toEqual([
{
sessionId: "ses_usage",
update: {
sessionUpdate: "usage_update",
used: 171,
size: 200_000,
cost: { amount: 3.5, currency: "USD" },
},
},
])
})
test("does not fail a completed prompt when the usage refresh fails", async () => {
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_usage_failure") })
}
if (request.method === "POST" && request.path === "/api/session/ses_usage_failure/prompt") {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.input.promoted",
data: { sessionID: "ses_usage_failure", inputID: id },
})
context.send({
id: "evt_step_failure",
type: "session.step.ended",
data: {
sessionID: "ses_usage_failure",
assistantMessageID: "msg_usage_failure",
finish: "stop",
cost: 0,
tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
context.send({
id: "evt_done_failure",
type: "session.execution.succeeded",
data: { sessionID: "ses_usage_failure" },
})
return Response.json({ data: {} })
}
if (request.method === "GET" && request.path === "/api/session/ses_usage_failure/message/msg_usage_failure") {
return Response.json({
data: {
id: "msg_usage_failure",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "stop",
tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
},
})
}
if (request.method === "GET" && request.path === "/api/session/ses_usage_failure") {
return new Response(null, { status: 500 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const response = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello" }],
})
expect(response.stopReason).toBe("end_turn")
expect(fixture.updates.some((item) => item.update.sessionUpdate === "usage_update")).toBe(false)
})
})
function requestID(request: FixtureRequest) {
if (!request.body || typeof request.body !== "object") throw new Error(`missing body for ${request.path}`)
const id = Reflect.get(request.body, "id")
if (typeof id !== "string") throw new Error(`missing prompt id for ${request.path}`)
return id
}
function completeTurn(context: FixtureContext, sessionID: string, start: unknown) {
context.send(start)
context.send({
id: `evt_done_${sessionID}`,
type: "session.execution.succeeded",
data: { sessionID },
})
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service"
describe("acp service", () => {
test("creates a v2 session, registers mcp, and publishes commands", async () => {
const requests: Array<{ method: string; path: string; body?: unknown }> = []
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
requests.push({
method: request.method,
path: url.pathname,
body: request.method === "GET" ? undefined : await request.json().catch(() => undefined),
})
const location = { directory: "/workspace", project: { id: "global", directory: "/workspace" } }
if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
if (url.pathname === "/api/command")
return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/skill") return Response.json({ location, data: [skill] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/mcp/docs" && request.method === "PUT") return new Response(null, { status: 204 })
return new Response(null, { status: 404 })
},
})
const service = ACPService.make({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async (update) => {
updates.push(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
})
try {
const result = await service.newSession({
cwd: "/workspace",
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
})
expect(result.sessionId).toBe("ses_acp")
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",
path: "/api/mcp/docs",
body: {
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
},
})
expect(updates.at(-1)).toMatchObject({
sessionId: "ses_acp",
update: {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "review" }, { name: "verify", description: "Verify work" }],
},
})
} finally {
await server.stop(true)
}
})
})
const model = {
id: "test-model",
modelID: "test-model",
providerID: "test",
name: "Test Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [{ id: "default" }, { id: "high" }],
time: { released: 0 },
cost: [],
status: "active" as const,
enabled: true,
limit: { context: 100_000, output: 10_000 },
}
const agent = {
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
mode: "primary" as const,
hidden: false,
permissions: [],
}
const skill = {
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
}
const session = {
id: "ses_acp",
projectID: "global",
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "New session",
location: { directory: "/workspace" },
}
@@ -0,0 +1,24 @@
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, initialize, newSession, verifierSkill } from "./subprocess"
describe("acp skills subprocess", () => {
test("skill slash command appears through available_commands_update", async () => {
await using fixture = await createAcpFixture({ skill: verifierSkill })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const update = await acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some(
(command) => command.name === "verifier-skill" && command.description.length > 0,
),
)
expect(update.params.sessionId).toBe(session.sessionId)
}, 60_000)
})
+207
View File
@@ -0,0 +1,207 @@
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode-ai/client/promise"
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
type RequestRecord = {
readonly method: string
readonly path: string
readonly body?: unknown
}
type FixtureOptions = {
readonly onPrompt?: (input: {
readonly sessionID: string
readonly id: string
readonly body: unknown
readonly signal: AbortSignal
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onInterrupt?: (input: {
readonly sessionID: string
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onPermissionReply?: (input: {
readonly sessionID: string
readonly requestID: string
readonly reply: string
readonly body: unknown
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onFormCancel?: (input: {
readonly sessionID: string
readonly formID: string
readonly send: (event: unknown) => void
}) => void | Promise<void>
}
const ids = { next: 0 }
export function durableEvent<Type extends DurableEvent["type"]>(
type: Type,
data: Extract<DurableEvent, { type: Type }>["data"],
) {
ids.next++
return {
id: `evt_${ids.next}`,
created: ids.next,
type,
durable: { aggregateID: "test", seq: ids.next, version: 1 },
data,
}
}
export function ephemeralEvent<Type extends EphemeralEvent["type"]>(
type: Type,
data: Extract<EphemeralEvent, { type: Type }>["data"],
) {
ids.next++
return { id: `evt_${ids.next}`, created: ids.next, type, data }
}
export function createSseFixture(options: FixtureOptions = {}) {
const encoder = new TextEncoder()
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>()
const requests: RequestRecord[] = []
const messages = new Map<string, SessionMessageInfo>()
const send = (event: unknown) => {
for (const stream of streams) {
try {
stream.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
} catch {
streams.delete(stream)
}
}
}
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
const body = request.method === "GET" ? undefined : await request.json().catch(() => undefined)
requests.push({ method: request.method, path: url.pathname, ...(body === undefined ? {} : { body }) })
if (url.pathname === "/api/event") {
const state: { stream?: ReadableStreamDefaultController<Uint8Array> } = {}
return new Response(
new ReadableStream<Uint8Array>({
start(stream) {
state.stream = stream
streams.add(stream)
stream.enqueue(
encoder.encode(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`,
),
)
},
cancel() {
if (state.stream) streams.delete(state.stream)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
const prompt = /^\/api\/session\/([^/]+)\/prompt$/.exec(url.pathname)
if (prompt?.[1]) {
const id = stringField(body, "id")
if (!id) return new Response(null, { status: 400 })
await options.onPrompt?.({
sessionID: decodeURIComponent(prompt[1]),
id,
body,
signal: request.signal,
send,
})
return Response.json({ data: { text: stringField(body, "text") ?? "" } })
}
const message = /^\/api\/session\/([^/]+)\/message\/([^/]+)$/.exec(url.pathname)
if (message?.[1] && message[2]) {
const sessionID = decodeURIComponent(message[1])
const messageID = decodeURIComponent(message[2])
return Response.json({
data: messages.get(`${sessionID}/${messageID}`) ?? messages.get(messageID) ?? assistantMessage(messageID),
})
}
const permission = /^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/.exec(url.pathname)
if (permission?.[1] && permission[2]) {
const reply = stringField(body, "reply")
if (!reply) return new Response(null, { status: 400 })
await options.onPermissionReply?.({
sessionID: decodeURIComponent(permission[1]),
requestID: decodeURIComponent(permission[2]),
reply,
body,
send,
})
return new Response(null, { status: 204 })
}
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)\/cancel$/.exec(url.pathname)
if (form?.[1] && form[2]) {
await options.onFormCancel?.({
sessionID: decodeURIComponent(form[1]),
formID: decodeURIComponent(form[2]),
send,
})
return new Response(null, { status: 204 })
}
const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
if (interrupt?.[1]) {
await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
},
})
return {
client: OpenCode.make({ baseUrl: server.url.toString() }),
messages,
requests,
send,
async stop() {
for (const stream of streams) {
try {
stream.close()
} catch {}
}
streams.clear()
await server.stop(true)
},
}
}
export async function withTimeout<Value>(promise: Promise<Value>, message: string, milliseconds = 2_000) {
const timeout = Promise.withResolvers<never>()
const timer = setTimeout(() => timeout.reject(new Error(message)), milliseconds)
try {
return await Promise.race([promise, timeout.promise])
} finally {
clearTimeout(timer)
}
}
function stringField(value: unknown, key: string) {
if (!value || typeof value !== "object") return undefined
const field = Reflect.get(value, key)
return typeof field === "string" ? field : undefined
}
function assistantMessage(id: string) {
return {
id,
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "stop",
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
} satisfies SessionMessageInfo
}
+384
View File
@@ -0,0 +1,384 @@
import type {
InitializeResponse,
NewSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
} from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
type JsonRpcRequest = {
readonly jsonrpc: "2.0"
readonly id: number
readonly method: string
readonly params?: unknown
}
export type JsonRpcError = {
readonly code: number
readonly message?: string
readonly data?: unknown
}
export type JsonRpcResponse<T> = {
readonly jsonrpc: "2.0"
readonly id: number
readonly result?: T
readonly error?: JsonRpcError
}
type JsonRpcNotification<T> = {
readonly jsonrpc: "2.0"
readonly method: string
readonly params: T
}
type JsonRpcMessage = Record<string, unknown>
type Waiter = {
readonly predicate: (message: JsonRpcMessage) => boolean
readonly resolve: (message: JsonRpcMessage) => void
readonly reject: (error: Error) => void
readonly timer: ReturnType<typeof setTimeout>
}
export type AcpProcess = {
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
timeoutMs?: number,
) => Promise<JsonRpcNotification<T>>
readonly close: () => Promise<number>
readonly stderr: () => string
readonly [Symbol.asyncDispose]: () => Promise<void>
}
export const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
const skills = path.join(root, "skills")
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(config, { recursive: true })])
if (options.skill) {
await fs.mkdir(path.join(skills, "verifier-skill"), { recursive: true })
await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
}
const requests: unknown[] = []
const llm = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
return new Response("Not found", { status: 404 })
}
requests.push(await request.json().catch(() => undefined))
return new Response(completion("accepted"), {
headers: { "content-type": "text/event-stream" },
})
},
})
await Bun.write(
path.join(config, "opencode.json"),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
)
const processes = new Set<AcpProcess>()
return {
root,
home,
llm: { requests },
spawn(extraEnv: Record<string, string | undefined> = {}) {
const acp = spawnAcp({
env: {
...process.env,
HOME: root,
USERPROFILE: root,
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_DIR: config,
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_MODELS_PATH: undefined,
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
...extraEnv,
},
})
processes.add(acp)
return acp
},
async [Symbol.asyncDispose]() {
await Promise.all([...processes].map((process) => process[Symbol.asyncDispose]()))
await llm.stop(true)
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
},
}
}
export function initialize(acp: AcpProcess) {
return acp
.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
clientInfo: { name: "opencode-local-acp", version: "0.1.0" },
})
.then(expectOk)
}
export function newSession(acp: AcpProcess, cwd: string) {
return acp.request<NewSessionResponse>("session/new", { cwd, mcpServers: [] }).then(expectOk)
}
export function expectOk<T>(response: JsonRpcResponse<T>) {
if (response.error) throw new Error(`ACP request failed: ${JSON.stringify(response.error)}`)
if (response.result === undefined) throw new Error("ACP response did not include a result")
return response.result
}
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
return options?.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.id === id && option.type === "select",
)
}
export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
const option = selectConfigOption(options, id)
if (!option) throw new Error(`Missing ACP config option: ${id}`)
return option
}
export function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }>) {
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
}
export function alternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
return value
}
function verifierConfig(llmUrl: string, skills?: string) {
const model = {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: { input: 0, output: 0 },
limit: { context: 100_000, output: 10_000 },
}
return {
autoupdate: false,
model: "test/test-model",
...(skills ? { skills: [skills] } : {}),
providers: {
test: {
name: "Test",
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "test-key", baseURL: llmUrl },
models: {
"test-model": {
...model,
name: "Test Model",
variants: [{ id: "low" }, { id: "high" }],
},
"second-model": {
...model,
name: "Second Test Model",
variants: [{ id: "medium" }, { id: "max" }],
},
},
},
},
}
}
function spawnAcp(input: { readonly env: Record<string, string | undefined> }): AcpProcess {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
cwd: path.join(import.meta.dir, "../.."),
env: input.env,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const errorDecoder = new TextDecoder()
const messages: JsonRpcMessage[] = []
const waiters: Waiter[] = []
let nextID = 1
let failure: Error | undefined
let stderr = ""
let inputClosed = false
let disposed = false
const fail = (error: Error) => {
if (failure) return
failure = error
waiters.splice(0).forEach((waiter) => {
clearTimeout(waiter.timer)
waiter.reject(error)
})
}
const dispatch = (message: JsonRpcMessage) => {
const index = waiters.findIndex((waiter) => waiter.predicate(message))
if (index === -1) {
messages.push(message)
return
}
const waiter = waiters.splice(index, 1)[0]
clearTimeout(waiter.timer)
waiter.resolve(message)
}
const output = (async () => {
const reader = child.stdout.getReader()
let buffered = ""
while (true) {
const chunk = await reader.read()
if (chunk.done) break
buffered += decoder.decode(chunk.value, { stream: true })
while (true) {
const newline = buffered.indexOf("\n")
if (newline === -1) break
const line = buffered.slice(0, newline).trim()
buffered = buffered.slice(newline + 1)
if (line) dispatch(parseMessage(line))
}
}
buffered += decoder.decode()
if (buffered.trim()) dispatch(parseMessage(buffered.trim()))
fail(new Error(`ACP exited before another response${stderr ? `: ${stderr}` : ""}`))
})().catch((error) => fail(asError(error)))
const errors = (async () => {
const reader = child.stderr.getReader()
while (true) {
const chunk = await reader.read()
if (chunk.done) break
stderr += errorDecoder.decode(chunk.value, { stream: true })
}
stderr += errorDecoder.decode()
})()
const take = (predicate: (message: JsonRpcMessage) => boolean, timeoutMs: number, description: string) => {
const index = messages.findIndex(predicate)
if (index !== -1) return Promise.resolve(messages.splice(index, 1)[0])
if (failure) return Promise.reject(failure)
return new Promise<JsonRpcMessage>((resolve, reject) => {
const waiter: Waiter = {
predicate,
resolve,
reject,
timer: setTimeout(() => {
const index = waiters.indexOf(waiter)
if (index !== -1) waiters.splice(index, 1)
reject(new Error(`Timed out waiting for ${description}${stderr ? `: ${stderr}` : ""}`))
}, timeoutMs),
}
waiters.push(waiter)
})
}
return {
async request<T>(method: string, params?: unknown) {
if (inputClosed) throw new Error("ACP stdin is closed")
const id = nextID++
const request: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
await child.stdin.write(encoder.encode(`${JSON.stringify(request)}\n`))
await child.stdin.flush()
const response = await take((message) => isResponse(message) && message.id === id, 20_000, `${method} response`)
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
return response
},
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
const notification = await take(
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
timeoutMs,
`${method} notification`,
)
if (!isNotification<T>(notification)) {
throw new Error(`Invalid ACP notification: ${JSON.stringify(notification)}`)
}
return notification
},
async close() {
if (!inputClosed) {
inputClosed = true
await child.stdin.end()
}
const exitCode = await withTimeout(child.exited, 5_000, "ACP did not exit after stdin EOF")
await Promise.all([output, errors])
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${stderr}`)
return exitCode
},
stderr: () => stderr,
async [Symbol.asyncDispose]() {
if (disposed) return
disposed = true
if (child.exitCode === null) child.kill("SIGKILL")
await child.exited
await Promise.all([output, errors])
},
}
}
function parseMessage(line: string): JsonRpcMessage {
const message: unknown = JSON.parse(line)
if (!isJsonRpcMessage(message)) throw new Error(`Invalid ACP message: ${line}`)
return message
}
function isJsonRpcMessage(message: unknown): message is JsonRpcMessage {
return !!message && typeof message === "object" && !Array.isArray(message)
}
function isResponse<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcResponse<T> {
return message.jsonrpc === "2.0" && typeof message.id === "number" && !("method" in message)
}
function isNotification<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcNotification<T> {
return message.jsonrpc === "2.0" && typeof message.method === "string" && !("id" in message)
}
function asError(error: unknown) {
return error instanceof Error ? error : new Error(String(error))
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string) {
let timer: ReturnType<typeof setTimeout> | undefined
return Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
}),
]).finally(() => clearTimeout(timer))
}
function completion(text: string) {
const chunks = [
{ choices: [{ delta: { role: "assistant" }, finish_reason: null }], usage: null },
{ choices: [{ delta: { content: text }, finish_reason: null }], usage: null },
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: null },
{
choices: [],
usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 },
},
]
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
}
+292
View File
@@ -0,0 +1,292 @@
import { resolve } from "node:path"
import { describe, expect, test } from "bun:test"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
toLocations,
toToolKind,
} from "../../src/acp/tool"
describe("acp tools", () => {
test("maps OpenCode tool ids to ACP tool kinds", () => {
expect(toToolKind("bash")).toBe("execute")
expect(toToolKind("shell")).toBe("execute")
expect(toToolKind("webfetch")).toBe("fetch")
expect(toToolKind("edit")).toBe("edit")
expect(toToolKind("apply_patch")).toBe("edit")
expect(toToolKind("patch")).toBe("edit")
expect(toToolKind("write")).toBe("edit")
expect(toToolKind("grep")).toBe("search")
expect(toToolKind("glob")).toBe("search")
expect(toToolKind("context7_resolve_library_id")).toBe("search")
expect(toToolKind("context7_get_library_docs")).toBe("search")
expect(toToolKind("read")).toBe("read")
expect(toToolKind("task")).toBe("think")
expect(toToolKind("custom_tool")).toBe("other")
})
test("extracts file locations from tool input", () => {
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
{ path: "/tmp/outside" },
])
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "subdir") },
])
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
test("builds completed content with text and image attachments", () => {
const image = Buffer.from("image-data").toString("base64")
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "edit",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
content: [
{ type: "text", text: "edited /tmp/file.ts" },
{ 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: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "text", text: "edited /tmp/file.ts" },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
{
type: "content",
content: { type: "image", mimeType: "image/png", data: image },
},
])
})
test("omits edit diffs when normalized content does not contain one", () => {
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "write",
input: {
filePath: "/tmp/file.ts",
content: "created",
},
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
structured: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "text", text: "wrote /tmp/file.ts" },
},
])
})
test("uses clean structured read content instead of 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,
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
expect(
completedToolUpdate({
toolCallId: "tool-list",
toolName: "read",
input: { path: "/tmp" },
content: [],
structured: {
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
},
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
})
test("sends completed tool calls as partial updates", () => {
expect(
pendingToolCall({
toolCallId: "tool-1",
toolName: "edit",
state: {
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
},
}),
).toMatchObject({
toolCallId: "tool-1",
status: "pending",
kind: "edit",
locations: [{ path: "/tmp/file.ts" }],
rawInput: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
})
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "edit",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
content: [{ type: "text", text: "Edit applied successfully." }],
structured: { output: "Edit applied successfully." },
}),
).toEqual({
toolCallId: "tool-1",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "Edit applied successfully." },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
],
rawOutput: {
structured: { output: "Edit applied successfully." },
},
})
})
test("builds running tool updates with normalized content", () => {
expect(
runningToolUpdate({
toolCallId: "call",
toolName: "read",
state: { input: { filePath: "/tmp/a" } },
content: [{ type: "text", text: "done" }],
}),
).toMatchObject({
toolCallId: "call",
status: "in_progress",
content: [{ type: "content", content: { type: "text", text: "done" } }],
})
})
test("builds completed raw output with structured data and optional result", () => {
const attachments = [
{
type: "file",
mime: "image/jpeg",
name: "photo.jpg",
uri: "data:image/jpeg;base64,AAAA",
},
]
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [],
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
}).rawOutput,
).toEqual({
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
})
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [],
structured: { output: "done" },
}).rawOutput,
).toEqual({ structured: { output: "done" } })
})
test("extracts image attachments only from data URLs", () => {
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "read",
input: {},
content: [
{ type: "file", mime: "image/webp", uri: "data:image/webp;charset=utf-8;base64,AAAA" },
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
],
structured: {},
}).content,
).toEqual([
{
type: "content",
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
},
])
})
test("builds failed tool updates", () => {
expect(
errorToolUpdate({
toolCallId: "call",
toolName: "read",
input: { filePath: "/tmp/a" },
content: [{ type: "text", text: "partial output" }],
structured: { path: "/tmp/a" },
error: "failed",
}),
).toEqual({
toolCallId: "call",
status: "failed",
kind: "read",
title: "read",
locations: [{ path: "/tmp/a" }],
rawInput: { filePath: "/tmp/a" },
content: [
{ type: "content", content: { type: "text", text: "partial output" } },
{ type: "content", content: { type: "text", text: "failed" } },
],
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
})
})
})
+7 -2
View File
@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { expect, test } from "bun:test"
import path from "path"
@@ -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"
@@ -5,13 +5,25 @@ import path from "node:path"
const root = path.resolve(import.meta.dir, "../../..")
describe("CLI frontend import boundaries", () => {
test("does not import Core directly", async () => {
const glob = new Bun.Glob("{src,test}/**/*.{ts,tsx}")
const imports: string[] = []
for await (const file of glob.scan({ cwd: path.join(root, "packages/cli") })) {
const source = await Bun.file(path.join(root, "packages/cli", file)).text()
if (/["']@opencode-ai\/core(?:\/[^"']*)?["']/.test(source)) imports.push(file)
}
expect(imports).toEqual([])
})
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(["toolInlineInfo", "toolOutputText"])
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
})
+33 -16
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/core/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,6 +213,7 @@ describe("mini command", () => {
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--server string")
expect(result.stdout).toContain("--prompt string")
expect(result.stdout).not.toContain("SUBCOMMANDS")
})
+184 -6
View File
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode, type EventSubscribeOutput, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import {
OpenCode,
type EventSubscribeOutput,
type SessionMessageAssistantTool,
type SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
type V2Event = EventSubscribeOutput
@@ -125,7 +130,6 @@ 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",
@@ -144,6 +148,8 @@ function failedTool(inputID: string): V2Event[] {
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,
},
},
@@ -151,6 +157,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: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
structured: { 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: {
@@ -162,10 +215,14 @@ async function run(input: {
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
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: {} }]
let wake: (() => void) | undefined
const wait = Promise.withResolvers<void>()
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
while (true) {
const value = values.shift()
@@ -175,6 +232,10 @@ async function run(input: {
})
continue
}
if (value.type.startsWith("session.execution.")) {
if (input.terminalDelay) await Bun.sleep(input.terminalDelay)
setTimeout(wait.resolve, 0)
}
yield value
}
})()
@@ -193,8 +254,19 @@ async function run(input: {
}) as never,
)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
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 } },
],
cursor: {},
}),
)
spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
promptID = messageID
values.push(...input.turn(messageID))
wake?.()
wake = undefined
@@ -231,7 +303,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()
@@ -244,6 +316,109 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("keeps formatted tool output and compact structured 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: {
structured: { matches: 2 },
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
},
},
},
})
expect(events[0].part.state.metadata.structured).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
const task = capture({
format: "json",
turn: (messageID) => [prompted(messageID)],
wait: () => idle.promise,
messages: (messageID) => [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "projected answer" }],
finish: "stop",
time: { created: 2, completed: 3 },
},
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
],
}).then((output) => {
done = true
return output
})
await Bun.sleep(0)
await Bun.sleep(0)
expect(done).toBe(false)
idle.resolve()
const output = await task
expect(
output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([expect.objectContaining({ type: "text", part: expect.objectContaining({ text: "projected answer" }) })])
})
test("reports an observed execution failure before prompt promotion", async () => {
const output = await capture({
format: "json",
turn: () => [executionFailed("instructions unavailable")],
messages: () => [],
})
expect(
output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([
expect.objectContaining({
type: "error",
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 () => {
const sdk = await run({
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
@@ -307,6 +482,9 @@ describe("runNonInteractivePrompt", () => {
}),
])
expect(output.stderr).toBe("")
const sdk = await run({ compatibility: "v1", turn: (messageID) => [prompted(messageID), settled()] })
expect(sdk.session.wait).not.toHaveBeenCalled()
expect(sdk.message.list).not.toHaveBeenCalled()
})
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
@@ -333,14 +511,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()
+4 -4
View File
@@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Global } from "@opencode-ai/util/global"
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,
}),
+29 -92
View File
@@ -1,18 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
import { Effect, Schedule, Schema } from "effect"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -20,6 +11,7 @@ import { ServiceConfig } from "../src/services/service-config"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
@@ -43,17 +35,35 @@ test("local channel stores service config with the local service filename", asyn
}
})
test("service filenames isolate installation channels", () => {
test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("next")).toBe("service.json")
expect(ServiceConfig.filename("local")).toBe("service-local.json")
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("preview-b"))
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("latest"))
expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json")
expect(ServiceConfig.filename("preview/a")).toBe("service-preview-a.json")
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234.2", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false)
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
})
test("service config migrates from the hashed channel filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
const target = path.join(root, ServiceConfig.filename("preview-a"))
try {
await fs.writeFile(legacy, JSON.stringify({ hostname: "127.0.0.2", port: 4098 }))
await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(target).json()).toEqual({ hostname: "127.0.0.2", port: 4098 })
await fs.writeFile(target, JSON.stringify({ port: 4099 }))
await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(target).json()).toEqual({ port: 4099 })
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("preview registration migration never moves stable discovery", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-"))
const legacy = path.join(root, "service.json")
@@ -185,31 +195,6 @@ test("concurrent service processes elect one server", async () => {
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
const sessionID = SessionV2.ID.make("ses_service_recovery")
await withDatabase(
database,
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make(root), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "recovery",
directory: root,
title: "recovery",
version: "test",
time_suspended: Date.now(),
})
.run()
.pipe(Effect.orDie)
}),
)
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const registration = path.join(root, "state", "opencode", "service-local.json")
const port = await availablePort()
@@ -262,20 +247,6 @@ test("concurrent service processes elect one server", async () => {
contender.kill("SIGTERM")
await contender.exited
}
expect(
await withDatabase(
database,
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ timeSuspended: SessionTable.time_suspended })
.from(SessionTable)
.get()
.pipe(Effect.orDie)
}),
),
).toEqual({ timeSuspended: null })
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await winner?.exited
expect(await Bun.file(registration).exists()).toBe(false)
@@ -361,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",
@@ -397,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")
@@ -409,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",
@@ -426,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",
@@ -510,40 +481,6 @@ test("a failed service stays registered and owns the selected port until stopped
}
}, 30_000)
function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) {
return Effect.runPromise(effect.pipe(Effect.provide(Database.layer({ path: file })), Effect.scoped))
}
function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
return withDatabase(
file,
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ id: EventTable.id, sessionID: EventTable.aggregate_id, type: EventTable.type })
.from(EventTable)
.all()
.pipe(
Effect.orDie,
Effect.map((rows) =>
rows.filter(
(row) =>
row.sessionID === sessionID &&
row.type ===
EventV2.versionedType(
SessionEvent.Execution.Started.type,
SessionEvent.Execution.Started.durable.version,
),
),
),
Effect.filterOrFail((rows) => rows.length > 0),
Effect.map((rows) => rows.length),
Effect.retry(Schedule.max([Schedule.spaced("50 millis"), Schedule.recurs(200)])),
)
}),
)
}
async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
for (let attempt = 0; attempt < 400; attempt++) {
const value = await Bun.file(file)
+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"))

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