Compare commits

..

154 Commits

Author SHA1 Message Date
LukeParkerDev a1a9511935 feat(app): queue and steer follow-up prompts 2026-08-25 12:45:01 +10:00
Luke Parker 9fc85ae9db fix(cli): honor notification-only automatic updates (#44820) 2026-08-24 19:34:15 -05:00
Dax 2e4b2c82f4 fix(tui): resolve plugin SDK imports at runtime (#44822) 2026-08-24 20:29:38 -04:00
Aiden Cline a9042a58ab feat(ai): add partial JSON parser (#44792) 2026-08-24 18:59:06 -05:00
Aiden Cline 244ec6c8f7 fix(core): validate JSON schema tool input (#44789) 2026-08-24 18:56:57 -05:00
Dax Raad e28471e0ad docs: rename build sidebar intro 2026-08-24 19:04:37 -04:00
Dax Raad 778d5b675c docs: split client and sdk guides 2026-08-24 19:03:42 -04:00
Filip 127113188e docs(github): correct action token configuration (#44795) 2026-08-25 00:29:41 +02:00
Dax Raad ce16b7cc12 docs: simplify plugin guide routes 2026-08-24 18:28:23 -04:00
Aiden Cline eda6d774bf fix(ai): ignore unknown Gemini response parts (#44745) 2026-08-24 17:23:35 -05:00
Dax Raad e11b3d08b6 docs: clarify plugin skill guidance 2026-08-24 18:10:40 -04:00
Dax Raad 0cdd711abf docs: expand plugin guides 2026-08-24 18:10:40 -04:00
Kit Langton 22c63833d2 feat(workspace): support caller-supplied IDs (#44771) 2026-08-24 18:08:42 -04:00
Kit Langton 42d160f4a0 test: stabilize asynchronous integration checks (#44787) 2026-08-24 18:02:13 -04:00
opencode-agent[bot] 8be467de8d fix(core): respect disabled Plan agent config (#44761)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-24 16:55:37 -05:00
opencode-agent[bot] 50c5218bca fix(core): clarify integration auth errors (#44786)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-24 16:53:14 -05:00
Kit Langton c1763e2b64 fix(server): make text generation locationless (#44773) 2026-08-24 17:36:02 -04:00
Kit Langton 34bd7c220c feat(session): report interrupt result (#44766) 2026-08-24 17:34:37 -04:00
Dax 7f5ea1889c test(core): provide command render services 2026-08-24 17:34:06 -04:00
Kit Langton a02b0a4729 fix(core): make workspace destroy idempotent (#44769) 2026-08-24 17:27:01 -04:00
Filip d8ce27fa29 fix(github): support immutable OIDC subjects (#44778) 2026-08-24 23:23:44 +02:00
Kit Langton 2f740cec5d fix(tui): highlight shell script files (#44772) 2026-08-24 17:21:19 -04:00
opencode-agent[bot] 162c3fcebd fix(app): hide revert for child sessions (#44774)
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-08-24 23:20:09 +02:00
Dax 71f81dc0fe fix(tui): simplify subagent continuation rows (#44763) 2026-08-24 16:12:56 -04:00
Dax de388dede4 feat(command): execute plugin callbacks 2026-08-24 16:01:58 -04:00
opencode-agent[bot] c936acd3fe chore: update nix node_modules hashes 2026-08-24 19:42:01 +00:00
Kit Langton c19186ee54 feat(sdk): add Promise-first embedded host (#44746) 2026-08-24 15:26:05 -04:00
Dax Raad 563943c52e feat(www): restore native API reference 2026-08-24 15:21:26 -04:00
Dax 575bbd6ea1 fix(www): update docs section navigation (#44753) 2026-08-24 14:48:26 -04:00
Aiden Cline 5d9b53b2c7 fix(ai): honor Responses parallel tool control (#44744) 2026-08-24 13:29:43 -05:00
Aiden Cline 4780248e84 fix(ai): require thinking signature with provider-aware fallback (#44713) 2026-08-24 13:13:48 -05:00
opencode-agent[bot] 43d4968356 fix(app): focus terminal after opening panel (#44737)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 17:50:53 +00:00
Aiden Cline 0164c1c8bc fix(ai): remove obsolete reasoning summary aliases (#44733) 2026-08-24 12:42:36 -05:00
Aiden Cline 6a687398eb feat(ai): gate Chat Completions body fields by provider (#44710) 2026-08-24 12:40:56 -05:00
Kit Langton f4cb9d06c8 fix(tui): keep remotely closed tabs closed (#44715) 2026-08-24 13:06:02 -04:00
opencode-agent[bot] 3e82b1a9fd fix(tui): provide location context in MCP tests (#44734)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-24 12:05:25 -05:00
Simon Klee e2a7600a2a tui: preserve close target after tab removal (#44720) 2026-08-24 19:04:50 +02:00
Dax Raad aa8c1f6dac fix(tui): scope MCP dialog to location 2026-08-24 12:17:12 -04:00
Kit Langton 23c3a1461c fix(merman): harden responsive diagram layouts (#44714) 2026-08-24 12:09:00 -04:00
opencode-agent[bot] 50a8539e4b chore: update nix node_modules hashes 2026-08-24 14:29:37 +00:00
Shoubhit Dash 7b47589225 refactor(core): optimize stats tool summary (#44690) 2026-08-24 19:39:04 +05:30
OpeOginni 84275c6e9d fix(cli): restore print-logs flag for --standalone runs (#44112)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-08-24 19:32:38 +05:30
Kit Langton 793ea52fa7 fix(core): make packed SDK boot on workerd (#44703) 2026-08-24 14:02:26 +00:00
Simon Klee 40380ad9b5 tui: update OpenTUI to 0.5.8 (#44698) 2026-08-24 15:42:27 +02:00
Kit Langton 156cb7dd10 feat(tui): render Mermaid Gantt diagrams (#44534) 2026-08-24 08:50:32 -04:00
Shoubhit Dash 0a60910208 fix(cli): preserve stats server errors (#44685) 2026-08-24 18:11:46 +05:30
Shoubhit Dash 11e2bde399 fix(stats): correct aggregation boundaries (#44682) 2026-08-24 17:26:43 +05:30
Simon Klee ea85118516 tui: preserve transcript window while scrolling (#44679) 2026-08-24 13:30:31 +02:00
Shoubhit Dash 2e1d7c84ab feat(cli): add shareable stats command (#43653) 2026-08-24 16:47:23 +05:30
opencode-agent[bot] 3b8949b1ee fix(tui): load paginated session history (#44656)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-24 16:02:19 +05:30
Luke Parker 97daae9b77 feat(app): align context search rows with figma (#44654) 2026-08-24 20:14:40 +10:00
opencode-agent[bot] 3dd84d629d fix(session-ui): preserve timeline message order (#44652) 2026-08-24 09:44:47 +00:00
Luke Parker 279062d3c9 feat(app): align execute tool with shell treatment (#44642) 2026-08-24 09:20:56 +00:00
Brendan Allan 0a410e404b fix(session-ui): show tool errors inline (#44632) 2026-08-24 07:59:12 +00:00
opencode-agent[bot] 5cd40ad66f fix(app): remember submitted workspace choice (#44620)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-24 07:12:46 +00:00
Luke Parker 46d1f1fed1 refactor(app): make auto-accept permissions an app-level setting (#44608) 2026-08-24 16:56:14 +10:00
opencode-agent[bot] e282066cf8 fix(app): place thinking above queued prompts (#44616)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 06:05:22 +00:00
opencode-agent[bot] 6704f34cad fix(app): handle Windows menu accelerators (#44607)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 13:57:52 +08:00
Aiden Cline e63aba48bc fix(ai): reconcile reasoning finals that arrive without deltas (#44595) 2026-08-24 00:29:23 -05:00
Brendan Allan 297f5298cc fix(app): deduplicate notification sounds across tabs (#44612) 2026-08-24 05:26:57 +00:00
opencode-agent[bot] 7102c487c9 fix(app): sync project list between windows (#44600)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 12:59:50 +08:00
opencode-agent[bot] d8f59d312b feat(desktop): hide local server from projects (#44598)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 12:58:53 +08:00
opencode-agent[bot] b23e4c3118 fix(app): cap workspace settings list height (#44603)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 12:57:30 +08:00
opencode-agent[bot] 1c03f08512 fix(desktop): copy IDs through native clipboard (#44599)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 12:54:28 +08:00
Michael Hart 3eb07e38ae fix(app): use a deletable cursor for draft blob cleanup (#44594)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-24 12:53:39 +08:00
Aiden Cline 299e314a28 fix(ai): passthrough anthropic top-level body fields (#44604) 2026-08-23 23:47:17 -05:00
Aiden Cline 76027fe3da fix(ai): expand anthropic media lowering to match SDK (#44593) 2026-08-23 23:12:28 -05:00
Aiden Cline 8b65bd53bf fix(ai): drop invalid item ids when replaying responses history (#44587) 2026-08-23 23:08:16 -05:00
Aiden Cline 1dee7e05b5 fix(ai): support disable_parallel_tool_use and send beta query (#44583) 2026-08-23 22:47:56 -05:00
opencode-agent[bot] 89026373bb fix(desktop): ignore late draft flushes (#44584)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 11:47:08 +08:00
opencode-agent[bot] 1ea4584f67 chore: update nix node_modules hashes 2026-08-24 03:41:11 +00:00
Aiden Cline 89a51a088a fix(ai): ignore orphan response deltas (#44575) 2026-08-23 22:37:44 -05:00
usrnk1 3f807d8836 feat(desktop): add home session actions (#43245)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-08-24 13:18:03 +10:00
Aiden Cline 4876f416cc fix(ai): fail loudly on missing anthropic tool_use id (#44569) 2026-08-23 21:56:39 -05:00
Luke Parker dbc178c3cc fix: add DOM.AsyncIterable lib so ReadableStream iteration typechecks (#44580) 2026-08-24 02:26:54 +00:00
opencode-agent[bot] 73974dc668 chore: update nix node_modules hashes 2026-08-24 02:18:31 +00:00
Kit Langton 1593479454 chore(core): upgrade drizzle orm to rc5 (#44573) 2026-08-23 21:56:02 -04:00
Kit Langton 971681c567 fix(image): degrade photon resizer load failure to typed error on workerd (#43894) 2026-08-23 21:44:01 -04:00
Luke Parker 378a7b7be1 fix: stream Bun runtime downloads to disk to avoid Bun.write GC hang (#44572) 2026-08-24 11:22:42 +10:00
Aiden Cline 6d57667168 fix(ai): clamp prompt_cache_key to 64 chars for all chat protocols (#44571) 2026-08-23 20:21:15 -05:00
Aiden Cline d4935360c5 fix(ai): keep stateless hosted tool results and tolerate WS keepalives (#44488) 2026-08-23 20:19:14 -05:00
Aiden Cline 89281dcd01 fix(ai): preserve display on enabled thinking (#44544) 2026-08-23 20:05:30 -05:00
Aiden Cline 9f9184b002 fix(ai): preserve OpenAI tool schemas (#44546) 2026-08-23 20:05:06 -05:00
Kit Langton c6f4fa0d1f fix(core): never build fff index for workspace locations (#44563) 2026-08-24 01:00:27 +00:00
Kit Langton c505c91438 fix(codemode): package conditional transpilers (#44565) 2026-08-23 20:50:39 -04:00
Kit Langton 8887c2ffa9 fix(core): skip host realpath canonicalization for workspace locations (#44564) 2026-08-23 20:50:29 -04:00
opencode-agent[bot] ddaf9ff433 chore: update nix node_modules hashes 2026-08-24 00:35:01 +00:00
usrnk1 d3e5d6d268 feat(desktop): add model change notices (#43288)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-08-24 00:17:35 +00:00
Kit Langton f9bc948912 fix(core): skip local liveness check for workspace locations (#44560) 2026-08-23 20:17:04 -04:00
opencode-agent[bot] 6f238feeff chore: update nix node_modules hashes 2026-08-24 00:07:29 +00:00
Kit Langton e867a21ea5 feat(core): decouple workspace identity from provisioning (#44526) 2026-08-23 23:49:51 +00:00
Luke Parker 64c0411edb fix(session-ui): align tool error card with figma (#44543) 2026-08-23 23:31:45 +00:00
opencode-agent[bot] 7da10aa65d fix(ai): retry detail-free response errors (#44537)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-23 17:39:55 -05:00
Luke Parker d80b0a1e7e fix(desktop): reconnect to elected service (#44369) 2026-08-24 08:27:04 +10:00
opencode-agent[bot] 481125f617 chore: update nix node_modules hashes 2026-08-23 22:15:43 +00:00
Kit Langton 59d0d64a6b chore: upgrade Effect to rc.111 (#44518) 2026-08-23 18:01:44 -04:00
opencode-agent[bot] e0cdf1ed09 fix(tui): restore global prompt history (#44416)
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-23 17:53:16 -04:00
Kit Langton 9e50d76416 fix(codegen): write prettier-stable schema snapshot (#43985) 2026-08-23 17:34:47 -04:00
Dax 771c0f5850 fix(core): avoid stale MCP OAuth snapshots destroying rotated credentials (#44527) 2026-08-23 17:02:51 -04:00
Kit Langton b1a0ef91bb refactor(core): use FiberMap for title generation (#44519) 2026-08-23 16:22:43 -04:00
Kit Langton 2e67cee75b fix(tui): recover from terminal forms (#44516) 2026-08-23 16:21:28 -04:00
opencode-agent[bot] 890735c1d7 feat(plugin): expose tool input schema (#44510)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-23 14:19:57 -05:00
Aiden Cline 049f0b0c3b fix(ai): handle additional usage locations and tool history for OpenAI Chat (#44502) 2026-08-23 13:48:30 -05:00
Aiden Cline 6020f36862 fix(ai): handle anthropic error events and ping frames (#44500) 2026-08-23 13:45:21 -05:00
Simon Klee 0d24ebdbbe tui: preserve selection after copy (#44496)
Copy-on-select reset the click counter, so a third click could not select the full line. Keep the selection after copying to preserve multi-click input.
2026-08-23 20:40:19 +02:00
opencode-agent[bot] b22c182406 fix(ai): preserve inline file data URLs (#44499)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-23 13:39:38 -05:00
Aiden Cline c7f2f367e3 refactor(ai): remove deprecated local shell (#44497) 2026-08-23 13:36:10 -05:00
Aiden Cline ccd2135e7a fix(ai): tolerate explicit nulls in Gemini stream payloads (#44490) 2026-08-23 13:17:13 -05:00
Brendan Allan c7c22b9d7e fix(app): stabilize pane transitions (#44484) 2026-08-23 17:59:00 +00:00
Aiden Cline 67e99993f5 fix(ai): align Responses compatibility (#44482) 2026-08-23 12:53:14 -05:00
Brendan Allan be6abc02b5 fix(app): preserve session panes across navigation (#44477) 2026-08-23 17:37:24 +00:00
Aiden Cline e012a57d1d fix(ai): harden Gemini tool call id handling on Vertex and duplicate streams (#44454) 2026-08-23 12:31:30 -05:00
Brendan Allan 697e3e13cc fix(app): simplify session max width (#44468) 2026-08-24 01:01:11 +08:00
Brendan Allan 89451c3e32 fix(app): navigate parent with escape (#44441) 2026-08-23 23:45:29 +08:00
Brendan Allan ad7d515d50 fix(session-ui): avoid replaying thinking summary animation (#44440) 2026-08-23 23:43:17 +08:00
Brendan Allan 8e71c8425f fix(app): hide empty terminal cursor (#44435) 2026-08-23 15:34:40 +00:00
Brendan Allan da4ab56801 fix(session-ui): hide empty file diffs (#44425) 2026-08-23 23:15:47 +08:00
Brendan Allan dd780ca882 fix(app): hand off optimistic attachments (#44411) 2026-08-23 22:59:51 +08:00
Brendan Allan fb9c9a2cbd fix(desktop): restore session completion alerts (#44412) 2026-08-23 22:35:06 +08:00
Brendan Allan 4c786f0ff8 fix(app): preserve subagent tab lineage (#44404) 2026-08-23 22:18:39 +08:00
Brendan Allan 1def4aa35a fix(app): avoid replaying streamed markdown fade (#44396) 2026-08-23 21:10:43 +08:00
opencode-agent[bot] de11122c96 feat(app): search sessions by ID (#44387)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-23 20:17:57 +08:00
opencode-agent[bot] 62e3b6ad78 chore: update nix node_modules hashes 2026-08-23 11:49:19 +00:00
Brendan Allan 2c99b549db fix(app): redesign model selection dialog (#44383) 2026-08-23 19:34:14 +08:00
Luke Parker d7c3a95368 feat(app): refine expanded shell output (#44368) 2026-08-23 11:31:40 +00:00
opencode-agent[bot] 3b5b5aee0c chore: update nix node_modules hashes 2026-08-23 11:16:11 +00:00
Brendan Allan 8cbfac3184 fix(desktop): smooth startup hydration (#44377) 2026-08-23 19:07:10 +08:00
opencode-agent[bot] e765349865 feat(session-ui): render streaming markdown with Solid (#44354)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-23 19:00:46 +08:00
opencode-agent[bot] 750f3384ff fix(app): defer service worker takeover (#44374)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-23 18:52:45 +08:00
Simon Klee 1cf61593b5 ci: fix node build (#44349) 2026-08-23 11:26:26 +02:00
opencode-agent[bot] 708e4d8867 fix(app): refine mobile error page (#44353) 2026-08-23 09:23:10 +00:00
Luke Parker 0a5349c51c fix(session-ui): restore compaction summaries (#44311) 2026-08-23 18:57:06 +10:00
opencode-agent[bot] 3fd93b659b chore: update nix node_modules hashes 2026-08-23 08:28:38 +00:00
Simon Klee 17a445de3e tui: update OpenTUI to 0.5.7 (#44345) 2026-08-23 10:15:17 +02:00
Brendan Allan 68e3888bc3 fix(app): preserve session header while loading (#44346) 2026-08-23 16:09:13 +08:00
Brendan Allan 27a53969d6 fix(app): stabilize initial timeline rendering (#44333) 2026-08-23 16:06:50 +08:00
Brendan Allan 1e3d3fcaca fix(app): complete leading paginated turns (#44322) 2026-08-23 15:27:33 +08:00
opencode-agent[bot] 7420903859 chore: update nix node_modules hashes 2026-08-23 07:25:41 +00:00
Simon Klee 853d3534e5 cli: share app archive across Node builds (#44331) 2026-08-23 09:11:54 +02:00
Brendan Allan 6cd3da1d4c fix(app): preserve scroll across history prepends (#44317) 2026-08-23 15:00:29 +08:00
Brendan Allan 9692bcbc21 fix(session-ui): align status tool typography (#44330) 2026-08-23 14:55:53 +08:00
Brendan Allan 79d5359d11 fix(app): align background hint spacing (#44329) 2026-08-23 14:50:32 +08:00
Brendan Allan b9ca77c5ff fix(app): suspend composer until restored (#44320) 2026-08-23 14:46:28 +08:00
Brendan Allan 7a72e51710 fix(app): reuse global model state (#44319) 2026-08-23 14:26:53 +08:00
Brendan Allan 0fda7d1b33 fix(session-ui): reduce tool error spacing (#44318) 2026-08-23 14:25:48 +08:00
Brendan Allan 6ba707d305 fix(app): synchronize composer model controls (#44316) 2026-08-23 14:25:27 +08:00
Brendan Allan d144c81d17 fix(app): preserve session shell while loading (#44315) 2026-08-23 14:18:38 +08:00
Brendan Allan b8fb894ec7 fix(server): compress responses with correct content types (#44321) 2026-08-23 06:18:20 +00:00
Brendan Allan b7167aaab0 feat(app): add project icon setting (#44323) 2026-08-23 06:18:15 +00:00
Luke Parker ad59ccc2e2 fix(ui): standardize safe text metrics (#44106) 2026-08-23 13:47:17 +10:00
Luke Parker a82318019f fix(app): skip service worker in development (#44302) 2026-08-23 13:35:05 +10:00
Luke Parker e9d58abd99 test(app): stabilize file expansion anchor (#44293) 2026-08-23 13:05:31 +10:00
Brendan Allan 1ce7e90d3e fix(app): register service worker after load (#44294) 2026-08-23 10:50:56 +08:00
Aiden Cline 92658e4389 fix(ai): preserve raw provider error payload on responses streams (#44271) 2026-08-22 20:59:59 -05:00
516 changed files with 37303 additions and 10127 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
---
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
+35 -4
View File
@@ -195,9 +195,33 @@ jobs:
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
build-node-app-archive:
needs: version
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build app archive
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-node-app-archive
path: packages/cli/.cache/app-archive.bin
if-no-files-found: error
build-node-cli:
needs:
- version
- build-node-app-archive
if: github.repository == 'anomalyco/opencode'
strategy:
fail-fast: false
matrix:
@@ -210,6 +234,7 @@ jobs:
host: macos-26
- target: windows-arm64
host: blacksmith-4vcpu-windows-2025
bun_install_flags: --cpu=*
- target: windows-x64
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
@@ -221,14 +246,19 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
install-flags: --os=* --cpu=*
install-flags: ${{ matrix.settings.bun_install_flags }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-node-app-archive
path: packages/cli/.cache
- name: Build
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -543,6 +573,7 @@ jobs:
- version
- build-cli
- sign-cli-macos
- build-node-app-archive
- build-node-cli
- sign-cli-windows
- build-electron
+11
View File
@@ -84,6 +84,17 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify published codemode package
if: runner.os == 'Linux'
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
timeout-minutes: 15
working-directory: packages/sdk
run: bun run verify:package
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
+39 -71
View File
@@ -59,6 +59,7 @@
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -70,6 +71,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
@@ -190,7 +192,7 @@
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.110",
"effect": "4.0.0-rc.111",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -524,7 +526,7 @@
"name": "@opencode-ai/http-recorder",
"version": "1.18.15",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.111",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -593,8 +595,8 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.6",
"@opentui/solid": ">=0.5.6",
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -670,6 +672,7 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
@@ -717,7 +720,6 @@
"luxon": "catalog:",
"marked": "catalog:",
"mermaid": "11.16.1",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
"remend": "catalog:",
@@ -727,6 +729,7 @@
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -1053,7 +1056,6 @@
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
@@ -1077,10 +1079,10 @@
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-rc.110",
"@effect/platform-node": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -1088,9 +1090,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1115,9 +1117,9 @@
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.110",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
@@ -1682,15 +1684,15 @@
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.110", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.110" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-8Uum1ikIAK2DjATeLseS71HZKVI/dduFYypvMhiO68RTli1xq9yaoTI1Y0IThPn2sI0i86ZdpFaKQx1hdw2tWw=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.111" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.110", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.110", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110", "redis": ">=5.0.0 <7.0.0" } }, "sha512-poj6VxTc2kRDowUHgjGXAZL2nWHTyGi/18607jK+pV9A9CH/wqZ4NeYj38rij95j5SiZ3U7Y1iOsk2Vfnr4GEw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.111", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.111", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.111", "redis": ">=5.0.0 <7.0.0" } }, "sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.110", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-P8EZloxS7RtCOSL3VSBPyqSenUm93xLbXf9jkWoy67cibZ2wgZ9nAou9iN8f1i4vzgxUsWtDuy6BEmg67np6Zw=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.111", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.110", "", { "peerDependencies": { "effect": "^4.0.0-rc.110" } }, "sha512-Lam3gY1xszjQS/cebdLbWbtR21FtQI4ke7QHqbBQ6AyVJF0CGUtS/ePL9zNq6wHNmJ95FUDGS6W+Qx/l6RPSzA=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -2216,27 +2218,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="],
"@opentui/core": ["@opentui/core@0.5.8", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.8", "@opentui/core-darwin-x64": "0.5.8", "@opentui/core-linux-arm64": "0.5.8", "@opentui/core-linux-arm64-musl": "0.5.8", "@opentui/core-linux-x64": "0.5.8", "@opentui/core-linux-x64-musl": "0.5.8", "@opentui/core-win32-arm64": "0.5.8", "@opentui/core-win32-x64": "0.5.8" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-GbZ+nSLYZqxj2Z5TU19Mx6IsAvVsn2+7WEXz+6OlMGoootvt3TxP3vWwfGI0jWk9qp1ftRlML1JPNEkzy+9I8g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c9Y1FBrSnA4sKUCMETsrLYOmsMTyJae8mU9cE6M4o9rXcr3ZLPA7o9AkPA3S0+kH+kZ0o1Fn0wLejgNxvEp5mg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-oZ/6Iz1KN+4volMFKmmvziYJhMgyyJ99LfK1S+uPRyIRqzT3CESoJ58D4h04M1g0dHeHE4PvkU1p3uQKrXiP3g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RAiRmosbg7DH4+ZuXRMjEmQmDAWhcRpuQKQ6bGL76nUd6wl/CufBiD7il/FQMQyUGzmQQH0Hu+pVQLz+kAkNJw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-N6i/ocrsTjIq9aUQyrfJkqUo+tc4P5ZS6xz38Cm1MhtDzwBE+CrNIJdHfoJfmRLhJkYHlw6Z0ToICn8ZNj4Bpw=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-eFMB41AWODaYf8PsCx3vtTMX33tFgtSpR9tKNnUErCXlcnZ+WCRHVaJ9F6DrVNX3ir6HmgbwAmaWXvaVF+kLtg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-gzlNauPg5UT5UnWiSIlONRwKnkNA6fjN8SyRGlT3SGvcAox+vT2WeP6TwoJSM6pq5H5MF/tlY+REIqc/ZMGJtQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-/2QM7/wMnML/sxchzbwgoU5tUu/7k836/kSOKMti8opjuecv1K+WWNKGXufhTNeRcXFZaba5rsCdYrf3VqnVsQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YXo+qUHYmep2uvv3ECvTeqr10aD7+lBsavYmsTLBzS5hHabbzlQ10oX/99nIRPC3Au1BMWD6d6zQP5czPx23eg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-7qBdhEAlh4tLFzW7nWLPlREtNiF6NZMDMi+4uDpUlAMhRavLr6wjcIcgfhNAF/puq06DjuZlPTwaMCgB3qOuwA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Z76YaTKnmRDSHKdKa7iTBCXBQdInQLq7UG3qIE84nvyHCDtkhYtPWOaCzDisSgXC8Y6hJt6NiFbaNjQyxPkfpQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6" }, "peerDependencies": { "@opentui/react": "0.5.6", "@opentui/solid": "0.5.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-1lR1aFRSgHCwN31YOHDQQVSqz1YNN7DsoIvWHjEiQJ12kTxHg9G7Q1HHFJttM838RWsNOvW6gBAhOr403VL8rg=="],
"@opentui/keymap": ["@opentui/keymap@0.5.8", "", { "dependencies": { "@opentui/core": "0.5.8" }, "peerDependencies": { "@opentui/react": "0.5.8", "@opentui/solid": "0.5.8", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-KQHKRnLZroSZIbHmGmSeDPsXi7Yykdu9x9ACmDzBFkgPGx0EIuXHqRcmrqmUaX485MQEZuCD9t6FdDsOMGyIFg=="],
"@opentui/solid": ["@opentui/solid@0.5.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kxmrQS31sgziW/qZDuFYuiT3CvcUQg9ZQA1qKCEb5mBoInur4fnECOdTaM2S8c3cidcV0z2yu41NP1Dypkl+BQ=="],
"@opentui/solid": ["@opentui/solid@0.5.8", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.8", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-L0NxuAU8XT+jlE5G90oA3kspqkof48b0hmzi5XLw+1gxnkxrkTb+YfKys+GzVK4UqhgwY9aW+TeDfrKbnfwCMw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -3216,12 +3218,6 @@
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/cli-config": ["@vercel/cli-config@0.2.3", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A=="],
"@vercel/cli-exec": ["@vercel/cli-exec@1.0.1", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ=="],
"@vercel/functions": ["@vercel/functions@3.9.3", "", { "dependencies": { "@vercel/oidc": "3.8.4" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -3834,9 +3830,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-rc.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TRxUmj1wDA2QCt3GvuhfamvIa66wJ7+MzSxBMKkpRtYScjHTumT9BE+x6daSzuEacSrPEuUH5/cW1uo5RkoPIg=="],
"drizzle-kit": ["drizzle-kit@1.0.0-rc.5-ab785fc", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TSg7sK1finOxdo48O9mDfyMY3lhi/aHCVIil5TFhthyq/H/89H/3nwX5V9Gdp7HYMUGFHyWJD5iowSn98l0elw=="],
"drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
"drizzle-orm": ["drizzle-orm@1.0.0-rc.5-169397b", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@drizzle-team/minipg": ">=0.3.2", "@effect/sql-d1": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-libsql": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-mysql2": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-pg": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-pglite": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-bun": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-do": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-node": ">=4.0.0-beta.105 || >=4.0.0", "@effect/sql-sqlite-wasm": ">=4.0.0-beta.105 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=17", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.7.1", "@tursodatabase/database-common": ">=0.7.1", "@tursodatabase/database-wasm": ">=0.7.1", "@tursodatabase/serverless": ">=1.4.0", "@tursodatabase/sync": ">=0.7.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.105 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.2.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@drizzle-team/minipg", "@effect/sql-d1", "@effect/sql-libsql", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/sql-pglite", "@effect/sql-sqlite-bun", "@effect/sql-sqlite-do", "@effect/sql-sqlite-node", "@effect/sql-sqlite-wasm", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@tursodatabase/serverless", "@tursodatabase/sync", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-xf5TudQhFplMMzjwgmMY5f64Sqbv4iwXxdTHBHqvLMt+SHyY8rAYhzNLQchEkDxiqMclTS/txPEOg5pdQy0Tcw=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -3852,7 +3848,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-rc.110", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-ega6FTJ8CS2of7tHZiADvgyJyV999Q6tZ9juE56V81O0jw6flRwydaPNtyfvP2a5LL9PZrse8A1jnNUD5sWVHg=="],
"effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3980,8 +3976,6 @@
"eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
@@ -4256,8 +4250,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -4622,8 +4614,6 @@
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"mermaid": ["mermaid@11.16.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g=="],
@@ -4708,8 +4698,6 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
@@ -4826,8 +4814,6 @@
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -4852,8 +4838,6 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
@@ -4868,8 +4852,6 @@
"opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
"os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="],
"own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="],
"oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="],
@@ -5452,8 +5434,6 @@
"strip-comments": ["strip-comments@2.0.1", "", {}, "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"stripe": ["stripe@18.0.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA=="],
@@ -5846,10 +5826,6 @@
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
@@ -6172,6 +6148,8 @@
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@effect/platform-node-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -6420,10 +6398,6 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.4", "", { "dependencies": { "@vercel/cli-config": "0.2.3", "@vercel/cli-exec": "1.0.1", "jose": "^5.9.6" } }, "sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA=="],
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitejs/plugin-react/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
@@ -6562,10 +6536,6 @@
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
@@ -7322,8 +7292,6 @@
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-tvhHO7NdDnBWtyaOj+kVX0Tzcv3O0uISbHC4V71kA0M=",
"aarch64-linux": "sha256-x3F43TL7BisEuXlJw7QS/DJoSzZWuNxbgn7hFCsTdXU=",
"aarch64-darwin": "sha256-y2r5Qy/XNgnvuzpnGMtwV5ZmhksUN2AUPLjbb40HYIE=",
"x86_64-darwin": "sha256-TS68JE40IaEa7ny0ATPUnEj8EV1CKtnGTPpyvZFwO7A="
"x86_64-linux": "sha256-2bkzaLe/n63btVRQNhu8LXCtMZJArX1Kedi5U40l1xw=",
"aarch64-linux": "sha256-5Cs9M3hvDKAymo71y8oZ7jj3pEm+MI+HHhuSuV7UvtM=",
"aarch64-darwin": "sha256-LsJcuxE/NMu+vUFdpBKHc2z0sC0C5bRMlH1Kj+ns9dY=",
"x86_64-darwin": "sha256-KDjmKC3JZD8I5A7gi+dYIl0dgHVt20/DwkM9RKBWiJk="
}
}
+10 -11
View File
@@ -39,19 +39,19 @@
"packages/stats/*"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.110",
"@effect/platform-node": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -71,9 +71,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.110",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -166,7 +166,6 @@
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
+1 -1
View File
@@ -213,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
- Input failed the `parameters` Schema.
- The handler returned a `ToolFailure`.
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
+404 -44
View File
@@ -1,3 +1,4 @@
import { Buffer } from "node:buffer"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
@@ -41,6 +42,7 @@ const SSE_EVENTS = new Set([
"content_block_start",
"content_block_delta",
"content_block_stop",
"ping",
"error",
])
export const framing = Framing.sseEvents(SSE_EVENTS)
@@ -53,7 +55,7 @@ export type ThinkingInput =
| {
readonly type: "disabled"
}
| ({ readonly type: "enabled" } & (
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
@@ -62,6 +64,19 @@ export interface OptionsInput {
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
readonly service_tier?: "auto" | "standard_only"
readonly serviceTier?: "auto" | "standard_only"
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?: string | { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
readonly outputConfig?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
}
export type ProviderOptionsInput = OptionsInput
@@ -81,32 +96,68 @@ const AnthropicTextBlock = Schema.Struct({
})
type AnthropicTextBlock = Schema.Schema.Type<typeof AnthropicTextBlock>
// SDK: Base64ImageSource:201 {type:"base64", media_type:"image/jpeg"|... , data}, URLImageSource:3817 {type:"url", url}, FileImageSource:2350 {type:"file", file_id}
// SDK: ImageBlockParam:2356 {source: Base64|URL|File, cache_control, transformations:2381 {oversized_image?}}
const AnthropicBase64ImageSource = Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.String,
data: Schema.String,
})
const AnthropicURLImageSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
const AnthropicFileImageSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
const AnthropicImageSource = Schema.Union([
AnthropicBase64ImageSource,
AnthropicURLImageSource,
AnthropicFileImageSource,
])
const AnthropicImageTransformations = Schema.Struct({
oversized_image: Schema.optional(Schema.Literals(["downsize", "error"])),
})
const AnthropicImageBlock = Schema.Struct({
type: Schema.tag("image"),
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.String,
data: Schema.String,
}),
source: AnthropicImageSource,
cache_control: Schema.optional(AnthropicCacheControl),
transformations: Schema.optional(AnthropicImageTransformations),
})
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
// SDK: Base64PDFSource:209 {type:"base64", media_type:"application/pdf", data}, PlainTextSource:2716 {type:"text", media_type:"text/plain", data},
// SDK: URLPDFSource:3823 {type:"url", url}, FileDocumentSource:2344 {type:"file", file_id}, ContentBlockSource:2266 {type:"content", content}
// SDK: DocumentBlockParam:2297 {source: 5-way union, cache_control, citations, context, title}
const AnthropicBase64PDFSource = Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
})
const AnthropicPlainTextSource = Schema.Struct({
type: Schema.tag("text"),
media_type: Schema.Literal("text/plain"),
data: Schema.String,
})
const AnthropicURLPDFSource = Schema.Struct({ type: Schema.tag("url"), url: Schema.String })
const AnthropicFileDocumentSource = Schema.Struct({ type: Schema.tag("file"), file_id: Schema.String })
const AnthropicDocumentSource = Schema.Union([
AnthropicBase64PDFSource,
AnthropicPlainTextSource,
AnthropicURLPDFSource,
AnthropicFileDocumentSource,
])
const AnthropicDocumentBlock = Schema.Struct({
type: Schema.tag("document"),
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
}),
source: AnthropicDocumentSource,
cache_control: Schema.optional(AnthropicCacheControl),
title: Schema.optional(Schema.String),
context: Schema.optional(Schema.String),
citations: Schema.optional(Schema.Struct({ enabled: Schema.Boolean })),
})
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
const AnthropicThinkingBlock = Schema.Struct({
type: Schema.tag("thinking"),
thinking: Schema.String,
signature: Schema.optional(Schema.String),
signature: Schema.String,
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -204,14 +255,18 @@ const AnthropicTool = Schema.Struct({
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
const AnthropicToolChoice = Schema.Union([
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
Schema.Struct({
type: Schema.Literals(["auto", "any", "none"]),
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
}),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String, disable_parallel_tool_use: Schema.optional(Schema.Boolean) }),
])
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
}),
Schema.Struct({
type: Schema.tag("adaptive"),
@@ -222,10 +277,28 @@ const AnthropicThinking = Schema.Union([
}),
])
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
const AnthropicJsonOutputFormat = Schema.Struct({
type: Schema.Literal("json_schema"),
schema: JsonObject,
})
const AnthropicOutputConfig = Schema.Struct({
effort: Schema.optional(Schema.String),
format: Schema.optional(Schema.NullOr(AnthropicJsonOutputFormat)),
})
// SDK Metadata:2649 {user_id?: string|null}
const AnthropicMetadata = Schema.Struct({ user_id: optionalNull(Schema.String) })
// SDK MessageCreateParamsContainer:2596 ContainerParams|string; ContainerParams:2172 {id?, skills?}
const AnthropicContainer = Schema.Union([
Schema.String,
Schema.Struct({
id: optionalNull(Schema.String),
skills: optionalNull(Schema.Array(JsonObject)),
}),
])
const AnthropicBodyFields = {
model: Schema.String,
system: optionalArray(AnthropicTextBlock),
@@ -240,6 +313,12 @@ const AnthropicBodyFields = {
stop_sequences: optionalArray(Schema.String),
thinking: Schema.optional(AnthropicThinking),
output_config: Schema.optional(AnthropicOutputConfig),
// SDK top-level passthrough: cache_control:4638, container:4643, inference_geo:4649, metadata:4654, service_tier:4670
cache_control: Schema.optional(AnthropicCacheControl),
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(AnthropicMetadata),
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -368,10 +447,26 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
auto: () => ({ type: "auto" as const }),
auto: () => ({
type: "auto" as const,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
none: () => ({ type: "none" as const }),
required: () => ({ type: "any" as const }),
tool: (name) => ({ type: "tool" as const, name }),
required: () => ({
type: "any" as const,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
tool: (name) => ({
type: "tool" as const,
name,
...(toolChoice.disableParallelToolUse === undefined
? {}
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
}),
})
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
@@ -410,7 +505,152 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
})
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = metadata.anthropic
if (ProviderShared.isRecord(anthropic)) {
if (typeof anthropic.file_id === "string") return anthropic.file_id
if (typeof anthropic.fileId === "string") return anthropic.fileId
}
if (typeof metadata.file_id === "string") return metadata.file_id
if (typeof metadata.fileId === "string") return metadata.fileId
return undefined
}
const transformationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicImageBlock["transformations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
const raw = anthropic?.transformations ?? metadata.transformations
if (ProviderShared.isRecord(raw)) {
const value = raw.oversized_image
if (value === "downsize" || value === "error") return { oversized_image: value }
}
if (anthropic && (anthropic.oversized_image === "downsize" || anthropic.oversized_image === "error"))
return { oversized_image: anthropic.oversized_image }
return undefined
}
const documentTitleFromPart = (part: MediaPart): string | undefined => {
if (ProviderShared.isRecord(part.metadata)) {
const anthropic = part.metadata.anthropic
if (ProviderShared.isRecord(anthropic) && typeof anthropic.title === "string") return anthropic.title
if (typeof part.metadata.title === "string") return part.metadata.title
}
if (typeof part.filename === "string" && part.filename.length > 0) return part.filename
return undefined
}
const documentContextFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const anthropic = ProviderShared.isRecord(metadata.anthropic) ? metadata.anthropic : undefined
if (anthropic && typeof anthropic.context === "string") return anthropic.context
if (typeof metadata.context === "string") return metadata.context
return undefined
}
const citationsFromMetadata = (
metadata: MediaPart["metadata"],
): AnthropicDocumentBlock["citations"] | undefined => {
if (!ProviderShared.isRecord(metadata)) return undefined
const raw = ProviderShared.isRecord(metadata.anthropic)
? (metadata.anthropic.citations ?? metadata.citations)
: metadata.citations
if (ProviderShared.isRecord(raw) && typeof raw.enabled === "boolean") return { enabled: raw.enabled }
return undefined
}
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value.trim())
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (
part: MediaPart,
breakpoints?: Cache.Breakpoints,
) {
const mime = part.mediaType.toLowerCase()
const cacheControlValue = breakpoints ? cacheControl(breakpoints, part.cache) : undefined
const fileId = fileIdFromMetadata(part.metadata)
// SDK file sources: FileImageSource:2350 / FileDocumentSource:2344 {type:"file", file_id}
if (fileId) {
if (mime.startsWith("image/"))
return {
type: "image" as const,
source: { type: "file" as const, file_id: fileId },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
return {
type: "document" as const,
source: { type: "file" as const, file_id: fileId },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
const rawString = typeof part.data === "string" ? part.data.trim() : undefined
// SDK URL sources: URLImageSource:3817 / URLPDFSource:3823 {type:"url", url}
if (rawString && isHttpUrl(rawString) && !rawString.startsWith("data:")) {
if (mime.startsWith("image/"))
return {
type: "image" as const,
source: { type: "url" as const, url: rawString },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
if (mime === "application/pdf")
return {
type: "document" as const,
source: { type: "url" as const, url: rawString },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
// SDK PlainTextSource:2716 {type:"text", media_type:"text/plain", data}
if (mime === "text/plain") {
const textData =
typeof part.data !== "string"
? Buffer.from(part.data).toString("utf8")
: part.data.startsWith("data:")
? (() => {
const comma = part.data.indexOf(",")
const payload = comma >= 0 ? part.data.slice(comma + 1) : part.data
return part.data.includes(";base64")
? Buffer.from(payload, "base64").toString("utf8")
: decodeURIComponent(payload)
})()
: part.data
return {
type: "document" as const,
source: { type: "text" as const, media_type: "text/plain" as const, data: textData },
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
}
const media = ProviderShared.normalizeMedia(part)
if (media.mime === "application/pdf")
return {
@@ -420,6 +660,14 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
media_type: "application/pdf" as const,
data: media.base64,
},
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(documentTitleFromPart(part) === undefined ? {} : { title: documentTitleFromPart(part)! }),
...(documentContextFromMetadata(part.metadata) === undefined
? {}
: { context: documentContextFromMetadata(part.metadata)! }),
...(citationsFromMetadata(part.metadata) === undefined
? {}
: { citations: citationsFromMetadata(part.metadata)! }),
} satisfies AnthropicDocumentBlock
if (!media.mime.startsWith("image/"))
return yield* invalid(`Anthropic Messages does not support media type ${part.mediaType}`)
@@ -430,6 +678,10 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
media_type: media.mime,
data: media.base64,
},
...(cacheControlValue === undefined ? {} : { cache_control: cacheControlValue }),
...(transformationsFromMetadata(part.metadata) === undefined
? {}
: { transformations: transformationsFromMetadata(part.metadata)! }),
} satisfies AnthropicImageBlock
})
@@ -449,6 +701,26 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
const requireThinkingSignature = (request: LLMRequest) => {
if (request.model.compatibility?.requireSignature !== undefined)
return request.model.compatibility.requireSignature
const provider = request.model.provider.toLowerCase()
const model = request.model.id.toLowerCase()
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
if (
provider === "kimi-for-coding" ||
provider === "moonshotai" ||
provider === "moonshotai-cn" ||
model.startsWith("kimi-") ||
baseURL.includes("api.kimi.com/coding") ||
baseURL.includes("api.moonshot.ai/anthropic") ||
baseURL.includes("api.moonshot.cn/anthropic")
)
return false
if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false
return true
}
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
@@ -538,7 +810,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(yield* lowerMedia(part))
content.push(yield* lowerMedia(part, breakpoints))
continue
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
@@ -555,15 +827,30 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
// thinking; only signature-less parts carrying redactedData
// round-trip as opaque redacted_thinking blocks.
// A signature marks visible thinking; only signature-less parts carrying
// redactedData round-trip as opaque redacted_thinking blocks.
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
const redactedData = redactedDataFromMetadata(part.providerMetadata)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
}
if (typeof signature !== "string" || signature.trim().length === 0) {
if (part.text.trim().length === 0) continue
if (!requireThinkingSignature(request)) {
content.push({ type: "thinking", thinking: part.text, signature: "" })
continue
}
// Without a signature this cannot be a valid thinking block per
// the SDK ThinkingBlockParam:3217 — demote to text so the
// conversation remains sendable.
content.push({
type: "text",
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
})
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
continue
}
@@ -605,24 +892,74 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier = (input as Record<string, unknown> | undefined)?.service_tier ?? (input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) &&
(typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as string | { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
(input as Record<string, unknown> | undefined)?.inferenceGeo
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
const rawCacheControl =
(input as Record<string, unknown> | undefined)?.cache_control ??
(input as Record<string, unknown> | undefined)?.cacheControl
const cache_control =
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
: undefined
const rawOutputConfig =
(input as Record<string, unknown> | undefined)?.output_config ??
(input as Record<string, unknown> | undefined)?.outputConfig
const outputConfigEffort =
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
? ((input as Record<string, unknown>).effort as string)
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
? (rawOutputConfig.effort as string)
: undefined
const outputConfigFormat =
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const output_config =
outputConfigEffort === undefined && outputConfigFormat === undefined
? undefined
: {
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
}
return {
thinking: yield* resolveThinking(input?.thinking),
effort: typeof input?.effort === "string" ? input.effort : undefined,
effort: outputConfigEffort,
output_config,
service_tier,
metadata,
container,
inference_geo,
cache_control,
}
})
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "adaptive") {
const display =
input.display === "summarized"
? ("summarized" as const)
: input.display === "omitted"
? ("omitted" as const)
: undefined
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive")
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
}
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined
const budget =
@@ -633,7 +970,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget }
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
@@ -683,7 +1020,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
output_config: options.effort === undefined ? undefined : { effort: options.effort },
output_config: options.output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control,
container: options.container,
inference_geo: options.inference_geo,
metadata: options.metadata,
service_tier: options.service_tier,
}
})
@@ -794,7 +1137,8 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
const block = event.content_block
if (!block) return [state, NO_EVENTS]
if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) {
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -802,7 +1146,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
...state,
lifecycle,
tools: ToolStream.start(state.tools, event.index, {
id: block.id ?? String(event.index),
id: block.id,
name: block.name ?? "",
input:
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
@@ -814,7 +1158,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
[
...events,
LLMEvent.toolInputStart({
id: block.id ?? String(event.index),
id: block.id,
name: block.name ?? "",
providerExecuted: block.type === "server_tool_use" ? true : undefined,
}),
@@ -1004,15 +1348,28 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
}
const onError = (event: AnthropicEvent) =>
new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
})
Effect.fail(
new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
}),
)
const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event))
if (event.type === "content_block_start") {
const block = event.content_block
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
}
return Effect.succeed(onContentBlockStart(state, event))
}
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
@@ -1051,7 +1408,10 @@ export const route = Route.make({
provider: "anthropic",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
endpoint: Endpoint.path(
(input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH),
{ baseURL: DEFAULT_BASE_URL },
),
auth: Auth.none,
framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
+98 -54
View File
@@ -1,4 +1,4 @@
import { Effect, Schema } from "effect"
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -17,7 +17,7 @@ import {
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -41,6 +41,13 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
// so their tool-result attachments lower as a separate user turn instead.
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
// Blacklist: Gemini 1.x/2.x ignore or reject explicit function call ids.
// Every other model id (Gemini 3+, gemma, anything unrecognized) gets them.
const omitsFunctionCallIds = (modelID: string) => {
const match = /^gemini(?:-live)?-(\d+)/i.exec(modelID)
return match !== null && Number(match[1]) < 3
}
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
@@ -75,10 +82,15 @@ export type ProviderOptionsInput = OptionsInput
// =============================================================================
// Request Body Schema
// =============================================================================
// Gemini is known to send explicit `null` for optional streaming fields
// (usage counts, flags, whole subtrees), so every response-side optional uses
// `optionalNull` instead of bare `Schema.optional`. The same part/content
// schemas lower the outbound request body; encoding drops `undefined` keys,
// so the shared schemas stay safe there.
const GeminiTextPart = Schema.Struct({
text: Schema.String,
thought: Schema.optional(Schema.Boolean),
thoughtSignature: Schema.optional(Schema.String),
thought: optionalNull(Schema.Boolean),
thoughtSignature: optionalNull(Schema.String),
})
const GeminiInlineDataPart = Schema.Struct({
@@ -91,11 +103,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
const GeminiFunctionCallPart = Schema.Struct({
functionCall: Schema.Struct({
id: Schema.optional(Schema.String),
id: optionalNull(Schema.String),
name: Schema.String,
args: Schema.optional(Schema.Unknown),
}),
thoughtSignature: Schema.optional(Schema.String),
thoughtSignature: optionalNull(Schema.String),
})
const GeminiFunctionResponsePart = Schema.Struct({
@@ -113,13 +125,19 @@ const GeminiContentPart = Schema.Union([
GeminiFunctionCallPart,
GeminiFunctionResponsePart,
])
const decodeGeminiContentPart = Schema.decodeUnknownOption(GeminiContentPart)
const GeminiContent = Schema.Struct({
role: Schema.Literals(["user", "model"]),
parts: Schema.Array(GeminiContentPart),
role: optionalNull(Schema.Literals(["user", "model"])),
parts: optionalNull(Schema.Array(GeminiContentPart)),
})
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
const GeminiResponseContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
parts: optionalNull(Schema.Array(Schema.Unknown)),
})
const GeminiSystemInstruction = Schema.Struct({
parts: Schema.Array(Schema.Struct({ text: Schema.String })),
})
@@ -179,37 +197,38 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
const GeminiUsage = Schema.Struct({
cachedContentTokenCount: Schema.optional(Schema.Number),
thoughtsTokenCount: Schema.optional(Schema.Number),
promptTokenCount: Schema.optional(Schema.Number),
candidatesTokenCount: Schema.optional(Schema.Number),
totalTokenCount: Schema.optional(Schema.Number),
cachedContentTokenCount: optionalNull(Schema.Number),
thoughtsTokenCount: optionalNull(Schema.Number),
promptTokenCount: optionalNull(Schema.Number),
candidatesTokenCount: optionalNull(Schema.Number),
totalTokenCount: optionalNull(Schema.Number),
})
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
const GeminiCandidate = Schema.Struct({
content: Schema.optional(GeminiContent),
finishReason: Schema.optional(Schema.String),
content: optionalNull(GeminiResponseContent),
finishReason: optionalNull(Schema.String),
})
const GeminiPromptFeedback = Schema.StructWithRest(
Schema.Struct({
blockReason: Schema.optional(Schema.String),
blockReasonMessage: Schema.optional(Schema.String),
safetyRatings: Schema.optional(Schema.Unknown),
blockReason: optionalNull(Schema.String),
blockReasonMessage: optionalNull(Schema.String),
safetyRatings: optionalNull(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
const GeminiEvent = Schema.Struct({
candidates: optionalArray(GeminiCandidate),
promptFeedback: Schema.optional(GeminiPromptFeedback),
usageMetadata: Schema.optional(GeminiUsage),
candidates: optionalNull(Schema.Array(GeminiCandidate)),
promptFeedback: optionalNull(GeminiPromptFeedback),
usageMetadata: optionalNull(GeminiUsage),
})
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly route: string
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly promptFeedback?: GeminiPromptFeedback
@@ -217,6 +236,7 @@ interface ParserState {
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
readonly textSignature?: string
readonly seenCallIds?: ReadonlySet<string>
}
// =============================================================================
@@ -274,20 +294,14 @@ 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: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
thoughtSignature: thoughtSignature(part.providerMetadata),
})
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const omitCallIds = omitsFunctionCallIds(request.model.id)
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
let pendingMedia: GeminiInlineDataPart[] | undefined
const flushMedia = () => {
@@ -303,8 +317,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
const previous = contents.at(-1)
// Gemini rejects a continuation whose function-response turn carries extra
// parts, so an update after a tool result starts its own user turn.
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
if (previous?.role === "user" && !(previous.parts ?? []).some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), { text: part.text }] }
else contents.push({ role: "user", parts: [{ text: part.text }] })
continue
}
@@ -336,7 +350,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
continue
}
if (part.type === "tool-call") {
const lowered = lowerToolCall(part)
const lowered = lowerToolCall(part, omitCallIds)
const signature = lowered.thoughtSignature
parts.push({
...lowered,
@@ -361,7 +375,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (part.result.type !== "content") {
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
...(omitCallIds ? {} : { id: part.id }),
name: part.name,
response: {
name: part.name,
@@ -382,7 +396,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
...(omitCallIds ? {} : { id: part.id }),
name: part.name,
response: {
name: part.name,
@@ -395,8 +409,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
// Gemini requires every response to a parallel call batch in one user turn,
// so consecutive tool results join the open function-response turn.
const previous = contents.at(-1)
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
else contents.push({ role: "user", parts })
}
@@ -486,21 +500,25 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// to produce the inclusive `outputTokens` the rest of the contract expects.
const mapUsage = (usage: GeminiUsage | undefined) => {
if (!usage) return undefined
const cached = usage.cachedContentTokenCount
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
// token arithmetic below treats them like absent counts.
const promptTokens = usage.promptTokenCount ?? undefined
const cached = usage.cachedContentTokenCount ?? undefined
const thoughts = usage.thoughtsTokenCount ?? undefined
const visible = usage.candidatesTokenCount ?? undefined
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
// inclusive `outputTokens` the contract expects. Only compute the total
// when the visible component is reported — otherwise we'd fabricate an
// inclusive number from a partial breakdown.
const outputTokens =
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
return new Usage({
inputTokens: usage.promptTokenCount,
inputTokens: promptTokens,
outputTokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
reasoningTokens: usage.thoughtsTokenCount,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
providerMetadata: { google: usage },
})
}
@@ -535,7 +553,10 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
}
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
// `?? undefined` normalizes an explicit `null` blockReason back to absent so
// the "nothing to finish" check below keeps its meaning.
const promptBlockReason =
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
const finishReason = state.finishReason ?? promptBlockReason
if (finishReason === undefined && state.usage === undefined) return []
@@ -581,8 +602,24 @@ const step = (state: ParserState, event: GeminiEvent) => {
let lifecycle = nextState.lifecycle
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
for (const part of candidate.content.parts) {
for (const input of candidate.content.parts ?? []) {
if (
ProviderShared.isRecord(input) &&
!("text" in input) &&
!("inlineData" in input) &&
!("functionCall" in input) &&
!("functionResponse" in input)
)
continue
const decoded = decodeGeminiContentPart(input)
if (Option.isNone(decoded))
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const part = decoded.value
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
@@ -618,13 +655,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
// Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
// Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
}
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
const supplied = part.functionCall.id ?? undefined
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
if (supplied !== undefined) seenCallIds.add(supplied)
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
@@ -637,7 +675,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
id,
name: part.functionCall.name,
input,
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
providerMetadata:
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
}),
)
hasToolCalls = true
@@ -651,6 +690,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
reasoningSignature,
textSignature,
seenCallIds,
finishReason: candidate.finishReason ?? nextState.finishReason,
},
events,
@@ -672,7 +712,11 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
initial: (request) => ({
route: `${request.model.provider}/${request.model.route.id}`,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
}),
step,
onHalt: finish,
},
@@ -112,6 +112,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
responseID = created
return { type: "frame", frame }
}
// Keepalives carry no response state and may arrive before response.created.
if (event.type === "keepalive") return { type: "frame", frame }
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
+137 -90
View File
@@ -5,6 +5,7 @@ import { Protocol } from "../route/protocol.js"
import {
AIError,
LLMEvent,
ProviderInternalReason,
Usage,
type FinishReason,
type JsonSchema,
@@ -61,6 +62,11 @@ const OpenResponsesOutputText = Schema.Struct({
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
}
const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
@@ -242,6 +248,7 @@ const OpenResponsesErrorPayload = Schema.Struct({
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
@@ -306,19 +313,8 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
const RefusalEvent = Schema.Union([
Schema.Struct({
type: Schema.tag("response.refusal.delta"),
item_id: Schema.String,
delta: Schema.String,
}),
Schema.Struct({
type: Schema.tag("response.refusal.done"),
item_id: Schema.String,
refusal: Schema.String,
}),
])
const isRefusalEvent = Schema.is(RefusalEvent)
// Which lowered input item a persisted item id is about to be attached to.
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
export interface Extension {
readonly id: string
@@ -328,6 +324,10 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
// Optional grammar check applied before a persisted item id is resent as
// part of replayed history. Returning false drops the id; every lowered
// item treats a dropped id the same as an absent one.
readonly acceptsItemID?: (kind: ItemKind, id: string) => boolean
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -340,7 +340,6 @@ export interface ParserState {
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
@@ -354,6 +353,10 @@ interface ReasoningStreamItem {
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
// Summary indexes that received at least one streamed delta. The `:0` block
// is started eagerly when the item opens, so block existence cannot tell
// whether a `.done` final would duplicate streamed text.
readonly deltaIndexes: ReadonlySet<number>
}
// =============================================================================
@@ -370,7 +373,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.responses(inputSchema),
parameters: inputSchema,
// The common tool definition does not currently express Responses strict-schema policy.
strict: false,
}
@@ -384,28 +387,46 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
// Servers validate item ids on replayed history, and a malformed or oversized
// id can fail an otherwise valid request. Only server-issued tokens are worth
// resending; anything else is treated as absent so the item is resent without
// an id (or skipped, for items that cannot be expressed without one).
const ITEM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
return ProviderShared.isRecord(metadata) &&
typeof metadata.itemId === "string" &&
ITEM_ID_PATTERN.test(metadata.itemId)
? metadata.itemId
: undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const acceptsItemID = (extension: Extension, kind: ItemKind, id: string | undefined): id is string =>
id !== undefined && (extension.acceptsItemID?.(kind, id) ?? true)
const lowerToolCall = (
part: ToolCallPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(id ? { id } : {}),
...(acceptsItemID(extension, "function-call", id) ? { id } : {}),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
if (!ProviderShared.isRecord(metadata) || !id) return undefined
if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
@@ -418,10 +439,6 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
return itemID(part.providerMetadata, providerMetadataKey)
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
@@ -441,7 +458,7 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
return {
type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
...(url ? { file_url: url } : { file_data: media.base64 }),
...(url ? { file_url: url } : { file_data: media.dataUrl }),
}
}
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
@@ -541,7 +558,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
const rawID = itemID(part.providerMetadata, providerMetadataKey)
const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
@@ -566,7 +584,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
@@ -587,16 +605,22 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey))
input.push(lowerToolCall(part, providerMetadataKey, extension))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const itemID = hostedToolItemID(part, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
const id = itemID(part.providerMetadata, providerMetadataKey)
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
if (store !== false && reference && !hostedToolReferences.has(reference))
input.push({ type: "item_reference", id: reference })
if (store === false) {
// The server is not storing this exchange, so the tool outcome has to
// travel in the input. Non-content results degrade to their text form.
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
@@ -604,7 +628,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
),
})
}
if (itemID) hostedToolReferences.add(itemID)
if (reference) hostedToolReferences.add(reference)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -641,6 +665,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
@@ -650,7 +676,7 @@ const lowerOptions = (request: LLMRequest) => {
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
@@ -658,11 +684,18 @@ const lowerOptions = (request: LLMRequest) => {
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(parallelToolCalls !== undefined ? { parallel_tool_calls: parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
export const resolveParallelToolCalls = (request: LLMRequest) => {
const configured = OpenResponsesOptions.resolve(request).parallelToolCalls
if (configured !== undefined) return configured
const disabled = request.toolChoice?.disableParallelToolUse
return disabled === undefined ? undefined : !disabled
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
@@ -766,7 +799,7 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
@@ -784,20 +817,33 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
const events: LLMEvent[] = []
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...state.reasoningItems,
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
},
},
events,
]
}
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
// Some compatible gateways emit a reasoning final without streaming any
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
@@ -816,18 +862,17 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id)
if (item?.type === "message" && item.id) {
const phase = messagePhase(item.phase)
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: (() => {
const phase = state.messagePhase(item.phase)
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
})(),
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
},
NO_EVENTS,
]
}
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -836,7 +881,11 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
[item.id]: {
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
},
},
},
events,
@@ -866,27 +915,9 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
if (event.summary_index === 0) {
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
},
},
events,
]
}
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
@@ -965,7 +996,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
state: ParserState,
event: Event,
) {
if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult
if (!event.item_id || !event.delta || !state.tools[event.item_id]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
state.id,
state.tools,
@@ -985,7 +1016,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
const itemPhase = state.messagePhase(item.phase)
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
@@ -1093,32 +1124,45 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build a single human-readable message from whatever the provider supplied.
// Build the prettiest summary available from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops.
const providerErrorMessage = (event: Event, fallback: string): string => {
const nested = event.error ?? event.response?.error ?? undefined
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code || fallback
return message || code
}
export const providerFailure = (id: string, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback)
const nested = event.error ?? event.response?.error ?? undefined
const code = event.code || nested?.code || undefined
// Keep the full raw payload on the error even when the message is a summary.
const body = JSON.stringify(nested ?? event) ?? ""
const summary = providerErrorMessage(event, nested)
const message = summary ?? (body === "{}" ? fallback : body)
const status =
typeof event.status === "number"
? event.status
: typeof event.status_code === "number"
? event.status_code
: undefined
const reason =
event.type === "error" &&
event.error === undefined &&
event.response === undefined &&
summary === undefined &&
status === undefined
? new ProviderInternalReason({ message })
: classifyProviderFailure({ message, code, status, rawBody: body })
return new AIError({
module: id,
method: "stream",
reason: classifyProviderFailure({ message, code, status }),
body,
reason,
})
}
@@ -1134,20 +1178,26 @@ export const step = (state: ParserState, event: Event) => {
)
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
if (!event.item_id || typeof value !== "string")
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event))
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
@@ -1162,7 +1212,10 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
}
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.function_call_arguments.delta")
return event.item_id
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
@@ -1193,17 +1246,11 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase,
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
}
export const protocol = Protocol.make({
id: ADAPTER,
body: {
+186 -17
View File
@@ -51,7 +51,12 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: OpenAIChatFunction,
function: Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
}),
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -133,6 +138,7 @@ export const bodyFields = {
store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
tool_stream: Schema.optional(Schema.Boolean),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -156,6 +162,9 @@ const OpenAIChatUsage = Schema.StructWithRest(
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
cached_tokens: optionalNull(Schema.Number),
prompt_cache_hit_tokens: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
@@ -204,11 +213,16 @@ const OpenAIChatDelta = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatChoice = Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
native_finish_reason: optionalNull(Schema.String),
})
const OpenAIChatChoice = Schema.StructWithRest(
Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
native_finish_reason: optionalNull(Schema.String),
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
usage: optionalNull(OpenAIChatUsage),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
@@ -256,12 +270,18 @@ interface LoweringOptions {
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
const lowerTool = (
tool: ToolDefinition,
inputSchema: JsonSchema,
options: LoweringOptions,
supportsStrictMode: boolean,
): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
parameters: inputSchema,
...(supportsStrictMode ? { strict: false } : {}),
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -509,11 +529,134 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
return messages
})
const lowerOptions = (request: LLMRequest) => {
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
// whenever the conversation history contains tool calls/results. Send an
// explicit empty array when we have history but no active tools.
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
for (const message of messages) {
if (message.role === "tool") return true
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
}
return false
}
// Derive `max_tokens` vs `max_completion_tokens` from provider/baseURL when
// explicit `compatibility.maxTokensField` is not set. Aligned with
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
// require `max_tokens`.
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
if (
p === "deepseek" ||
url.includes("deepseek.com") ||
p === "moonshotai" ||
url.includes("api.moonshot.ai") ||
p === "togetherai" ||
url.includes("api.together.") ||
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn") ||
p === "nvidia" ||
url.includes("integrate.api.nvidia.com") ||
p === "cerebras" ||
url.includes("cerebras.ai") ||
url.includes("llm.chutes.ai") ||
p === "chutes" ||
p === "cloudflare-ai-gateway" ||
url.includes("gateway.ai.cloudflare.com") ||
p === "cloudflare-workers-ai" ||
url.includes("api.cloudflare.com")
)
return "max_tokens"
return "max_completion_tokens"
}
const detectSupportsStore = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
const isDeepSeek = p === "deepseek" || url.includes("deepseek.com")
const isCerebras = p === "cerebras" || url.includes("cerebras.ai")
const isXai = p === "xai" || url.includes("api.x.ai")
const isChutes = p === "chutes" || url.includes("chutes.ai")
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isNonStandard =
isNvidia ||
isCerebras ||
isXai ||
isTogether ||
isChutes ||
isDeepSeek ||
isZai ||
isMoonshot ||
isOpencode ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isVercelAiGateway ||
isAntLing
return !isNonStandard
}
const detectSupportsUsageInStreaming = (): boolean => true
const detectSupportsStrictMode = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
}
const detectZaiToolStream = (
provider: string,
baseURL: string | undefined,
modelID: string,
): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
if (!isZai) return false
const id = modelID.toLowerCase()
if (id === "glm-4.5" || id === "glm-4.5-air" || id === "glm-4.5-flash" || id === "glm-4.5v") return false
return true
}
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
// even when no explicit `providerOptions.store` was supplied, mirroring the
// native OpenAI Chat default. Non-standard providers omit `store` entirely.
...(supportsStore && options.store === undefined ? { store: false } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
}
@@ -531,23 +674,39 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
const provider = String(request.model.provider)
const baseURL = request.model.route.endpoint.baseURL
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
const maxTokensField = request.model.compatibility?.maxTokensField ?? detectedMaxTokensField
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
const supportsUsageInStreaming =
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
const zaiToolStream =
request.model.compatibility?.zaiToolStream ??
detectZaiToolStream(provider, baseURL, request.model.id)
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = request.tools.length > 0
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
tools:
request.tools.length === 0
? undefined
? hasHistory
? []
: undefined
: request.tools.map((tool) =>
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
supportsStrictMode,
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
stream_options: { include_usage: true },
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
@@ -557,7 +716,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
presence_penalty: generation?.presencePenalty,
seed: generation?.seed,
stop: generation?.stop,
...lowerOptions(request),
...lowerOptions(request, supportsStore),
}
})
@@ -581,11 +740,18 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `AI.Usage` contract is
// satisfied on both sides.
// Providers differ on cache-hit location: OpenAI uses
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
const cached =
(usage.prompt_tokens_details?.cached_tokens ??
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
(usage as { cached_tokens?: number | null }).cached_tokens ??
undefined) as number | undefined
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
@@ -691,8 +857,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
}),
})
const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices?.[0]
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
// `choice.usage` instead of the top-level `usage` field.
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const rawFinishReason = choice?.finish_reason
const finishReason =
rawFinishReason !== undefined && rawFinishReason !== null
+23 -7
View File
@@ -51,9 +51,28 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
// Replayed items are paired with stored server state by id, so a foreign or
// synthetic token can fail request validation even when `call_id` pairing is
// intact. Only resend ids in each item kind's own grammar; hosted tool
// references keep generic validation because every hosted tool mints its own
// prefix. The same allowlist approach codex uses before resending history
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
message: ["msg_"],
reasoning: ["rs_"],
"function-call": ["fc_"],
// Every hosted tool mints its own id prefix, so references keep generic
// validation only.
reference: [],
}
const extension = {
id: ADAPTER,
name: NAME,
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
const prefixes = ITEM_ID_PREFIXES[kind]
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
},
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -92,8 +111,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return {
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
request.tools.length === 0
? undefined
@@ -134,24 +155,19 @@ const HOSTED_TOOLS = {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
if (event.type === "response.reasoning_text.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+11
View File
@@ -24,6 +24,17 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
if (key === undefined) return undefined
const chars = Array.from(key)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
/**
* Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here
@@ -28,7 +28,11 @@ export const ResponseIncludables = [
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number]
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
export const ServiceTier = Schema.declare<ServiceTier>(
(value): value is ServiceTier => typeof value === "string",
{ title: "ServiceTier" },
)
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
@@ -38,7 +42,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
{ title: "ResponseIncludable" },
)
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export const ServiceTierSchema = ServiceTier
export const TruncationSchema = Schema.Literals(Truncations)
export const AllowedTools = Schema.Struct({
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
@@ -0,0 +1,64 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js/blob/main/src/options.ts
* Licensed under the MIT License; see partial-json.ts for the complete notice.
*/
/**
* allow partial strings like `"hello \u12` to be parsed as `"hello `
*/
export const STR = 0b000000001
/**
* allow partial numbers like `123.` to be parsed as `123`
*/
export const NUM = 0b000000010
/**
* allow partial arrays like `[1, 2,` to be parsed as `[1, 2]`
*/
export const ARR = 0b000000100
/**
* allow partial objects like `{"a": 1, "b":` to be parsed as `{"a": 1}`
*/
export const OBJ = 0b000001000
/**
* allow `nu` to be parsed as `null`
*/
export const NULL = 0b000010000
/**
* allow `tr` to be parsed as `true`, and `fa` to be parsed as `false`
*/
export const BOOL = 0b000100000
/**
* allow `Na` to be parsed as `NaN`
*/
export const NAN = 0b001000000
/**
* allow `Inf` to be parsed as `Infinity`
*/
export const INFINITY = 0b010000000
/**
* allow `-Inf` to be parsed as `-Infinity`
*/
export const _INFINITY = 0b100000000
export const INF = INFINITY | _INFINITY
export const SPECIAL = NULL | BOOL | INF | NAN
export const ATOM = STR | NUM | SPECIAL
export const COLLECTION = ARR | OBJ
export const ALL = ATOM | COLLECTION
/**
* Control what types you allow to be partially parsed.
* The default is to allow all types to be partially parsed, which in most cases is the best option.
*/
export const Allow = { STR, NUM, ARR, OBJ, NULL, BOOL, NAN, INFINITY, _INFINITY, INF, SPECIAL, ATOM, COLLECTION, ALL }
export default Allow
@@ -0,0 +1,223 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js
*
* MIT License
*
* Copyright (c) 2023 Promplate Dev Team
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Schema } from "effect"
import { Allow } from "./partial-json-options.js"
export * from "./partial-json-options.js"
export class PartialJSON extends Error {}
export class MalformedJSON extends Error {}
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))
/** Parse complete or incomplete JSON, restricted by the supplied partial-value flags. */
export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown {
if (typeof jsonString !== "string") throw new TypeError(`expecting str, got ${typeof jsonString}`)
const input = jsonString.trim()
if (!input) throw new Error(`${jsonString} is empty`)
try {
return decodeJson(input)
} catch {}
return _parseJSON(input, allowPartial)
}
const _parseJSON = (jsonString: string, allow: number) => {
const length = jsonString.length
let index = 0
const markPartialJSON = (message: string): never => {
throw new PartialJSON(`${message} at position ${index}`)
}
const throwMalformedError = (message: string): never => {
throw new MalformedJSON(`${message} at position ${index}`)
}
const parseAny = (): unknown => {
skipBlank()
if (index >= length) markPartialJSON("Unexpected end of input")
if (jsonString[index] === '"') return parseStr()
if (jsonString[index] === "{") return parseObj()
if (jsonString[index] === "[") return parseArr()
if (
jsonString.substring(index, index + 4) === "null" ||
(Allow.NULL & allow && length - index < 4 && "null".startsWith(jsonString.substring(index)))
) {
index += 4
return null
}
if (
jsonString.substring(index, index + 4) === "true" ||
(Allow.BOOL & allow && length - index < 4 && "true".startsWith(jsonString.substring(index)))
) {
index += 4
return true
}
if (
jsonString.substring(index, index + 5) === "false" ||
(Allow.BOOL & allow && length - index < 5 && "false".startsWith(jsonString.substring(index)))
) {
index += 5
return false
}
if (
jsonString.substring(index, index + 8) === "Infinity" ||
(Allow.INFINITY & allow && length - index < 8 && "Infinity".startsWith(jsonString.substring(index)))
) {
index += 8
return Infinity
}
if (
jsonString.substring(index, index + 9) === "-Infinity" ||
(Allow._INFINITY & allow &&
1 < length - index &&
length - index < 9 &&
"-Infinity".startsWith(jsonString.substring(index)))
) {
index += 9
return -Infinity
}
if (
jsonString.substring(index, index + 3) === "NaN" ||
(Allow.NAN & allow && length - index < 3 && "NaN".startsWith(jsonString.substring(index)))
) {
index += 3
return NaN
}
return parseNum()
}
const parseStr = (): string => {
const start = index
let escape = false
index++
while (index < length && (jsonString[index] !== '"' || (escape && jsonString[index - 1] === "\\"))) {
escape = jsonString[index] === "\\" ? !escape : false
index++
}
if (jsonString.charAt(index) === '"') {
try {
return decodeJson(jsonString.substring(start, ++index - Number(escape))) as string
} catch (error) {
throwMalformedError(String(error))
}
}
if (Allow.STR & allow) {
try {
return decodeJson(`${jsonString.substring(start, index - Number(escape))}"`) as string
} catch {
return decodeJson(`${jsonString.substring(start, jsonString.lastIndexOf("\\"))}"`) as string
}
}
return markPartialJSON("Unterminated string literal")
}
const parseObj = (): Record<string, unknown> => {
index++
skipBlank()
const object: Record<string, unknown> = {}
try {
while (jsonString[index] !== "}") {
skipBlank()
if (index >= length && Allow.OBJ & allow) return object
const key = parseStr()
skipBlank()
index++
try {
object[key] = parseAny()
} catch (error) {
if (Allow.OBJ & allow) return object
throw error
}
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.OBJ & allow) return object
return markPartialJSON("Expected '}' at end of object")
}
index++
return object
}
const parseArr = (): unknown[] => {
index++
const array: unknown[] = []
try {
while (jsonString[index] !== "]") {
array.push(parseAny())
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.ARR & allow) return array
return markPartialJSON("Expected ']' at end of array")
}
index++
return array
}
const parseNum = (): unknown => {
if (index === 0) {
if (jsonString === "-") throwMalformedError("Not sure what '-' is")
try {
return decodeJson(jsonString)
} catch (error) {
if (Allow.NUM & allow) {
try {
return decodeJson(jsonString.substring(0, jsonString.lastIndexOf("e")))
} catch {}
}
throwMalformedError(String(error))
}
}
const start = index
if (jsonString[index] === "-") index++
while (jsonString[index] && !",]}".includes(jsonString[index])) index++
if (index === length && !(Allow.NUM & allow)) markPartialJSON("Unterminated number literal")
try {
return decodeJson(jsonString.substring(start, index))
} catch (error) {
if (jsonString.substring(start, index) === "-") markPartialJSON("Not sure what '-' is")
try {
return decodeJson(jsonString.substring(start, jsonString.lastIndexOf("e")))
} catch {
throwMalformedError(String(error))
}
}
}
const skipBlank = () => {
while (index < length && " \n\r\t".includes(jsonString[index])) index++
}
return parseAny()
}
export const parse = parseJSON
+1 -32
View File
@@ -2,20 +2,6 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch
import { isRecord } from "../../utils/record.js"
import { GeminiToolSchema } from "./gemini-tool-schema.js"
const removeNullSchemas = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(removeNullSchemas)
if (!isRecord(value)) return value
const fields = Object.fromEntries(
Object.entries(value)
.filter(([key]) => key !== "anyOf")
.map(([key, field]) => [key, removeNullSchemas(field)]),
)
if (!Array.isArray(value.anyOf)) return fields
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
return { ...fields, anyOf: variants }
}
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
const projected = items.map(moonshotNode)
if (projected.length === 0) return {}
@@ -45,24 +31,7 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
return isRecord(projected) ? projected : {}
}
const openAI = (schema: JsonSchema): JsonSchema => {
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
const flattened =
variants.length === 0
? { ...schema, type: "object" }
: {
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
type: "object",
properties: variants.reduce(
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
{},
),
additionalProperties: false,
}
const normalized = removeNullSchemas(flattened)
return isRecord(normalized) ? normalized : { type: "object" }
}
const openAI = (schema: JsonSchema): JsonSchema => schema
const responses = openAI
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
+2 -10
View File
@@ -1,7 +1,5 @@
import { Effect } from "effect"
import { Protocol } from "../route/protocol.js"
import { OpenResponses } from "./open-responses.js"
import { ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
@@ -27,15 +25,9 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+7 -2
View File
@@ -80,6 +80,9 @@ export interface ProviderFailure {
readonly message: string
readonly status?: number | undefined
readonly code?: string | undefined
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
// that the summary message does not carry. Not shown to users.
readonly rawBody?: string | undefined
readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
readonly http?: HttpContext | undefined
@@ -89,11 +92,13 @@ export interface ProviderFailure {
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const body = input.http?.body ?? ""
const body = input.http?.body ?? input.rawBody ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined)
.map((code) => code.toLowerCase())
const text = body || input.message
// Scan the raw payload too so signals missing from the summary message
// (e.g. overflow phrases nested in a JSON error body) still classify.
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
+11 -1
View File
@@ -38,13 +38,23 @@ export type Settings = ProviderPackage.Settings &
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
const body = yield* Gemini.protocol.body.from(request)
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
// unlike AI Studio, so history minted there cannot be lowered verbatim.
const contents = body.contents.map((content) => ({
...content,
parts: (content.parts ?? []).map((part) => {
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
return part
}),
}))
const value = request.providerOptions?.labels
const labels = ProviderShared.isRecord(value)
? Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
: undefined
return { ...body, labels }
return { ...body, contents, labels }
})
const protocol = {
+6 -2
View File
@@ -1,9 +1,13 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = OpenResponsesOptionsInput
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
readonly serviceTier?: OpenAIServiceTier
readonly [key: string]: unknown
}
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
+3 -2
View File
@@ -9,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord } from "../protocols/shared.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
@@ -115,11 +115,12 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails,
}
})
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
} as OpenRouterBody
}),
),
+3
View File
@@ -153,6 +153,9 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
module: Schema.String,
method: Schema.String,
reason: AIErrorReason,
// Raw provider payload as a string, so classified failures never lose the
// original error detail even when the pretty message is a summary.
body: Schema.optional(Schema.String),
}) {
override readonly cause = this.reason
+2
View File
@@ -51,6 +51,7 @@ export const MediaPart = Schema.Struct({
mediaType: Schema.String,
data: Schema.Union([Schema.String, Schema.Uint8Array]),
filename: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
@@ -255,6 +256,7 @@ export namespace ToolDefinition {
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
type: Schema.Literals(["auto", "none", "required", "tool"]),
name: Schema.optional(Schema.String),
disableParallelToolUse: Schema.optional(Schema.Boolean),
}) {}
export namespace ToolChoice {
+5
View File
@@ -155,6 +155,11 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+1 -1
View File
@@ -66,7 +66,7 @@ describe("request option precedence", () => {
expect(prepared.body).toMatchObject({
model: "gpt-4o-mini",
stream: true,
max_tokens: 30,
max_completion_tokens: 30,
temperature: 0.5,
top_p: 0.9,
frequency_penalty: 0.25,
@@ -0,0 +1,31 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"name": "azure/chat-streams-text",
"recordedAt": "2026-08-23T17:21:53.198Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiden-azury-group.openai.azure.com/openai/v1/chat/completions?api-version=v1",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.6-luna\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"medium\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"choices\":[],\"created\":0,\"id\":\"\",\"model\":\"\",\"object\":\"\",\"prompt_filter_results\":[{\"prompt_index\":0,\"content_filter_results\":{}}]}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"\",\"refusal\":null,\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"Mxr\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"hello\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"WyZa5AY1CaCeFdS\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"latency_checkpoint\":{\"engine_tbt_ms\":20,\"engine_ttft_ms\":106,\"engine_ttlt_ms\":206,\"pre_inference_ms\":89,\"service_tbt_ms\":20,\"service_ttft_ms\":480,\"service_ttlt_ms\":576,\"total_duration_ms\":491,\"user_visible_ttft_ms\":391},\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"6\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":5,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"audio_tokens\":0,\"reasoning_tokens\":0,\"rejected_prediction_tokens\":0},\"prompt_tokens\":13,\"prompt_tokens_details\":{\"audio_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0},\"total_tokens\":18}}\n\ndata: [DONE]\n\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
@@ -7,7 +7,13 @@
"route": "cloudflare-workers-ai",
"transport": "http",
"model": "@cf/openai/gpt-oss-20b",
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
"tags": [
"prefix:cloudflare-workers-ai",
"provider:cloudflare-workers-ai",
"tool",
"tool-call",
"golden"
]
},
"interactions": [
{
@@ -18,7 +24,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}"
"body": "{\"model\": \"@cf/openai/gpt-oss-20b\", \"messages\": [{\"role\": \"system\", \"content\": \"Call tools exactly as requested.\"}, {\"role\": \"user\", \"content\": \"Call get_weather with city exactly Paris.\"}], \"tools\": [{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get current weather for a city.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"], \"additionalProperties\": false}, \"strict\": false}}], \"tool_choice\": {\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}, \"stream\": true, \"stream_options\": {\"include_usage\": true}, \"max_tokens\": 120, \"temperature\": 0}"
},
"response": {
"status": 200,
@@ -29,4 +35,4 @@
}
}
]
}
}
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"name": "google-vertex/calls-a-tool",
"recordedAt": "2026-08-23T17:21:51.036Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
}
}
]
}
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"name": "google-vertex/continues-after-a-tool-result",
"recordedAt": "2026-08-23T17:21:51.853Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
}
}
]
}
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"name": "google-vertex/streams-text",
"recordedAt": "2026-08-23T17:21:50.112Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly one word: hello\"}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { Allow, MalformedJSON, PartialJSON, parse } from "../src/protocols/utils/partial-json.js"
describe("partial JSON", () => {
test("parses complete JSON", () => {
expect(parse('{"key":"value","items":[1,true,null]}')).toEqual({
key: "value",
items: [1, true, null],
})
const object = parse('{"__proto__":{"safe":true}}') as Record<string, unknown>
expect(Object.hasOwn(object, "__proto__")).toBe(true)
})
test("parses partial strings", () => {
expect(parse('"hello')).toBe("hello")
expect(parse('"hello \\u12')).toBe("hello ")
expect(() => parse('"hello', ~Allow.STR)).toThrow(PartialJSON)
})
test("controls partial collection values independently", () => {
expect(parse('["', Allow.ARR)).toEqual([])
expect(parse('["', Allow.ARR | Allow.STR)).toEqual([""])
expect(parse('{"key":"', Allow.OBJ)).toEqual({})
expect(parse('{"key":"', Allow.OBJ | Allow.STR)).toEqual({ key: "" })
})
test("parses partial literals and numbers", () => {
expect(parse("nu", Allow.NULL)).toBeNull()
expect(parse("tr", Allow.BOOL)).toBe(true)
expect(parse("fa", Allow.BOOL)).toBe(false)
expect(parse("1e", Allow.NUM)).toBe(1)
})
test("distinguishes disallowed partial values from malformed values", () => {
expect(() => parse("[", Allow.STR)).toThrow(PartialJSON)
expect(() => parse("n", ~Allow.NULL)).toThrow(MalformedJSON)
})
test("rejects empty input", () => {
expect(() => parse(" ")).toThrow("is empty")
})
})
+17
View File
@@ -106,3 +106,20 @@ describe("provider error classification", () => {
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
})
})
describe("provider error rawBody classification", () => {
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
const reason = classifyProviderFailure({
message: "Request failed",
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
})
expect(reason._tag).toBe("InvalidRequest")
expect(reason).toMatchObject({ classification: "context-overflow" })
})
test("extracts nested codes from the raw payload", () => {
expect(
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
).toBe("QuotaExceeded")
})
})
@@ -4,10 +4,4 @@ import { GoogleVertexChat } from "../../src/providers.js"
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
providerOptions: { serviceTier: "premium" },
})
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
@@ -8,6 +8,8 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
@@ -18,6 +18,15 @@ const opus48 = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-opus-4-8" })
const compileUnsignedReasoning = (model: LLMRequest["model"]) =>
compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "reasoning", text: "unsigned reasoning" }])],
cache: "none",
}),
)
const request = LLM.request({
id: "req_1",
model,
@@ -564,6 +573,66 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("demotes unsigned reasoning when signatures are required", () =>
Effect.gen(function* () {
const prepared = yield* compileUnsignedReasoning(model)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("infers empty-signature compatibility across Kimi providers", () =>
Effect.gen(function* () {
const coding = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://compatible.test/v1/" },
auth: Auth.header("x-api-key", "test"),
})
const moonshot = AnthropicMessages.route
.with({
provider: "moonshotai",
endpoint: { baseURL: "https://api.moonshot.ai/anthropic" },
auth: Auth.bearer("test"),
})
.model({ id: "kimi-k2.6" })
const codingPrepared = yield* compileUnsignedReasoning(coding.model({ id: "k3" }))
const moonshotPrepared = yield* compileUnsignedReasoning(moonshot)
expect(codingPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
expect(moonshotPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
}),
)
it.effect("lets an explicit signature requirement override inference", () =>
Effect.gen(function* () {
const compatible = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://api.kimi.com/coding/v1/" },
auth: Auth.header("x-api-key", "test"),
})
.model({ id: "k3", compatibility: { requireSignature: true } })
const prepared = yield* compileUnsignedReasoning(compatible)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -0,0 +1,93 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
import { Azure } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const resourceName = process.env.AZURE_OPENAI_RESOURCE_NAME ?? "aiden-azury-group"
const chatModel = Azure.configure({
resourceName,
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
}).chat("gpt-5.6-luna")
const responsesModel = Azure.configure({
resourceName,
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
}).responses("gpt-5.6-luna")
const lookupWeather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
})
const recorded = recordedTests({
prefix: "azure",
provider: "azure",
requires: ["AZURE_OPENAI_API_KEY"],
})
describe("Azure OpenAI recorded", () => {
recorded.effect("chat streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }),
)
expect(response.text.toLowerCase()).toContain("hello")
}),
)
recorded.effect("responses streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }),
)
expect(response.text.toLowerCase()).toContain("bonjour")
}),
)
recorded.effect("responses calls a tool", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: responsesModel,
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
tools: [lookupWeather],
}),
)
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
expect(call).toBeDefined()
expect(call?.input).toMatchObject({ city: "Paris" })
}),
)
recorded.effect("responses continues after a tool result", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: responsesModel,
messages: [
Message.user("What is the weather in Paris?"),
Message.assistant([
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
]),
Message.tool({
id: "call_paris_1",
name: "lookup_weather",
result: "18C, light rain",
resultType: "text",
}),
],
tools: [lookupWeather],
}),
)
expect(response.text.length).toBeGreaterThan(0)
}),
)
})
+260 -18
View File
@@ -156,14 +156,13 @@ describe("Gemini route", () => {
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
@@ -201,8 +200,8 @@ describe("Gemini route", () => {
{
role: "model",
parts: [
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
{ functionCall: { name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "time" } } },
],
},
{
@@ -210,14 +209,12 @@ describe("Gemini route", () => {
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "noon" },
},
@@ -228,6 +225,104 @@ describe("Gemini route", () => {
}),
)
it.effect("lowers function call ids for gemini 3 models", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{
functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "call_1",
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
])
}),
)
it.effect("omits function call ids entirely for pre-gemini-3 models", () =>
Effect.gen(function* () {
const messages = [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
]
const legacy = yield* compileRequest(LLM.request({ model, messages }))
const older = yield* compileRequest(
LLM.request({
model: Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-1.5-flash" }),
messages,
}),
)
expect(legacy.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "done" } } }],
},
])
expect(JSON.stringify(legacy.body.contents)).not.toContain('"id"')
expect(JSON.stringify(older.body.contents)).not.toContain('"id"')
}),
)
it.effect("includes function call ids for non-gemini model ids", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemma-3-27b-it" }),
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [
{ functionResponse: { id: "call_1", name: "lookup", response: { name: "lookup", content: "done" } } },
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -419,13 +514,16 @@ describe("Gemini route", () => {
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
parts: [
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "call_image",
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
@@ -808,6 +906,54 @@ describe("Gemini route", () => {
}),
)
it.effect("ignores unknown response parts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: "Hello " },
{ executableCode: { language: "PYTHON", code: "print('ignored')" } },
{ text: "world" },
],
},
finishReason: "STOP",
},
],
}),
),
),
)
expect(response.text).toBe("Hello world")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
}),
)
it.effect("rejects malformed recognized response parts", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [{ content: { role: "model", parts: [{ text: 42 }] } }],
}),
),
),
Effect.flip,
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Invalid google/gemini stream event")
}),
)
it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () =>
Effect.gen(function* () {
const body = sseEvents({
@@ -849,7 +995,7 @@ describe("Gemini route", () => {
})
expect(toolCall).toMatchObject({
id: "provider_call",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
providerMetadata: { google: { thoughtSignature: "tool_sig" } },
})
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex((event) => event.type === "tool-call"),
@@ -857,7 +1003,7 @@ describe("Gemini route", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
model: gemini3,
messages: [
Message.assistant([
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
@@ -873,7 +1019,6 @@ describe("Gemini route", () => {
name: "lookup",
result: "done",
resultType: "text",
providerMetadata: toolCall?.providerMetadata,
}),
],
}),
@@ -977,7 +1122,7 @@ describe("Gemini route", () => {
role: "model",
parts: [
{
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
@@ -987,7 +1132,7 @@ describe("Gemini route", () => {
parts: [
{
functionResponse: {
id: undefined,
id: "tool_0",
name: "lookup",
response: { name: "lookup", content: "done" },
},
@@ -1023,15 +1168,15 @@ describe("Gemini route", () => {
role: "model",
parts: [
{
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
thoughtSignature: "parallel_signature",
},
{
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
thoughtSignature: undefined,
},
{
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
thoughtSignature: undefined,
},
],
@@ -1059,11 +1204,11 @@ describe("Gemini route", () => {
role: "model",
parts: [
{
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
{
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
@@ -1214,7 +1359,6 @@ describe("Gemini route", () => {
id: "call_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "call_0" } },
})
expect(response.toolCalls[1]).toMatchObject({
type: "tool-call",
@@ -1230,6 +1374,37 @@ describe("Gemini route", () => {
}),
)
it.effect("replaces repeated supplier ids with fresh fallback ids", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "news" } } },
],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls[0]).toMatchObject({
id: "dup_call",
providerMetadata: undefined,
})
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[1].id).not.toBe(response.toolCalls[0].id)
}),
)
it.effect("assigns distinct unique fallback ids across separate requests", () =>
Effect.gen(function* () {
const body = sseEvents({
@@ -1365,6 +1540,73 @@ describe("Gemini route", () => {
}),
)
it.effect("survives explicit null usage counts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] } }] },
{ usageMetadata: { promptTokenCount: null, candidatesTokenCount: 5 } },
),
),
),
)
expect(response.text).toBe("Hi")
expect(response.usage).toMatchObject({ outputTokens: 5, totalTokens: 5 })
expect(response.usage?.inputTokens).toBeUndefined()
expect(response.usage?.nonCachedInputTokens).toBeUndefined()
expect(response.usage?.cacheReadInputTokens).toBeUndefined()
expect(response.usage?.reasoningTokens).toBeUndefined()
}),
)
it.effect("survives null candidates, content, parts, and finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ candidates: null },
{ candidates: [{ content: { role: "model", parts: null } }] },
{ candidates: [{ content: null, finishReason: null }] },
{
candidates: [
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
],
},
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
}),
)
it.effect("treats a null thought flag on a text part as visible output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{ content: { role: "model", parts: [{ text: "Visible", thought: null }] }, finishReason: "STOP" },
],
}),
),
),
)
const reasoningStart = response.events.find((event) => event.type === "reasoning-start")
expect(reasoningStart).toBeUndefined()
expect(response.reasoning ?? "").toBe("")
expect(response.text).toBe("Visible")
}),
)
it.effect("fails invalid stream events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -0,0 +1,77 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
import { GoogleVertex } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const model = GoogleVertex.configure({
apiKey: process.env.GOOGLE_VERTEX_API_KEY ?? "fixture",
}).model("gemini-3.5-flash")
const lookupWeather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
})
const recorded = recordedTests({
prefix: "google-vertex",
provider: "google-vertex",
protocol: "gemini",
requires: ["GOOGLE_VERTEX_API_KEY"],
})
describe("Google Vertex Gemini recorded", () => {
recorded.effect("streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
)
expect(response.text.toLowerCase()).toContain("hello")
}),
)
recorded.effect("calls a tool", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
tools: [lookupWeather],
}),
)
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
expect(call).toBeDefined()
expect(call?.input).toMatchObject({ city: "Paris" })
}),
)
recorded.effect("continues after a tool result", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
messages: [
Message.user("What is the weather in Paris?"),
Message.assistant([
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
]),
Message.tool({
id: "call_paris_1",
name: "lookup_weather",
result: "18C, light rain",
resultType: "text",
}),
],
tools: [lookupWeather],
}),
)
expect(response.text.length).toBeGreaterThan(0)
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src/index.js"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
@@ -75,6 +75,53 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertex.configure({
accessToken: "vertex-token",
project: "vertex-project",
}).model("gemini-3.5-flash"),
messages: [
Message.assistant([
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "provider_call_1" } },
}),
]),
Message.tool({
id: "call_1",
name: "lookup",
result: "sunny",
resultType: "text",
providerMetadata: { google: { functionCallId: "provider_call_1" } },
}),
],
}),
)
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
expect(prepared.body.contents).toMatchObject([
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
],
},
])
}),
)
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () {
const model = GoogleVertexMessages.configure({
@@ -190,6 +237,7 @@ describe("Google Vertex providers", () => {
})
return input.respond(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello." },
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -47,7 +47,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
expect(prepared.body).toMatchObject({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are concise." },
@@ -55,7 +55,8 @@ describe("OpenAI Chat route", () => {
],
stream: true,
stream_options: { include_usage: true },
max_tokens: 20,
store: false,
max_completion_tokens: 20,
temperature: 0,
})
}),
@@ -325,7 +326,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body).toEqual({
expect(prepared.body).toMatchObject({
model: "gpt-4o-mini",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -342,8 +343,10 @@ describe("OpenAI Chat route", () => {
},
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
],
tools: [],
stream: true,
stream_options: { include_usage: true },
store: false,
})
}),
)
@@ -70,7 +70,7 @@ describe("OpenAI-compatible Chat route", () => {
baseURL: "https://api.deepseek.test/v1/",
query: { "api-version": "2026-01-01" },
})
expect(prepared.body).toEqual({
expect(prepared.body).toMatchObject({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -79,7 +79,7 @@ describe("OpenAI-compatible Chat route", () => {
tools: [
{
type: "function",
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } },
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" }, strict: false },
},
],
tool_choice: "required",
@@ -130,7 +130,7 @@ describe("OpenAI-compatible Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
expect(prepared.body).toMatchObject({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -158,6 +158,29 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("enables ZAI tool streaming except for GLM 4.5 models", () =>
Effect.gen(function* () {
const prepare = (provider: string, baseURL: string, id: string) =>
compileRequest(
LLM.request({
model: OpenAICompatibleChat.route.with({ provider, endpoint: { baseURL } }).model({ id }),
prompt: "Use a tool.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: {} })],
}),
)
const current = yield* prepare("zai", "https://api.z.ai/api/paas/v4", "glm-4.7")
expect(current.body).toMatchObject({ tool_stream: true })
const legacy = yield* Effect.all(
["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"].map((id) =>
prepare("zhipuai", "https://open.bigmodel.cn/api/paas/v4", id),
),
)
legacy.forEach((item) => expect(item.body).not.toHaveProperty("tool_stream"))
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -180,7 +203,7 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
expect(prepared.body).toEqual({
expect(prepared.body).toMatchObject({
model: "deepseek-chat",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -204,6 +227,7 @@ describe("OpenAI-compatible Chat route", () => {
name: "lookup",
description: "Lookup data",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
strict: false,
},
},
],
@@ -78,6 +78,45 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const pdf = "data:application/pdf;base64,JVBERi0xLjQ="
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([{ type: "media", mediaType: "application/pdf", data: pdf, filename: "input.pdf" }]),
Message.assistant({ type: "tool-call", id: "call_1", name: "read", input: {} }),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [{ type: "file", uri: pdf, mime: "application/pdf", name: "result.pdf" }],
}),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_file", filename: "input.pdf", file_data: pdf }],
},
{ type: "function_call", call_id: "call_1", name: "read", arguments: "{}" },
{
type: "function_call_output",
call_id: "call_1",
output: [{ type: "input_file", filename: "result.pdf", file_data: pdf }],
},
])
}),
)
it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () {
const model = configure({
@@ -93,6 +132,112 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("lowers canonical parallel tool control", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Read the file.",
tools: [
ToolDefinition.make({
name: "read",
description: "Read a file.",
inputSchema: { type: "object" },
}),
],
toolChoice: { type: "auto", disableParallelToolUse: true },
}),
)
expect(prepared.body.parallel_tool_calls).toBe(false)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "read",
description: "Read a file.",
parameters: { type: "object" },
strict: false,
},
])
}),
)
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
// The baseline does not enforce a provider id grammar, so a
// non-OpenAI but well-formed token is resent as-is.
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
// Shape violations are dropped even without a grammar policy.
{
type: "text",
text: "Dropped.",
providerMetadata: { openresponses: { itemId: `m${"a".repeat(64)}` } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "message",
id: "history_1",
role: "assistant",
content: [{ type: "output_text", text: "Kept." }],
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Dropped." }],
},
])
}),
)
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
},
// Raw reasoning finals carry no summary index; they reconcile
// into the item's first block.
{ type: "response.reasoning.done", item_id: "rs_raw", text: "Raw chain of thought." },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Raw chain of thought.")
}),
)
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
Effect.gen(function* () {
const model = configure({
@@ -114,7 +259,12 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body).toMatchObject({
input: [
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }], phase: null },
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
],
})
}),
@@ -189,6 +339,7 @@ describe("Open Responses-compatible route", () => {
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto",
serviceTier: "provider-tier",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
parallelToolCalls: false,
@@ -213,6 +364,7 @@ describe("Open Responses-compatible route", () => {
presence_penalty: 0.2,
frequency_penalty: -0.1,
truncation: "auto",
service_tier: "provider-tier",
tool_choice: {
type: "allowed_tools",
mode: "auto",
@@ -188,17 +188,15 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("omits unsupported semantic service tiers", () =>
it.effect("passes through provider-defined service tiers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
)
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
expect(prepared.body).not.toHaveProperty("service_tier")
expect(prepared.body.service_tier).toBe("scale")
}),
)
it.effect("flattens top-level object unions in function schemas", () =>
it.effect("preserves function schemas", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
@@ -238,19 +236,53 @@ describe("OpenAI Responses route", () => {
strict: false,
parameters: {
type: "object",
properties: {
path: { type: "string" },
reference: { type: "string" },
limit: { type: "integer", maximum: 2000 },
resource: { type: "string" },
},
additionalProperties: false,
anyOf: [
{
type: "object",
properties: {
path: { type: "string" },
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
limit: { type: "integer", maximum: 2000 },
},
required: ["path"],
},
{
type: "object",
properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
required: ["resource"],
},
],
},
},
])
}),
)
it.effect("maps the canonical parallel tool setting with provider-option precedence", () =>
Effect.gen(function* () {
const disabled = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: true },
}),
)
const enabled = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: false },
}),
)
const overridden = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: true },
providerOptions: { parallelToolCalls: true },
}),
)
expect(disabled.body.parallel_tool_calls).toBe(false)
expect(enabled.body.parallel_tool_calls).toBe(true)
expect(overridden.body.parallel_tool_calls).toBe(true)
}),
)
it.effect("lowers chronological system updates to developer messages in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -314,6 +346,10 @@ describe("OpenAI Responses route", () => {
}),
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({
type: "response.output_item.added",
item: { type: "message", id: "msg_1" },
}),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
@@ -394,6 +430,38 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("tolerates keepalive frames before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
response: { id: "resp_alive", usage: { input_tokens: 1, output_tokens: 1 } },
}),
]),
close: Effect.void,
}),
})
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
)
expect(response.finishReason?.normalized).toBe("stop")
}),
)
it.effect("continues a tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
@@ -1273,7 +1341,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
])
}),
@@ -1300,7 +1368,7 @@ describe("OpenAI Responses route", () => {
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "report.pdf", file_data: base64 },
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
])
}),
)
@@ -1333,7 +1401,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
])
}),
@@ -1358,7 +1426,7 @@ describe("OpenAI Responses route", () => {
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "file", file_data: "AAECAw==" },
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
])
}),
)
@@ -1626,6 +1694,7 @@ describe("OpenAI Responses route", () => {
it.effect("parses text and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "!" },
{
@@ -1887,6 +1956,67 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores deltas without a matching output item", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_missing", delta: "orphaned text" },
{ type: "response.refusal.delta", item_id: "refusal_missing", delta: "orphaned refusal" },
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_missing",
summary_index: 0,
delta: "orphaned reasoning",
},
{
type: "response.reasoning_summary_part.added",
item_id: "rs_still_missing",
summary_index: 0,
},
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_still_missing",
summary_index: 0,
delta: "still orphaned reasoning",
},
{
type: "response.function_call_arguments.delta",
item_id: "fc_missing",
delta: '{"orphaned":true}',
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("")
expect(response.message.content).toEqual([])
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}),
)
it.effect("rejects function argument deltas without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.function_call_arguments.delta", delta: "{}" },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.function_call_arguments.delta is missing item_id")
}),
)
it.effect("rejects reasoning events without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
@@ -1942,8 +2072,10 @@ describe("OpenAI Responses route", () => {
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_text.done", item_id: "msg_1" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
{ type: "response.completed", response: { id: "resp_1" } },
@@ -1954,10 +2086,10 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1", providerMetadata: undefined },
{ type: "text-delta", id: "msg_1", text: "First", providerMetadata: undefined },
{ type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-delta", id: "msg_2", text: "Second", providerMetadata: undefined },
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
])
}),
@@ -1966,7 +2098,9 @@ describe("OpenAI Responses route", () => {
it.effect("parses reasoning summary stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1" },
{ type: "response.completed", response: { id: "resp_1" } },
@@ -1978,18 +2112,22 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "reasoning-start", id: "rs_1:0" },
{ type: "reasoning-delta", id: "rs_1:0", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "reasoning-end", id: "rs_1:0" },
{ type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" },
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
@@ -2001,6 +2139,7 @@ describe("OpenAI Responses route", () => {
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{
type: "response.output_item.done",
@@ -2020,7 +2159,7 @@ describe("OpenAI Responses route", () => {
expect(response.events).toContainEqual(
expect.objectContaining({
type: "reasoning-end",
id: "rs_1",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
)
@@ -2082,6 +2221,105 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
// No `.delta` events at all: the gateway sends the complete
// summary text in the `.done` final.
{
type: "response.reasoning_summary_text.done",
item_id: "rs_1",
summary_index: 0,
text: "Checked the diff.",
},
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Checked the diff.")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [response.message],
providerOptions: { store: false, include: ["reasoning.encrypted_content"] },
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
])
}),
)
it.effect("does not duplicate reasoning finals after streamed deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Streamed" },
// Repeats the complete text, as the spec allows.
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Streamed" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Streamed")
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([
{ type: "reasoning-delta", id: "rs_1:0", text: "Streamed", providerMetadata: undefined },
])
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
@@ -2161,6 +2399,7 @@ describe("OpenAI Responses route", () => {
})
return input.respond(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -2272,6 +2511,134 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues stateless hosted tool results with their text form", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Search."),
Message.assistant([
ToolCallPart.make({
id: "ws_1",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
}),
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
Message.user("Continue."),
],
providerOptions: { store: false },
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }],
},
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("drops replayed item ids outside the server's grammar", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
// Fails the message id prefix.
{
type: "text",
text: "Hello",
providerMetadata: { openai: { itemId: "history_1" } },
},
// Oversized for the Responses item id limit.
{
type: "text",
text: "World",
providerMetadata: { openai: { itemId: `m${"a".repeat(64)}` } },
},
// Fails the reasoning id prefix, so the whole item is unreplayable
// statelessly and is skipped rather than sent malformed.
{
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "thinking_1", reasoningEncryptedContent: "encrypted-state" } },
},
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { openai: { itemId: "toolu_01A" } },
}),
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
content: [
{ type: "output_text", text: "Hello" },
{ type: "output_text", text: "World" },
],
},
{
type: "function_call",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
])
}),
)
it.effect("keeps well-formed hosted references and drops malformed ones under storage", () =>
Effect.gen(function* () {
const hostedResult = (itemId: string) => [
ToolCallPart.make({
id: itemId,
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId } },
}),
{
type: "tool-result" as const,
id: itemId,
name: "web_search",
result: { type: "json" as const, value: { status: "completed" } },
providerExecuted: true as const,
providerMetadata: { openai: { itemId } },
},
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant(hostedResult("ws_1")), Message.assistant(hostedResult("bad ref"))],
providerOptions: { store: true },
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "ws_1" }])
}),
)
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
@@ -2395,15 +2762,15 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' },
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query"' },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: ':"weather"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2432,7 +2799,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-start",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
{
type: "tool-input-delta",
@@ -2450,7 +2817,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
{
type: "tool-call",
@@ -2458,7 +2825,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
{
type: "step-finish",
@@ -2479,7 +2846,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2493,7 +2860,7 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
@@ -2505,7 +2872,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
{
type: "tool-call",
@@ -2513,7 +2880,7 @@ describe("OpenAI Responses route", () => {
name: "lookup",
input: {},
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
],
)
@@ -2526,14 +2893,14 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
@@ -2565,7 +2932,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
@@ -2594,7 +2961,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -2608,7 +2975,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
providerMetadata: { openai: { itemId: "item_1" } },
providerMetadata: { openai: { itemId: "fc_item_1" } },
})
}),
)
@@ -2652,6 +3019,47 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {
type: "computer_call",
id: "computer_1",
call_id: "call_1",
status: "completed",
action: { type: "click", x: 100, y: 200 },
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
{
type: "tool-call",
id: "computer_1",
name: "computer_use",
input: { type: "click", x: 100, y: 200 },
providerExecuted: true,
providerMetadata: { openai: { itemId: "computer_1" } },
},
{
type: "tool-result",
id: "computer_1",
name: "computer_use",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "computer_1" } },
},
])
}),
)
it.effect("decodes image generation output as image content", () =>
Effect.gen(function* () {
const item = {
@@ -2765,7 +3173,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
],
},
@@ -2796,7 +3204,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
],
},
@@ -2821,7 +3229,7 @@ describe("OpenAI Responses route", () => {
{
type: "input_file",
filename: "file",
file_data: "AAECAw==",
file_data: "data:application/x-tar;base64,AAECAw==",
},
],
},
@@ -3016,36 +3424,42 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("falls back to a stable default when error is null", () =>
it.effect("falls back to the raw payload when error is null", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"error":null')
expect(error.body).toBe(error.reason.message)
}),
)
it.effect("falls back to a stable default when both error and response are absent", () =>
it.effect("classifies a detail-free error event as a transient provider failure", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.provide(fixedResponse(sseEvents({ type: "error", sequence_number: 2 }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason.message).toContain('"type":"error"')
expect(error.body).toBe(error.reason.message)
}),
)
it.effect("falls back to a stable default when response.failed has no error payload", () =>
it.effect("keeps the raw response payload when response.failed has no error payload", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"resp_failed_3"')
expect(error.body).toBe(error.reason.message)
}),
)
@@ -24,14 +24,23 @@ describe("xAI Responses route", () => {
}),
)
it.effect("parses xAI reasoning text events", () =>
it.effect("parses xAI reasoning summaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
{
type: "response.output_item.added",
item: { type: "reasoning", id: "reasoning_1" },
},
// Grok streams reasoning with the standard summary event name.
{
type: "response.reasoning_summary_text.delta",
item_id: "reasoning_1",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
+2 -2
View File
@@ -101,7 +101,7 @@ describe("LLMClient tools", () => {
const messages = Reflect.get(second, "messages")
const tools = Reflect.get(second, "tools")
expect(Reflect.get(second, "max_tokens")).toBe(50)
expect(Reflect.get(second, "max_completion_tokens")).toBe(50)
expect(Reflect.get(second, "tool_choice")).toBe("auto")
expect(tools).toHaveLength(1)
expect(
@@ -191,7 +191,7 @@ describe("LLMClient tools", () => {
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const providerMetadata = { google: { functionCallId: "provider_call" } }
const providerMetadata = { google: { thoughtSignature: "provider_sig" } }
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
+10 -31
View File
@@ -50,32 +50,7 @@ describe("tool schema projections", () => {
})
})
test("openai keeps one flat object top-level schema", () => {
expect(
ToolSchemaProjection.openAI({
anyOf: [
{
type: "object",
properties: {
path: { type: "string" },
maybe: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
{ type: "object", properties: { resource: { type: "string" } } },
],
}),
).toEqual({
type: "object",
properties: {
path: { type: "string" },
maybe: { type: "string" },
resource: { type: "string" },
},
additionalProperties: false,
})
})
it.effect("applies model compatibility before protocol projection", () =>
it.effect("applies model compatibility without changing schema semantics", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -107,11 +82,15 @@ describe("tool schema projections", () => {
expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
type: "object",
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
additionalProperties: false,
anyOf: [
{
type: "object",
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
},
],
})
}),
)
+6
View File
@@ -19,6 +19,12 @@
- Always prefer `createStore` over multiple `createSignal` calls
## Typography
- Use `--line-height-compact` (`16px`) for `13px` compact UI text and `--line-height-base` (`20px`) for body text.
- Do not use `leading-none`, `line-height: 1`, or a `13px` line height for normal text. Inter descenders clip inside truncation and overflow containers.
- Keep control and row heights explicit. Fix font metrics directly rather than using transforms, negative margins, or clip-padding compensation.
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
@@ -259,6 +259,22 @@ export function event(
return makeEvent(type, data)
}
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
return makeEvent("session.compaction.started", data)
}
export function compactionDelta(data: Extract<OpenCodeEvent, { type: "session.compaction.delta" }>["data"]) {
return makeEvent("session.compaction.delta", data)
}
export function compactionEnded(data: Extract<OpenCodeEvent, { type: "session.compaction.ended" }>["data"]) {
return makeEvent("session.compaction.ended", data)
}
export function compactionFailed(data: Extract<OpenCodeEvent, { type: "session.compaction.failed" }>["data"]) {
return makeEvent("session.compaction.failed", data)
}
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
return makeEvent("session.tool.input.started", data)
}
@@ -883,8 +899,26 @@ function provider() {
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
{
id: "company-gateway",
name: "Company Gateway",
models: {
"fast-nano": {
id: "fast-nano",
api: { id: "openai/gpt-5.4-nano" },
name: "GPT-5.4 nano",
limit: { context: 128_000 },
},
"long-context": {
id: "long-context",
api: { id: "company/long-context" },
name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts",
limit: { context: 128_000 },
},
},
},
],
connected: ["opencode"],
connected: ["opencode", "company-gateway"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
@@ -53,6 +53,37 @@ benchmark.describe("performance: first navigation paint", () => {
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a session from the new session page without a blank frame", async ({ page, report }) => {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, { draftID })
await page.goto("/")
const draftHref = stressDraftHref(draftID)
const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftHref}"]`)
await expect(draftTab).toHaveCount(1)
await draftTab.click()
await expect(page.locator('[data-component="new-session"]')).toBeVisible()
const href = stressSessionHref(fixture.targetID)
const sessionTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)
await expect(sessionTab).toHaveCount(1)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: '[data-component="new-session"]',
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await sessionTab.click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.childID)
@@ -0,0 +1,106 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("renames, exports, and deletes a home session from its context menu", async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.route("**/api/session/*/rename", async (route) => {
const sessionID = new URL(route.request().url()).pathname.split("/").at(-2)
const session = sessions.find((item) => item.id === sessionID)
const payload: unknown = route.request().postDataJSON()
if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string")
throw new Error("Invalid rename payload")
if (session) session.title = payload.title
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
await expect(row).toBeVisible()
const container = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox()
const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0)
await row.focus()
await row.press("Shift+F10")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
await expect(row).toBeFocused()
const rowBox = await row.boundingBox()
await row.click({ button: "right", position: { x: 48, y: 12 } })
await expect(page).toHaveURL("/")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
await page.getByRole("menuitem", { name: "Rename" }).click()
const title = page.locator('[data-component="home-session-rename"]')
await expect(title).toBeFocused()
await expect(title).toHaveValue(fixture.expected.targetTitle)
const editorBox = await title.boundingBox()
const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
expect(editorBox?.x).toBe(titleBox?.x)
expect(editingAvatarBox).toEqual(avatarBox)
expect(
await title.evaluate((element) => ({
outline: getComputedStyle(element).outlineStyle,
shadow: getComputedStyle(element).boxShadow,
})),
).toEqual({ outline: "none", shadow: "none" })
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
await title.fill("Renamed from Home")
const renamed = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"),
)
await title.press("Enter")
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
await page.getByRole("button", { name: "Home" }).click()
await expect(page).toHaveURL("/")
renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click({ button: "right" })
const download = page.waitForEvent("download")
const exportItem = page.getByRole("menuitem", { name: "Export..." })
await exportItem.click()
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
await expect(exportItem).toBeHidden()
await renamedRow.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete..." }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
)
await dialog.getByRole("button", { name: "Delete session" }).click()
await removed
await expect(renamedRow).toBeHidden()
})
@@ -13,9 +13,14 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, permissionRequests)
// Server A has no tab and is never visited: a pending request there proves
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
})
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -38,7 +43,17 @@ test("session settings use the remote server context", async ({ page }) => {
}),
)
.toBe(true)
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { reply: "once" },
},
])
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
@@ -143,6 +158,99 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
])
})
test("auto-accept sweeps again after a reconnect", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const pendingA: MockPermission[] = []
const listFailures: Record<string, number> = {}
const sessionGets: string[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport(page, { server: serverA, retry: 20 })
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: pendingA },
listFailures,
sessionGets,
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
const first = await transport.waitForConnection()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
}),
)
.toBe(true)
await page.keyboard.press("Escape")
// This request is asked while the client is disconnected, so it is never
// delivered as an event and only a reconnect sweep can find it. The first
// listing after the reconnect fails, so only the bounded sweep retry can
// deliver the reply.
pendingA.push(pendingPermission("permission-offline-a", sessionA.id))
listFailures[serverA] = 1
const syncsBeforeReconnect = sessionGets.length
await transport.disconnect()
await transport.waitForConnection({ after: first.id })
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { reply: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
// cached locations, since another client may have moved them meanwhile.
expect(sessionGets.slice(syncsBeforeReconnect)).toContain(sessionA.id)
})
test("auto-accept approves a request discovered by opening a session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
// The request is only served from the per-session permission list, so it
// reaches the client through the store sync when the session view opens,
// never through a location sweep or an event.
await mockServers(page, permissionRequests, permissionResponses, {
sessionPending: { [sessionA.id]: [pendingPermission("permission-synced-a", sessionA.id)] },
})
await configureServers(page, [{ type: "session", server: serverA, sessionId: sessionA.id }])
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverA,
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { reply: "once" },
},
])
})
type PermissionResponse = {
origin: string
directory?: string
@@ -151,6 +259,19 @@ type PermissionResponse = {
body: unknown
}
type MockPermission = {
id: string
sessionID: string
action: string
resources: string[]
metadata: Record<string, unknown>
save: unknown[]
}
function pendingPermission(id: string, sessionID: string): MockPermission {
return { id, sessionID, action: "shell", resources: ["git status"], metadata: {}, save: [] }
}
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
@@ -161,7 +282,23 @@ async function configureServers(page: Page, tabs: { type: "session"; server: str
)
}
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
type MockServerOptions = {
// Pending requests served from /api/permission/request, keyed by origin.
pending?: Record<string, MockPermission[]>
// Pending requests served from /api/session/:id/permission, keyed by session ID.
sessionPending?: Record<string, MockPermission[]>
// Counts of /api/permission/request calls to fail with a 500, keyed by origin.
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
}
async function mockServers(
page: Page,
permissionRequests: string[],
permissionResponses: PermissionResponse[] = [],
options: MockServerOptions = {},
) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@@ -178,8 +315,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
permissionID: response[2]!,
body: route.request().postDataJSON(),
})
return json(route, true)
// The generated client requires exactly 204 for a successful reply.
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionPermission = url.pathname.match(/^\/api\/session\/([^/]+)\/permission$/)
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/provider")
return json(route, {
@@ -197,7 +338,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/permission/request") {
permissionRequests.push(url.toString())
return json(route, { location: { directory }, data: [] })
const failures = options.listFailures?.[url.origin] ?? 0
if (failures > 0) {
options.listFailures![url.origin] = failures - 1
return json(route, { name: "Internal" }, 500)
}
return json(route, { location: { directory }, data: options.pending?.[url.origin] ?? [] })
}
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
@@ -219,9 +365,13 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === "/api/session/active")
return json(route, { data: Object.fromEntries(sessions.map((session) => [session.id, { type: "running" }])) })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (currentSessionInfo) {
options.sessionGets?.push(currentSessionInfo.id)
return json(route, { data: currentSession(currentSessionInfo) })
}
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
@@ -45,21 +45,17 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
test("shows a comment button when a diff line is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
const line = review.getByText("export const first = 1", { exact: true })
await expectAppVisible(line)
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await lineNumber.hover()
await expect(lineNumber).toHaveAttribute("data-hovered", "")
await expect(comment).toHaveCount(1)
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
@@ -179,6 +179,7 @@ async function expectMountedTree(page: Page, total: number) {
}
async function expectSideGeometry(page: Page) {
await expectPanelGap(page, 8)
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!.getBoundingClientRect()
@@ -188,15 +189,20 @@ async function expectSideGeometry(page: Page) {
terminalLeft: terminal.left,
terminalRight: terminal.right,
terminalTop: terminal.top,
terminalBottom: terminal.bottom,
reviewTop: review.top,
reviewBottom: review.bottom,
}
})
expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1)
expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop)
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
}
async function expectBottomGeometry(page: Page) {
await expectPanelGap(page, 8)
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
@@ -226,6 +232,30 @@ async function expectBottomGeometry(page: Page) {
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
}
async function expectPanelGap(page: Page, expected: number) {
await expect
.poll(() => {
return page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.NEGATIVE_INFINITY
const gap = terminal.top - review.bottom
return gap
})
})
.toBeGreaterThanOrEqual(expected - 1)
await expect
.poll(() => {
return page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.POSITIVE_INFINITY
return terminal.top - review.bottom
})
})
.toBeLessThanOrEqual(expected + 1)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -20,45 +20,47 @@ const messages = [
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
const session = {
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
}
const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
pageMessages: () => ({ items: messages }),
}
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
},
],
pageMessages: () => ({ items: messages }),
...fixture,
sessions: [session],
onRevertStage: (input) => staged.push(input),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -77,3 +79,19 @@ test("reverts directly to the selected user message", async ({ page }) => {
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
expect(staged).toEqual([{ sessionID, messageID: "msg_second" }])
})
test("hides revert actions in a child session", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
sessions: [
{ ...session, id: "ses_parent", slug: "parent", title: "Parent session" },
{ ...session, parentID: "ses_parent" },
],
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Session message revert")
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
await expect(message.getByRole("button", { name: "Revert message" })).toHaveCount(0)
})
@@ -0,0 +1,226 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/SessionQueueRegression"
const projectID = "proj_session_queue_regression"
const sessionID = "ses_session_queue_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
type InboxRow = {
id: string
sessionID: string
timeCreated: number
type: "user"
payload: { text: string; metadata?: Record<string, unknown> }
delivery: "steer" | "queue"
}
function createQueueMock(seed: string[]) {
const rows: InboxRow[] = seed.map((text, index) => ({
id: `inb_seed_${index + 1}`,
sessionID,
timeCreated: 1700000000000 + index,
type: "user",
payload: { text },
delivery: "queue",
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const log: string[] = []
let sequence = 0
const emit = (type: OpenCodeEvent["type"], data: OpenCodeEvent["data"]) => {
sequence += 1
events.push({
id: `evt_queue_${sequence}`,
type,
created: Date.now(),
durable: { aggregateID: sessionID, seq: sequence, version: 1 },
data,
} as OpenCodeEvent)
}
return {
rows,
prompts,
changes,
log,
events: () => events.splice(0),
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
prompts.push(input.body)
log.push(`prompt:${String(input.body.delivery ?? "steer")}`)
const row: InboxRow = {
id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`,
sessionID: input.sessionID,
timeCreated: Date.now(),
type: "user",
payload: {
text: typeof input.body.text === "string" ? input.body.text : "",
...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record<string, unknown> }),
},
delivery: input.body.delivery === "queue" ? "queue" : "steer",
}
rows.push(row)
emit("session.inbox.enqueued", {
sessionID: input.sessionID,
inboxID: row.id,
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
const row = rows[index]
if (!row) return
if (input.action === "cancel") {
rows.splice(index, 1)
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = "steer"
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
inboxID: input.inboxID,
delivery: "steer",
})
},
}
}
async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>, followUpBehavior?: "queue" | "steer") {
if (followUpBehavior) {
await page.addInitScript(
(behavior) => localStorage.setItem("settings.v3", JSON.stringify({ general: { followUpBehavior: behavior } })),
followUpBehavior,
)
}
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "session-queue-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "queue-model": { id: "queue-model", name: "Queue Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "queue-model" },
},
sessions: [
{
id: sessionID,
slug: "session-queue-regression",
projectID,
directory,
title: "Session queue regression",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
onInboxChange: mock.onInboxChange,
events: mock.events,
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
return {
composer,
input: composer.locator('[data-component="composer-editor"]'),
rows: page.locator('[data-component="session-queue-row"]'),
}
}
test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => {
const mock = createQueueMock([])
const view = await openSession(page, mock, "queue")
await view.input.fill("queue this follow-up")
await expect(view.composer.locator('[data-action="composer-alternate-delivery"]')).toContainText("Steer")
await view.input.press("Enter")
await expect(view.rows.getByText("queue this follow-up", { exact: true })).toBeVisible()
await view.input.fill("steer this correction")
await view.input.press("ControlOrMeta+Enter")
await expect.poll(() => mock.prompts.map((prompt) => prompt.delivery)).toEqual(["queue", "steer"])
await expect(view.input).toHaveText("")
})
test("dragging reorders queued prompts", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(3)
const first = view.rows.filter({ hasText: "first queued prompt" })
const third = view.rows.filter({ hasText: "third queued prompt" })
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await third.boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"second queued prompt",
"third queued prompt",
"first queued prompt",
])
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"second queued prompt",
"third queued prompt",
"first queued prompt",
])
expect(mock.changes).toEqual([
{ inboxID: "inb_seed_1", action: "cancel" },
{ inboxID: "inb_seed_2", action: "cancel" },
{ inboxID: "inb_seed_3", action: "cancel" },
])
})
test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"])
const view = await openSession(page, mock)
const original = view.rows.getByText("tighten the error copy", { exact: true })
await expect(original).toBeVisible()
await view.input.fill("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await view.input.press("Escape")
await expect(view.input).toHaveText("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await view.input.fill("tighten the error copy and add a retry hint")
await view.input.press("Enter")
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"first queued prompt",
"tighten the error copy and add a retry hint",
"third queued prompt",
])
await expect(view.input).toHaveText("my in-progress draft")
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"tighten the error copy and add a retry hint",
"tighten the error copy and add a retry hint",
"third queued prompt",
])
expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true)
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
expect(mock.log[0]).toBe("prompt:queue")
})
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
@@ -3,9 +3,9 @@ import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
setupTimeline,
textPart,
toolPart,
userMessage,
userText,
} from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
@@ -82,9 +82,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
const id = "prt_file_projection_anchored_patch"
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
const timeline = await setupTimeline(page, {
await setupTimeline(page, {
messages: [
userMessage(),
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
toolPart(
id,
@@ -105,7 +105,6 @@ test("keeps an expanded file diff header at the same viewport position", async (
},
},
),
textPart("prt_after_anchored_patch", "The diff is ready.\n\n".repeat(4)),
]),
],
viewport: { width: 1200, height: 600 },
@@ -113,7 +112,21 @@ test("keeps an expanded file diff header at the same viewport position", async (
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect
.poll(() =>
row.evaluate((element) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
return measured
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
: Number.POSITIVE_INFINITY
}),
)
.toBeLessThanOrEqual(1)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(1)
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
})
@@ -121,11 +134,28 @@ test("keeps an expanded file diff header at the same viewport position", async (
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(0.5)
await trigger.dispatchEvent("wheel", { deltaY: -1, deltaMode: 0 })
await trigger.dispatchEvent("pointerdown")
const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop)
await scroller.hover()
await page.mouse.wheel(0, -20)
await expect
.poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop))
.toBeGreaterThan(0)
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
await trigger.dispatchEvent("click")
const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() =>
row.evaluate((element, collapsed) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
const allocatedHeight = element.getBoundingClientRect().height
return {
grew: allocatedHeight > collapsed + 1,
measured: measured ? Math.abs(allocatedHeight - measured.getBoundingClientRect().height) <= 1 : false,
}
}, collapsedHeight),
)
.toEqual({ grew: true, measured: true })
await expect
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
.toBeLessThanOrEqual(5)
@@ -133,9 +163,10 @@ test("keeps an expanded file diff header at the same viewport position", async (
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
await scroller.hover()
await page.mouse.wheel(0, 200)
await timeline.settle(40)
await expect
.poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop))
.toBeGreaterThan(50)
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
expect(scrolled).toBeGreaterThan(50)
expect(scrolled).toBeLessThan(400)
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
@@ -150,6 +181,16 @@ test("keeps an expanded file diff header at the same viewport position", async (
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() =>
row.evaluate((element) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
return measured
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
: Number.POSITIVE_INFINITY
}),
)
.toBeLessThanOrEqual(1)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 200
const messagePageSize = 20
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
@@ -1,6 +1,15 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { event, session, sessionID, setupTimeline, toolPart } from "../performance/timeline-stability/fixture"
import {
compactionDelta,
compactionEnded,
compactionFailed,
compactionStarted,
event,
session,
sessionID,
setupTimeline,
} from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
@@ -73,23 +82,102 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(
compactionStarted({
sessionID,
reason: "manual",
recent: "",
}),
)
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.send(
compactionDelta({
sessionID,
text: "## Checkpoint\n\nStreamed implementation details.",
}),
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await timeline.send(
compactionEnded({
sessionID,
reason: "manual",
text: "## Checkpoint\n\nFinal implementation details.",
recent: "",
}),
)
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
})
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
await timeline.send(
compactionFailed({
sessionID,
reason: "auto",
error: {
type: "compaction.failed",
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
},
}),
)
const compactions = page.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Partial summary that should be discarded.")
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
await timeline.send(
compactionFailed({
sessionID,
reason: "manual",
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
}),
)
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [toolPart("call_subagent", "subagent", "streaming", {})],
content: [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
],
},
],
})
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
await expect(delegating.locator('[data-component="text-shimmer"]')).toHaveAttribute(
"aria-label",
"Delegating agent...",
)
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
@@ -127,11 +215,11 @@ test("renders the moved location notice in its compact timeline style", async ({
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "13px")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "13px")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
@@ -250,6 +250,43 @@ test.describe("session timeline projection", () => {
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
sessionMessages: [
{
id: "msg_model_fast_nano",
type: "model-switched",
time: { created: 1700000000000 },
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
},
{
id: "msg_model_long_context",
type: "model-switched",
time: { created: 1700000001000 },
model: { providerID: "company-gateway", id: "long-context" },
},
userMessage(),
assistantMessage(),
],
})
const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(shortNotice).toBeVisible()
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice).toBeVisible()
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
@@ -98,9 +98,9 @@ test("labels completed searches with result counts", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="tool-trigger"]')
await expect(rows.nth(0)).toContainText("(1 match)")
await expect(rows.nth(1)).toContainText("(12 matches)")
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
test("labels read tools from their path input", async ({ page }) => {
@@ -111,7 +111,11 @@ test("labels read tools from their path input", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
@@ -121,19 +125,20 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "sample-skill" }),
toolPart(pending, "skill", "running", { id: "frontend-design" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
for (const [id, name] of [
[pending, "sample-skill"],
[pending, "frontend-design"],
[completed, "OpenCode"],
] as const) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
const loaded = skill.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
@@ -26,6 +26,42 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("returns to the parent session with Escape", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await page.keyboard.press("Escape")
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
})
test("shows parent lineage while the child timeline loads", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) =>
url.pathname === `/api/session/${childID}/message` &&
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
expect(page.locator('[data-slot="session-title-child"]')).toHaveText(childTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent visible while the child session resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
@@ -50,6 +86,44 @@ test("keeps the parent visible while the child session resolves", async ({ page
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent tab selected while a loaded child session resolves", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await page.goBack()
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
const parentTab = page.locator("[data-titlebar-tab-slot]", {
has: page.locator('[data-slot="tab-title"]', { hasText: parentTitle }),
})
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(parentTab).toHaveAttribute("data-active", "true"),
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
const home = page.getByRole("button", { name: "Home" })
await home.click()
await expect(page).toHaveURL("/")
const childTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(childID)}"]`)
await expect(childTab).toHaveCount(1)
await childTab.click()
await Promise.all([expect(page).toHaveURL(sessionHref(childID)), expectSessionTitle(page, taskDescription)])
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: OpenCodeEvent[] = []
await setup(page, () => events.splice(0, 1))
@@ -10,11 +10,13 @@ const ptyID = "pty_terminal_composer_focus"
const newPtyID = "pty_terminal_composer_focus_new"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const ptyInput: string[] = []
let sendPtyOutput: ((data: string) => void) | undefined
test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => {
ptyInput.length = 0
sendPtyOutput = undefined
await mockOpenCodeServer(page, {
directory,
project: {
@@ -74,6 +76,7 @@ test.beforeEach(async ({ page }) => {
)
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
ws.onMessage((message) => ptyInput.push(message.toString()))
sendPtyOutput = (data) => ws.send(data)
})
})
@@ -90,12 +93,41 @@ test("clears the terminal line with Command+Delete", async ({ page }) => {
await expect.poll(() => ptyInput.join("")).toBe("\x15")
})
test("hides the native contenteditable caret", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toHaveAttribute("contenteditable", "true")
await expect(terminal).toHaveCSS("caret-color", "rgba(0, 0, 0, 0)")
})
test("reveals the terminal after its first server output renders", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toHaveAttribute("contenteditable", "true")
await expect(terminal).toHaveCSS("opacity", "0")
await expect.poll(() => sendPtyOutput).toBeDefined()
sendPtyOutput?.("\x1b[?25h")
await expect(terminal).toHaveCSS("opacity", "0")
sendPtyOutput?.("ready")
await expect(terminal).toHaveCSS("opacity", "1")
})
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
await composer.click()
await expect(composer).toBeFocused()
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
@@ -213,7 +245,8 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
await page.getByRole("button", { name: "New terminal" }).click()
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
const active = page.locator(`#terminal-wrapper-${newPtyID} [data-component="terminal"]`)
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
})
function seedCachedTerminal(page: Page) {
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -8,7 +8,7 @@ const sessionID = "ses_hidden_terminal_regression"
const title = "Hidden terminal regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
test("animates review and terminal panels while caching hidden terminal content", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
@@ -42,6 +42,16 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: "src/animation.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/animation.ts b/src/animation.ts\n--- a/src/animation.ts\n+++ b/src/animation.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
},
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/pty*", (route) =>
@@ -94,24 +104,454 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await installMotionProbe(page)
const reviewToggle = page.getByRole("button", { name: "Toggle review" })
await reviewToggle.click()
await expect(page.locator("#review-panel")).toBeVisible()
await expectWidthMotions(page, 1)
await expectReviewWidthStable(page)
await expectLogicalSideAlignment(page, "ltr")
await page.evaluate(() => (document.documentElement.dir = "rtl"))
await expectLogicalSideAlignment(page, "rtl")
await page.evaluate(() => (document.documentElement.dir = "ltr"))
await page.keyboard.press("Control+Backquote")
const panel = page.locator("#terminal-panel")
const terminalContent = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expect(terminalContent).toBeVisible()
await terminalContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await expectHeightMotions(page, "session-side-region", 1)
await expectHeightMotions(page, "session-side-terminal-region", 1)
await expectStackedGeometry(page)
await expectPanelGapHeld(page)
await resetTerminalTopMotion(page)
await resetTerminalBottomMotion(page)
await resetTerminalAnchorGaps(page)
await resetPanelGaps(page)
const reviewContent = page.locator('[data-component="session-review-v2"]')
await reviewContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
await reviewToggle.click()
await expect(page.locator("#review-panel")).toBeHidden()
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
await expect(panel).toBeVisible()
await expectHeightMotions(page, "session-side-region", 2)
await expectHeightMotions(page, "session-side-terminal-region", 2)
await expectTerminalTopMotion(page)
await expectTerminalBottomFixed(page)
await expectTerminalTopAnchored(page)
await expectPanelGapHeld(page)
await reviewToggle.click()
await expect(page.locator("#review-panel")).toBeVisible()
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
await expectHeightMotions(page, "session-side-region", 3)
await expectHeightMotions(page, "session-side-terminal-region", 3)
await resetTerminalContentSizes(page)
await resetPanelGaps(page)
await page.keyboard.press("Control+Backquote")
await expect(page.locator('[data-slot="side-terminal-panel-clip"]')).toHaveCSS("overflow", "clip")
await expectHeightMotions(page, "session-side-region", 4)
await expectHeightMotions(page, "session-side-terminal-region", 4)
await expect(panel).toBeHidden()
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expectTerminalContentCachedSize(page)
await expectStackPainted(page)
await expectPanelGapHeld(page)
await expect(page.locator('[data-slot="session-side-panel-gap"]')).toHaveCSS("height", "0px")
await reviewToggle.click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await expectWidthMotions(page, 2)
await expectSideSlideSettled(page, 2)
await expectHiddenSideAligned(page)
await resetHeightMotions(page)
await resetHorizontalScrolls(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await expectWidthMotions(page, 3)
await expectSideSlideSettled(page, 3)
await expectNoHeightMotion(page)
await expectNoHorizontalScroll(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveCount(0)
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await expect(panel).toBeHidden()
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expectWidthMotions(page, 4)
await page.setViewportSize({ width: 1200, height: 700 })
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await expect(terminalContent).toBeVisible()
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
await expectWidthMotions(page, 5)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeHidden()
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }),
)
})
await page.reload()
await expectSessionTitle(page, title)
await installMotionProbe(page)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expectAnimation(page, "terminal-panel-size-in")
await page.keyboard.press("Control+Backquote")
await expectAnimation(page, "terminal-panel-size-out")
await expect(panel).toBeHidden()
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
})
type MotionProbe = {
widths: number
widthEnds: number
horizontalScrolls: number[]
reviewWidths: number[]
paintGaps: { review: number; terminalSurface: number }[]
terminalContentSizes: { width: number; height: number }[]
terminalAnchorGaps: number[]
resetAnchorOnMotion: boolean
panelGaps: number[]
terminalTops: number[]
terminalBottoms: number[]
heights: string[]
animations: string[]
}
async function installMotionProbe(page: Page) {
await page.evaluate(() => {
const probe: MotionProbe = {
widths: 0,
widthEnds: 0,
horizontalScrolls: [],
reviewWidths: [],
paintGaps: [],
terminalContentSizes: [],
terminalAnchorGaps: [],
resetAnchorOnMotion: false,
panelGaps: [],
terminalTops: [],
terminalBottoms: [],
heights: [],
animations: [],
}
const observed = new WeakSet<Element>()
const observers: ResizeObserver[] = []
const observeReview = () => {
const review = document.querySelector('[data-component="session-review-v2"]')
if (!review || observed.has(review)) return
observed.add(review)
const observer = new ResizeObserver(([entry]) => probe.reviewWidths.push(entry.contentRect.width))
observer.observe(review)
observers.push(observer)
}
const observedRegions = new WeakSet<Element>()
const observeStack = () => {
const reviewRegion = document.querySelector<HTMLElement>('[data-slot="session-side-region"]')
const terminalRegion = document.querySelector<HTMLElement>('[data-slot="session-side-terminal-region"]')
if (!reviewRegion || !terminalRegion || observedRegions.has(reviewRegion)) return
observedRegions.add(reviewRegion)
const observer = new ResizeObserver(() => {
const review = document.querySelector<HTMLElement>("#review-panel")
const terminal = document.querySelector<HTMLElement>("#terminal-panel")
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
if (!terminal || !terminalContent) return
probe.terminalTops.push(terminal.getBoundingClientRect().top)
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
probe.terminalContentSizes.push({
width: terminalContent.getBoundingClientRect().width,
height: terminalContent.getBoundingClientRect().height,
})
const anchorGap = Math.abs(terminal.getBoundingClientRect().top - terminalContent.getBoundingClientRect().top)
if (probe.resetAnchorOnMotion) {
if (anchorGap > 8) return
probe.terminalAnchorGaps = []
probe.resetAnchorOnMotion = false
}
probe.terminalAnchorGaps.push(anchorGap)
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
if (!review) return
probe.paintGaps.push({
review: Math.abs(reviewRegion.getBoundingClientRect().height - review.getBoundingClientRect().height),
terminalSurface: Math.abs(
terminalRegion.getBoundingClientRect().height - terminal.getBoundingClientRect().height,
),
})
})
observer.observe(reviewRegion)
observer.observe(terminalRegion)
observers.push(observer)
}
new MutationObserver(() => {
observeReview()
observeStack()
}).observe(document.body, { childList: true, subtree: true })
observeReview()
observeStack()
document.addEventListener("transitionrun", (event) => {
if (!(event.target instanceof Element)) return
const slot = event.target.getAttribute("data-slot")
if (event.propertyName === "width" && slot === "session-chat-panel") probe.widths++
if (event.propertyName === "height" && slot) {
probe.heights.push(slot)
}
})
document.addEventListener("transitionend", (event) => {
if (!(event.target instanceof Element)) return
if (event.propertyName === "width" && event.target.getAttribute("data-slot") === "session-chat-panel")
probe.widthEnds++
})
document.addEventListener("animationstart", (event) => {
if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return
probe.animations.push(event.animationName)
})
window.addEventListener("scroll", () => probe.horizontalScrolls.push(window.scrollX))
;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe
})
}
async function expectWidthMotions(page: Page, count: number) {
await expect
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widths ?? 0))
.toBeGreaterThanOrEqual(count)
}
async function resetHeightMotions(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.heights = []
})
}
async function expectSideSlideSettled(page: Page, count: number) {
await expect
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
.toBeGreaterThanOrEqual(count)
}
async function expectNoHeightMotion(page: Page) {
const heights = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights ?? [],
)
expect(heights).toEqual([])
}
async function expectHiddenSideAligned(page: Page) {
await expect
.poll(() =>
page.evaluate(() => {
const chat = document.querySelector<HTMLElement>('[data-slot="session-chat-panel"]')
const side = document.querySelector<HTMLElement>('[data-slot="session-side-panel-presence"]')
if (!chat?.parentElement || !side) return Number.POSITIVE_INFINITY
const row = chat.parentElement.getBoundingClientRect()
const hidden = side.getBoundingClientRect()
return Math.max(
Math.abs(row.top - hidden.top),
Math.abs(row.right - hidden.right),
Math.abs(row.bottom - hidden.bottom),
)
}),
)
.toBeLessThanOrEqual(1)
}
async function resetHorizontalScrolls(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.horizontalScrolls = []
})
}
async function expectNoHorizontalScroll(page: Page) {
const scrolls = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.horizontalScrolls ?? [],
)
expect(Math.max(0, ...scrolls)).toBe(0)
expect(await page.evaluate(() => window.scrollX)).toBe(0)
}
async function expectReviewWidthStable(page: Page) {
const side = page.locator('[data-slot="session-side-panel-presence"]')
await expect
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
.toBe(true)
await expect
.poll(() =>
page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.length ?? 0),
)
.toBeGreaterThan(0)
const widths = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.map(Math.round) ?? [],
)
expect(new Set(widths).size).toBe(1)
}
async function expectStackedGeometry(page: Page) {
await expect
.poll(() =>
page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.POSITIVE_INFINITY
return terminal.top - review.bottom
}),
)
.toBeLessThanOrEqual(9)
await expect
.poll(() =>
page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
if (!review || !terminal) return Number.NEGATIVE_INFINITY
return terminal.top - review.bottom
}),
)
.toBeGreaterThanOrEqual(7)
}
async function expectLogicalSideAlignment(page: Page, direction: "ltr" | "rtl") {
await expect
.poll(() =>
page.evaluate((direction) => {
const frame = document.querySelector('[data-slot="session-side-panel-presence"]')?.getBoundingClientRect()
const content = document.querySelector('[data-slot="session-side-panel-content"]')?.getBoundingClientRect()
if (!frame || !content) return Number.POSITIVE_INFINITY
return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left)
}, direction),
)
.toBeLessThanOrEqual(1)
}
async function expectStackPainted(page: Page) {
const gaps = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.paintGaps ?? [],
)
expect(gaps.length).toBeGreaterThan(0)
expect(Math.max(...gaps.map((gap) => gap.review))).toBeLessThanOrEqual(1)
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
}
async function resetTerminalTopMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalTops = []
})
}
async function resetTerminalBottomMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalBottoms = []
})
}
async function expectTerminalBottomFixed(page: Page) {
const bottoms = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalBottoms ?? [],
)
expect(bottoms.length).toBeGreaterThan(0)
expect(Math.max(...bottoms) - Math.min(...bottoms)).toBeLessThanOrEqual(1)
}
async function resetTerminalAnchorGaps(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.resetAnchorOnMotion = true
})
}
async function resetPanelGaps(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.panelGaps = []
})
}
async function expectPanelGapHeld(page: Page) {
const gaps = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.panelGaps ?? [],
)
expect(gaps.length).toBeGreaterThan(0)
expect(gaps.filter((gap) => gap >= 7 && gap <= 9).length / gaps.length).toBeGreaterThan(0.6)
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0)
expect(Math.max(...gaps)).toBeLessThanOrEqual(9)
}
async function expectTerminalTopAnchored(page: Page) {
const gaps = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalAnchorGaps ?? [],
)
expect(gaps.length).toBeGreaterThan(0)
expect(Math.max(...gaps), JSON.stringify(gaps)).toBeLessThanOrEqual(8)
}
async function resetTerminalContentSizes(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalContentSizes = []
})
}
async function expectTerminalContentCachedSize(page: Page) {
const sizes = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalContentSizes ?? [],
)
expect(sizes.length).toBeGreaterThan(0)
expect(Math.min(...sizes.map((size) => size.width))).toBeGreaterThan(100)
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
}
async function expectTerminalTopMotion(page: Page) {
const tops = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
)
const unique = [...new Set(tops)]
const range = Math.max(...unique) - Math.min(...unique)
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
}
async function expectHeightMotions(page: Page, slot: string, count: number) {
await expect
.poll(() =>
page.evaluate(
(slot) =>
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights.filter((value) => value === slot)
.length ?? 0,
slot,
),
)
.toBeGreaterThanOrEqual(count)
}
async function expectAnimation(page: Page, name: string) {
await expect
.poll(() =>
page.evaluate(
(name) =>
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.animations.includes(name) ?? false,
name,
),
)
.toBe(true)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -358,7 +358,8 @@ test.describe("smoke: session timeline", () => {
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("bun typecheck")
await expect(shell.locator('[data-slot="bash-result"]')).not.toContainText("bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
+33
View File
@@ -174,6 +174,39 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPrompt", "/api/session/:sessionID/prompt", {
params: SessionParams,
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchModel", "/api/session/:sessionID/model", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("sessionInboxCancel", "/api/session/:sessionID/inbox/:inboxID", {
params: { ...SessionParams, inboxID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
params: { ...SessionParams, inboxID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
params: SessionParams,
+37 -2
View File
@@ -35,6 +35,9 @@ export interface MockServerConfig {
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
}
type MockStreamWindow = Window & {
@@ -397,7 +400,39 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () => Effect.succeed({ data: [] }),
sessionInbox: () =>
Effect.sync(() => ({ data: typeof config.inbox === "function" ? config.inbox() : (config.inbox ?? []) })),
sessionPrompt: (ctx) =>
Effect.sync(() => {
const body = record(ctx.payload) ? ctx.payload : {}
config.onPrompt?.({ sessionID: ctx.params.sessionID, body })
return {
data: {
id: typeof body.id === "string" ? body.id : `inb_mock_${Date.now()}`,
sessionID: ctx.params.sessionID,
timeCreated: Date.now(),
type: "user",
payload: {
text: typeof body.text === "string" ? body.text : "",
...(body.files === undefined ? {} : { files: body.files }),
...(body.agents === undefined ? {} : { agents: body.agents }),
...(body.skills === undefined ? {} : { skills: body.skills }),
...(body.metadata === undefined ? {} : { metadata: body.metadata }),
},
delivery: body.delivery === "queue" ? "queue" : "steer",
},
}
}),
sessionInboxCancel: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxSteer: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
sessionPermission: (ctx) => {
const permissions =
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
@@ -501,7 +536,7 @@ function currentModels(value: unknown) {
return [
{
id: model.id,
modelID: model.id,
modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
+2
View File
@@ -57,6 +57,7 @@
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -68,6 +69,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
+13 -13
View File
@@ -105,14 +105,16 @@ export function AppInterface(props: {
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
<GlobalProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
@@ -123,11 +125,9 @@ export function AppInterface(props: {
servers={props.servers}
>
<SettingsProvider>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
</Dynamic>
</GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
</Dynamic>
</SettingsProvider>
</ServersProvider>
)
+27 -1
View File
@@ -1,4 +1,5 @@
import type { Data } from "@opencode-ai/client/solid"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
import type { ModelSelection } from "@/providers/models/selection"
import type { ServerSDK } from "@/runtime/server/client"
@@ -38,9 +39,33 @@ export type ComposerSelection = {
variant?: string
}
export type ComposerDelivery = "steer" | "queue"
// Contract between the composer and the session prompt queue. The session
// owns the queue (pending inbox items); the composer only asks which delivery
// a submit should use and delegates edit confirmation while a queued prompt
// is loaded in the editor.
export type ComposerQueue = {
count: Accessor<number>
// Delivery a plain submit uses right now.
delivery: Accessor<ComposerDelivery>
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
alternate: Accessor<ComposerDelivery | undefined>
// Inbox ID of the queued prompt currently loaded in the composer for editing.
editing: Accessor<string | undefined>
confirmEdit: (delivery: ComposerDelivery) => void
cancelEdit: () => void
// Loads the first queued prompt into the composer. Returns false when the queue is empty.
editFirst: () => boolean
}
export type ComposerSession = {
id: string
directory: string
handoff?: {
set: (message: SessionMessageUser) => void
clear: (messageID: string) => void
}
api: {
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
@@ -51,6 +76,7 @@ export type ComposerSession = {
location: { command: Pick<Data["location"]["command"], "list"> }
session: {
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
setStatus: Data["session"]["setStatus"]
}
}
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
@@ -77,7 +103,7 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & {
start: (
selection: ComposerSelection,
submission: ReturnType<typeof createComposerSubmission>,
) => Promise<ComposerSession | undefined>
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void> } | undefined>
}
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
+3 -2
View File
@@ -8,7 +8,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ComposerEditor } from "./editor/editor"
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
import { useCommand } from "@/shell/commands/command"
import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
@@ -29,9 +29,10 @@ export function Composer(props: {
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.model.model.loading}
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+64 -14
View File
@@ -42,9 +42,10 @@ export type ComposerEditorProps = {
borderUnderlay?: boolean
class?: string
modelControl?: JSX.Element
variantControlVisible?: boolean
modelControlsVisible?: boolean
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -177,10 +178,15 @@ export function ComposerEditor(props: ComposerEditorProps) {
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit()
props.controller.submit(mod ? { alternate: true } : undefined)
}
}}
onKeyUp={updateCursor}
@@ -233,19 +239,27 @@ export function ComposerEditor(props: ComposerEditorProps) {
/>
)}
</Show>
{props.modelControl}
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
/>
</Show>
)}
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
/>
</Show>
)}
</Show>
</Show>
</div>
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -253,7 +267,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
accent={props.accentSubmit}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={props.controller.submit}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</div>
@@ -689,6 +703,42 @@ export function ComposerEditorPopover(props: {
)
}
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
// delivery opposite to what plain Enter does. Visible only while the queue
// exposes an alternate (turn running and composer holding a value), so it
// disappears on its own when the current turn ends.
function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) {
const i18n = useI18n()
const view = props.controller.view
const action = createMemo(() => {
const queue = view.submit.queue
if (!queue || !props.controller.canSubmit()) return undefined
if (queue.editing()) return "steer" as const
return queue.alternate()
})
return (
<Show when={action()} keyed>
{(delivery) => (
<Tooltip placement="top" inactive={delivery !== "steer"} value={i18n.t("ui.promptInput.steerHint")}>
<Button
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530]"
onClick={() => props.controller.submit({ alternate: true })}
>
{delivery === "steer" ? i18n.t("ui.promptInput.steer") : i18n.t("ui.promptInput.queue")}
<span class="hidden sm:block">
<Keybind keys={props.keybind} variant="neutral" />
</span>
</Button>
</Tooltip>
)}
</Show>
)
}
export function ComposerEditorSubmitButton(props: {
mode: ComposerMode
stopping: boolean
@@ -19,6 +19,7 @@ import {
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
export type ComposerSelectControl = {
options: Accessor<ComposerOption[]>
@@ -37,7 +38,8 @@ export type ComposerEditorView = {
submit: {
stopping: Accessor<boolean>
working?: Accessor<boolean>
onSubmit: () => void
queue?: ComposerQueue
onSubmit: (options?: { alternate?: boolean }) => void
onStop: () => void
}
shell?: {
@@ -212,6 +214,11 @@ export function createComposerEditor(input: {
)
}
if (handled) return true
if (event.key === "Escape" && input.view.submit.queue?.editing()) {
event.preventDefault()
input.view.submit.queue.cancelEdit()
return true
}
const stop =
input.view.submit.working?.() &&
((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
@@ -354,8 +361,8 @@ export function createComposerEditor(input: {
openShell() {
dispatch({ type: "mode.shell" })
},
submit() {
input.view.submit.onSubmit()
submit(options?: { alternate?: boolean }) {
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
},
stop() {
+24 -4
View File
@@ -16,7 +16,7 @@ import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerAdapter, ComposerControls } from "./adapter"
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
@@ -27,7 +27,7 @@ export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
}
export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
const sdk = useWorkspaceLocation()
const data = useData()
const files = useFile()
@@ -80,7 +80,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
})
const stopping = createMemo(() => adapter.working() && blank())
const placeholder = () =>
composerPlaceholder(mode(), (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never))
composerPlaceholder(
mode(),
(key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
adapter.working() || (options?.queue?.count() ?? 0) > 0,
)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
@@ -253,6 +257,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
delivery: (alternate) => {
const queue = options?.queue
if (!queue) return "steer"
return (alternate ? queue.alternate() : queue.delivery()) ?? "steer"
},
notify: {
missingSelection: () =>
showToast({
@@ -360,7 +369,18 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
submit: {
stopping,
working: adapter.working,
onSubmit: () => void submission.submit(new Event("submit")),
queue: options?.queue,
onSubmit: (submitOptions) => {
const queue = options?.queue
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
void submission.submit(new Event("submit"), submitOptions)
},
onStop: () => void submission.stop(),
},
},
@@ -12,4 +12,12 @@ describe("Composer placeholder", () => {
test("uses the command and context hint in normal mode", () => {
expect(composerPlaceholder("normal", t)).toBe("ui.promptInput.placeholder.normal/@")
})
test("uses the follow-up copy while a turn runs or prompts are queued", () => {
expect(composerPlaceholder("normal", t, true)).toBe("ui.promptInput.placeholder.followUp/@")
})
test("keeps the shell placeholder while a turn runs", () => {
expect(composerPlaceholder("shell", t, true)).toBe("prompt.placeholder.shell:git status")
})
})
+2
View File
@@ -1,7 +1,9 @@
export function composerPlaceholder(
mode: "normal" | "shell",
t: (key: string, params?: Record<string, string>) => string,
followUp?: boolean,
) {
if (mode === "shell") return t("prompt.placeholder.shell", { example: "git status" })
if (followUp) return t("ui.promptInput.placeholder.followUp", { slash: "/", at: "@" })
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
}
+68 -6
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Skill } from "@opencode-ai/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
@@ -69,6 +70,8 @@ function submitInput(
function session(input: {
calls: string[]
prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
handoff?: ComposerSession["handoff"]
statuses?: ("idle" | "running")[]
current?: ComposerSession["current"]
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
@@ -77,6 +80,7 @@ function session(input: {
return {
id: "session-1",
directory: "C:/repo",
handoff: input.handoff,
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
@@ -92,6 +96,7 @@ function session(input: {
data: {
location: { command: { list: () => [] } },
session: {
setStatus: (_sessionID, status) => input.statuses?.push(status),
prompt: async (value) => {
input.calls.push("prompt")
await input.prompt(value)
@@ -140,10 +145,12 @@ describe("Composer submission", () => {
test("starts and promotes a New Session once before admitting its first prompt", async () => {
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
const promoted = createMemoryComposerState().capture()
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()
const calls: string[] = []
const statuses: ("idle" | "running")[] = []
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls, prompt: async (value) => admitted.resolve(value) })
const cleanupReady = Promise.withResolvers<void>()
const target = session({ calls, statuses, prompt: async (value) => admitted.resolve(value) })
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state: draft,
@@ -156,20 +163,72 @@ describe("Composer submission", () => {
async start(_selection, submission) {
calls.push("start")
submission.retarget(promoted)
return target
return { session: target, cleanupReady: cleanupReady.promise }
},
}
await submitInput(adapter).submit(new Event("submit"))
const submitted = submitInput(adapter).submit(new Event("submit"))
const request = await admitted.promise
expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"])
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt"])
expect(statuses).toEqual(["running"])
expect(promoted.current()).toMatchObject([{ type: "text", content: "restored draft" }])
cleanupReady.resolve()
await submitted
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt", "submitted"])
expect(request.delivery).toBe("steer")
expect(request.text).toBe("first prompt")
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})
test("hands off image-only first prompts before admission", async () => {
const draft = createMemoryComposerState().capture()
draft.set([
{ type: "text", content: "", start: 0, end: 0 },
{
type: "image",
id: "attachment",
filename: "image.png",
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
])
const handedOff = Promise.withResolvers<SessionMessageUser>()
const target = session({
calls: [],
handoff: { set: handedOff.resolve, clear() {} },
prompt: async () => undefined,
})
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state: draft,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(adapter).submit(new Event("submit"))
expect(await handedOff.promise).toMatchObject({
type: "user",
text: "",
files: [
{
data: "",
mime: "image/png",
source: { type: "uri", uri: "data:image/png;base64,YQ==" },
name: "image.png",
},
],
})
})
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture()
const checked = Promise.withResolvers<void>()
@@ -233,7 +292,7 @@ describe("Composer submission", () => {
submitted() {},
async start(_selection, submission) {
submission.retarget(promoted)
return target
return { session: target, cleanupReady: Promise.resolve() }
},
}
@@ -250,10 +309,12 @@ describe("Composer submission", () => {
test("reuses the message ID when an unacknowledged admission is retried", async () => {
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
const attempts: string[] = []
const statuses: ("idle" | "running")[] = []
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const target = session({
calls: [],
statuses,
prompt: async (value) => {
attempts.push(value.id ?? "")
throw new Error("network unavailable")
@@ -283,6 +344,7 @@ describe("Composer submission", () => {
expect(attempts).toHaveLength(4)
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
})
+94 -43
View File
@@ -1,10 +1,11 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
@@ -20,7 +21,7 @@ type ComposerSubmission = {
text: string
images: ImageAttachmentPart[]
selection: ComposerSelection
delivery: "steer"
delivery: ComposerDelivery
}
type ComposerSubmitInput = {
@@ -32,6 +33,7 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
@@ -44,7 +46,7 @@ type ComposerSubmitInput = {
}
export function createComposerSubmit(input: ComposerSubmitInput) {
const submit = async (event: globalThis.Event) => {
const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => {
event.preventDefault()
const submission = createComposerSubmission({
@@ -55,7 +57,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const value = readSubmission(input, submission.prompt, submission.context)
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!value) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
@@ -65,15 +67,43 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const comments = input.comments.capture()
try {
const session =
const started =
input.adapter.kind === "active-session"
? input.adapter.session()
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
: await input.adapter.start(value.selection, submission)
if (!session) return
if (!started) return
const session = started.session
input.addToHistory(value.prompt, value.mode)
input.resetHistory()
const restore = () => restoreSubmission(input, submission, value, comments)
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
if (value.mode === "normal" && !command) {
if (value.images.length > 0) session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
await started.cleanupReady
input.adapter.submitted()
submission.context
.filter((item) => !!item.comment?.trim())
.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
void sending.then((result) => {
if (!result.ok)
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
})
})
return
}
await started.cleanupReady
input.adapter.submitted()
if (value.mode === "shell") {
@@ -82,23 +112,17 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
return
}
const command = findCommand(session, value.text)
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command).catch((error) =>
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
return
}
submission.context
.filter((item) => !!item.comment?.trim())
.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
void sendPrompt(session, value).catch((error) =>
failSubmission(input, session, "prompt", error, restore, value.id),
)
} finally {
submitting.delete(input.adapter.state)
}
@@ -110,10 +134,34 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
}
}
function handoffMessage(value: ComposerSubmission): SessionMessageUser {
return {
id: value.id,
type: "user",
text: value.text,
files: value.images.map((image) => ({
data: "",
mime: image.mime,
source: { type: "uri", uri: image.blob.url },
name: image.sourcePath ?? image.filename,
})),
metadata: {
displayText: value.text,
agent: value.selection.agent,
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
},
time: { created: Date.now() },
}
}
function readSubmission(
input: ComposerSubmitInput,
prompt: Prompt,
context: ComposerSubmission["context"],
alternate: boolean,
): ComposerSubmission | undefined {
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const mode = input.mode()
@@ -152,7 +200,7 @@ function readSubmission(
model: { modelID: model.id, providerID: model.provider.id },
variant,
},
delivery: "steer",
delivery: input.delivery?.(alternate) ?? "steer",
}
}
@@ -231,15 +279,8 @@ async function sendCommand(
const request = await buildSubmissionRequest(session, value)
await session.api.command({
sessionID: session.id,
id: value.id,
command: command.command,
arguments: command.arguments,
agent: value.selection.agent,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
text: command.arguments,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
@@ -249,23 +290,30 @@ async function sendCommand(
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
const request = await buildSubmissionRequest(session, value)
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
}
const admission = {
@@ -313,8 +361,11 @@ function failSubmission(
error: unknown,
restore: () => boolean,
messageID?: string,
rollback?: () => void,
) {
if (messageID && session.admitted(messageID)) return
if (messageID) session.handoff?.clear(messageID)
rollback?.()
restore()
input.notify.failed(kind, error)
}
+1
View File
@@ -1,6 +1,7 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export { ServerConnection, useServers } from "./runtime/server/registry"
+4
View File
@@ -42,6 +42,10 @@ const clearAuthToken = () => {
const web = createWebPlatform(pkg.version)
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"), { once: true })
}
if (import.meta.env.VITE_SENTRY_DSN) {
init({
dsn: import.meta.env.VITE_SENTRY_DSN,
+5 -6
View File
@@ -1,6 +1,6 @@
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
@@ -8,12 +8,11 @@ import { createEffect, createMemo, startTransition } from "solid-js"
export function createHomeController() {
const layout = useLayout()
const global = useGlobal()
const servers = useServers()
const tabs = useTabs()
const selection = layout.home.selection
const focusedServer = createMemo<ServerConnection.Any | undefined>(
() =>
global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ??
global.servers.list()[0],
() => servers.visible.find((conn) => ServerConnection.key(conn) === selection().server) ?? servers.visible[0],
)
const focusedServerCtx = useServerCtx(focusedServer)
const focusedSync = () => focusedServerCtx()?.sync
@@ -29,7 +28,7 @@ export function createHomeController() {
)
createEffect(() => {
const list = global.servers.list()
const list = servers.visible
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
const conn = list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
@@ -54,7 +53,7 @@ export function createHomeController() {
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
},
server: {
list: global.servers.list,
list: () => servers.visible,
health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn),
focused: focusedServer,
@@ -61,6 +61,8 @@ export function createHomeProjectsController(home: HomeController) {
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
canHide: (conn: ServerConnection.Any) => serverManagement.connection.canHide(ServerConnection.key(conn)),
hide: (conn: ServerConnection.Any) => serverManagement.connection.setHidden(ServerConnection.key(conn), true),
edit: (conn: ServerConnection.Http) => {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
@@ -26,6 +26,8 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
onSetDefaultServer={props.projects.server.setDefault}
canRemoveServer={props.projects.server.canRemove}
onRemoveServer={props.projects.server.remove}
canHideServer={props.projects.server.canHide}
onHideServer={props.projects.server.hide}
onMoveProject={props.projects.project.move}
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
+11 -5
View File
@@ -49,6 +49,8 @@ export type HomeProjectsViewProps = {
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
canRemoveServer: (server: ServerConnection.Any) => boolean
onRemoveServer: (server: ServerConnection.Any) => void
canHideServer: (server: ServerConnection.Any) => boolean
onHideServer: (server: ServerConnection.Any) => void
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
@@ -188,6 +190,8 @@ function HomeServerRow(props: {
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
canHideServer: HomeProjectsViewProps["canHideServer"]
onHideServer: HomeProjectsViewProps["onHideServer"]
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
server: ServerConnection.Any
@@ -212,11 +216,11 @@ function HomeServerRow(props: {
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
>
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
<HomeProjectNavButton
type="button"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
<HomeProjectNavButton
type="button"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
@@ -284,10 +288,12 @@ function HomeServerRow(props: {
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
canHide={props.canHideServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
onHide={() => props.onHideServer(props.server)}
open={props.contextMenuOpen(contextMenuID())}
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
/>
@@ -30,6 +30,7 @@ export function HomeCommandPalette(props: {
opened: server.projects.list,
stored: () => server.sync.data.project,
load: (search, signal) => server.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
get: (sessionID, signal) => server.sdk.api.session.get({ sessionID }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
+144 -4
View File
@@ -1,8 +1,12 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { skipToken, useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-ai/ui/dialog"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
import type { LocalProject } from "@/shell/state/layout"
@@ -11,7 +15,10 @@ import { ServerConnection } from "@/runtime/server/registry"
import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
@@ -35,6 +42,8 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const queryClient = useQueryClient()
const [removed, setRemoved] = createStore({ keys: [] as string[] })
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
if (!selected) return
@@ -59,9 +68,13 @@ export function createHomeSessionsController(home: HomeController) {
})
const indexedSessions = createMemo(() => {
const ctx = home.server.focusedContext()
if (!ctx) return []
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()),
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
),
HOME_SESSION_LIMIT,
Date.now(),
)
@@ -123,6 +136,119 @@ export function createHomeSessionsController(home: HomeController) {
},
])
const rename = async (server: ServerConnection.Key, session: SessionInfo, title: string) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const next = title.trim()
if (!next || next === sessionLabel(session)) return true
return ctx.sdk.api.session
.rename({ sessionID: session.id, title: next })
.then(() => {
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
// Rename advances time.updated server-side; re-sync the canonical
// record so date grouping and ordering do not go stale.
ctx.data.session.invalidate(session.id)
void ctx.data.session.sync(session.id).catch(() => {})
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.map((item) => (item.id === session.id ? { ...item, title: next } : item)),
)
return true
})
.catch((cause) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
return false
})
}
const exportSession = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!ctx) return
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (cause) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: cause instanceof Error ? cause.message : language.t("toast.session.export.failed.description"),
})
}
}
const remove = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
return ctx.sdk.api.session
.remove({ sessionID: session.id })
.then(() => {
const removedIDs = new Set(ids)
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((item) => !removedIDs.has(item.id)),
)
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
directory: session.location.directory,
sessionIDs: ids,
})
return true
})
.catch((cause) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(cause, language.t("session.delete.failed.title")),
})
return false
})
.finally(() => {
// Always refetch: the pre-mutation cancel may have aborted an
// in-flight index fetch, and a failed delete must not leave the
// index unloaded either.
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
})
}
function DeleteDialog(props: { server: ServerConnection.Key; session: SessionInfo }) {
const name = () => sessionTitle(props.session.title) ?? language.t("command.session.new")
const confirm = async () => {
await remove(props.server, props.session)
dialog.close()
}
return (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</Button>
</DialogFooter>
</Dialog>
)
}
return {
copy: {
language,
@@ -137,6 +263,17 @@ export function createHomeSessionsController(home: HomeController) {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(),
lookup: async (sessionID: string) => {
const ctx = home.server.focusedContext()
if (!ctx) return
const result = await ctx.sdk.api.session.get({ sessionID })
if (result.time.archived) return
return buildHomeSessionRecords({
sessions: () => [result],
projectDirectories,
projects: home.project.list,
})[0]
},
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
@@ -182,6 +319,10 @@ export function createHomeSessionsController(home: HomeController) {
}),
})
},
rename,
export: exportSession,
showDelete: (server: ServerConnection.Key, session: SessionInfo) =>
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) =>
@@ -232,7 +373,6 @@ export function HomeSessionStatusController(props: {
}) {
const avatar = useSessionTabAvatarState(
() => props.server,
() => props.record.session.location.directory,
() => props.record.session.id,
() => true,
)
@@ -27,6 +27,9 @@ export function HomeSessions(props: {
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
+35 -5
View File
@@ -8,13 +8,21 @@ import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "../model"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
import { looksLikeSessionID } from "@/session/search"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
const command = useCommand()
const language = useLanguage()
const [state, setState] = createStore({ value: "", focused: false, highlighted: "" })
const [state, setState] = createStore({
value: "",
focused: false,
highlighted: "",
exact: undefined as HomeSessionRecord | undefined,
lookingUp: false,
})
let lookup = 0
let root: HTMLDivElement | undefined
let input: HTMLInputElement | undefined
let list: HTMLDivElement | undefined
@@ -22,9 +30,11 @@ export function createHomeSessionSearchController(home: HomeController, sessions
const results = createMemo(() => {
const value = query().toLowerCase()
if (!value) return []
return sessions.data
const records = sessions.data
.searchRecords()
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
if (!state.exact || records.some((record) => record.session.id === state.exact?.session.id)) return records
return [state.exact, ...records]
})
const active = createMemo(() => {
const records = results()
@@ -50,6 +60,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
close()
}),
)
onCleanup(() => lookup++)
command.register("home.search", () => [
{
@@ -67,7 +78,26 @@ export function createHomeSessionSearchController(home: HomeController, sessions
}
function close() {
setState({ value: "", focused: false })
lookup++
setState({ value: "", focused: false, exact: undefined, lookingUp: false })
}
function update(value: string) {
const current = ++lookup
const sessionID = value.trim()
setState({ value, highlighted: "", exact: undefined, lookingUp: false })
if (!looksLikeSessionID(sessionID)) return
setState("lookingUp", true)
void sessions.session.lookup(sessionID).then(
(record) => {
if (current !== lookup) return
setState({ exact: record, lookingUp: false })
},
() => {
if (current !== lookup) return
setState("lookingUp", false)
},
)
}
function select(record: HomeSessionRecord, options?: { background?: boolean }) {
@@ -81,11 +111,11 @@ export function createHomeSessionSearchController(home: HomeController, sessions
placeholder,
open,
focus,
input: (value: string) => setState({ value, highlighted: "" }),
input: update,
close,
},
result: {
loading: sessions.data.loading,
loading: () => sessions.data.loading() || state.lookingUp,
list: results,
active,
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }),

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