Compare commits

...
Author SHA1 Message Date
Brendan Allan 4c44206ae2 feat(app): add a sidebar toggle for vertical tabs 2026-10-15 15:48:55 +08:00
Jack 4397ee44d0 docs(console): add DeepSeek V4.1 Flash Free (#55636) 2026-10-15 15:23:29 +08:00
Aiden Clineandkksingh000 b47c6802da fix(ai): accept null Responses function call namespace (#55631)
Co-authored-by: kksingh000 <kkandsingh000@gmail.com>
2026-10-15 02:13:14 -05:00
Aiden Cline ae8d59f8b5 fix(core): fit compaction output limit to the request actually sent (#55613) 2026-10-15 01:57:46 -05:00
opencode-agent[bot] 7ced591c3c chore: update nix node_modules hashes 2026-10-15 06:51:13 +00:00
Aiden Cline b55f220a17 fix(core): treat *.localhost as loopback for MCP OAuth token endpoints (#55627) 2026-10-15 01:39:46 -05:00
fbf5f164d9 fix(core): skip MCP list calls for unadvertised capabilities (#55474)
Co-authored-by: tsehla42 <tsehla42@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-15 01:26:12 -05:00
Don BowmanandAiden Cline 3aa26f1676 fix(core): advertise the spec-shaped MCP elicitation capability (#53196)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-15 01:19:35 -05:00
Aiden Cline a8fc567c5c chore(core): redact params in database query errors (#55621) 2026-10-15 01:04:07 -05:00
Aiden Cline 7b5f1bd9e1 fix(core): skip codemode query for MCP servers that reject it (#55618) 2026-10-15 00:50:11 -05:00
opencode-agent[bot] bdf4793286 feat(app): show voice delegations from the iOS app as Voice messages (#54426) 2026-10-15 13:23:12 +08:00
Luke Parker 37df62f4a1 fix(session-ui): show webfetch URL while the fetch runs (#55590) 2026-10-15 12:24:18 +10:00
Aiden Cline d099d2cb2a fix(cli): poll install download progress instead of tracing bytes (#55586) 2026-10-14 20:57:03 -05:00
Aiden Cline 97c201931c fix(www): serve install script from dev branch (#55587) 2026-10-14 20:37:54 -05:00
328f74c213 fix(mcp): pass tool input as permission metadata (#51062)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-14 18:52:52 -05:00
Aiden Cline 32b1a1bf85 test(core): run MCP lifecycle tests against an in-process server (#55568) 2026-10-14 18:50:26 -05:00
Luke Parker 2ae7ec8a16 fix(app): keep project settings open when the project folder is missing (#55553) 2026-10-15 09:40:43 +10:00
Aiden Cline ccd3aa787a fix(schema): publish config schema that accepts V1 and V2 shapes (#55524) 2026-10-14 18:09:31 -05:00
Aiden Cline e05cc4a18d docs(plugin): document permission assert and fix session rules example (#55526) 2026-10-14 18:08:45 -05:00
Luke Parker 3943b84ef0 fix(cli): default the Windows service to port 45278 (#55547) 2026-10-15 08:41:49 +10:00
Filipandvegerot 1e63a3bb73 fix(core): load configured instructions (#55539)
Co-authored-by: vegerot <5297725+vegerot@users.noreply.github.com>
2026-10-15 00:34:17 +02:00
vprdev 7291f3e07a fix(console): keep API route handlers out of route modules (#55541) 2026-10-14 14:41:00 -07:00
opencode-agent[bot]andTarquinen 13afea6a1a docs(console): correct Cat Jam Free privacy on Go page (#55499)
Co-authored-by: Tarquinen <78942835+Tarquinen@users.noreply.github.com>
2026-10-14 14:00:46 -05:00
James Long 8ffbcc2c81 fix(tui): tidy docked diff panel spacing (#55467) 2026-10-14 13:12:43 -04:00
opencode-agent[bot]andrekram1-node 35bb8e44ef fix(core): treat empty configured provider and model names as omitted (#55465)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-14 11:29:44 -05:00
opencode-agent[bot]anddc85 0ccfe69ff3 docs(console): list Cat Jam Free unavailable regions (#55466)
Co-authored-by: dc85 <83497+dc85@users.noreply.github.com>
2026-10-14 09:28:55 -07:00
Stefan Avram 07c0ff5914 feat(console): enrich Salesforce leads with Console account status (#54387) 2026-10-14 12:28:23 -04:00
James Long fe1717b309 fix(tui): put the diff side panel behind an experiment (#55456) 2026-10-14 12:16:18 -04:00
Aiden Cline 943dc005f7 fix(vscode): use the sst/opencode repository URL for Marketplace publishing (#55463) 2026-10-14 11:04:25 -05:00
Aiden Cline cddf1dd3e9 docs: document top-level policies in Console API response (#55461) 2026-10-14 10:55:52 -05:00
James Long b474b84259 fix(tui): use the action disabled token for inactive key hints (#55452) 2026-10-14 11:28:46 -04:00
James Long c0d41d2b26 feat(tui): render diffs in plugin panel slots (#47152) 2026-10-14 11:26:36 -04:00
Filip c458ad72d3 fix(core): substitute empty for missing config file references (#55388) 2026-10-14 16:35:25 +02:00
opencode-agent[bot]andneriousy f44c40b4d3 docs: add mise install method (#55415)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-10-14 15:43:06 +02:00
opencode-agent[bot]andBrendonovich 5fa0a42386 feat(app): restore option arrow session tab navigation (#54277)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-14 21:35:52 +08:00
Jack d3e1de326b docs(www): remove Ling free models from Console models (#55410) 2026-10-14 21:29:21 +08:00
opencode-agent[bot]andBrendonovich 81d57087a4 feat(session-ui): summarize each turn's changed files (#55064)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-14 21:13:31 +08:00
opencode 34d8ded0c5 sync release versions for v2.0.29 [skip ci] 2026-10-14 09:03:25 +00:00
Jack 3a69bae845 docs(go): remove DeepSeek V4 Flash and V4 Flash Vision Exp (#55332) 2026-10-14 15:12:10 +08:00
黑墨水鱼 a7ad408dfe fix(stats): normalize deepseek v4.1 flash codename (#55330) 2026-10-14 15:01:34 +08:00
opencode-agent[bot] 6cc58b9094 docs(go): add Gemini 4 Argon Contributor (#55314)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-10-14 00:00:53 -07:00
Luke Parker 165cd2e832 feat(desktop): pair devices through remote access (#55306) 2026-10-14 15:56:17 +10:00
Aiden Cline 5aabb619c0 fix(plugin): support Figma Git installs and dev releases (#55304) 2026-10-13 23:31:48 -05:00
Aiden Cline def5d948ef chore(tui): revert OpenTUI 0.6 upgrade (#55300) 2026-10-13 23:27:27 -05:00
Aiden Cline f1632d052d fix(plugin): use public Figma endpoint and OAuth defaults (#55252) 2026-10-13 23:18:49 -05:00
Aiden Cline f576da508d feat(core): stabilize top-level policies config (#55297) 2026-10-13 22:47:44 -05:00
Luke Parker 1c28c2fec5 fix(app): show a steer as pending from its first frame (#55251) 2026-10-14 13:07:36 +10:00
Luke Parker 52ba7d630f test(app): stop partial settings mocks leaking across browser tests (#55276) 2026-10-14 12:58:50 +10:00
Aiden Cline 6dc4d4e758 fix(core): give managed configuration highest precedence (#55267) 2026-10-13 21:28:47 -05:00
Aiden Cline 6a7cedcbf1 feat(plugin): let plugins assert permissions (#54930) 2026-10-13 20:28:24 -05:00
Luke Parker 89e043b681 fix(plugin): resolve the figma plugin's peer from the workspace (#55269) 2026-10-14 11:15:01 +10:00
Aiden Clineand1052326311 73517183b3 fix(plugin): keep each Promise hook rejection on its own tool call (#55263)
Co-authored-by: 1052326311 <65798732+1052326311@users.noreply.github.com>
2026-10-13 19:31:51 -05:00
usrnk1andLukeParkerDev 9de9b746e3 feat(desktop): refine diff view colors (#52551)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-14 10:12:13 +10:00
opencode-agent[bot]andSlickstef11 d2cedffd71 docs: correct Cat Jam Free data policy (#55224)
Co-authored-by: Slickstef11 <98915060+Slickstef11@users.noreply.github.com>
2026-10-14 00:11:09 +00:00
usrnk1andLukeParkerDev de417edf24 feat(desktop): manage provider accounts and restore Console connect intro (#54101)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-13 23:49:06 +00:00
usrnk1andLukeParkerDev 293a5bab9a feat(desktop): move session unread dots into tab container (#53570)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-13 23:40:15 +00:00
opencode-agent[bot]andrekram1-node 41f656730e docs: use supported policy action names (#55259)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-13 18:39:07 -05:00
Kit Langton 4ea42c47df fix(session-ui): render user message attachments above text and admit inline files (#54650) 2026-10-13 16:28:42 -07:00
opencode-agent[bot]andrekram1-node 43bb7ba3a2 fix(core): let v1 npm config target built-in provider routes (#55254)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-10-13 18:23:51 -05:00
usrnk1andLukeParkerDev 7b33bcea4e feat(desktop): show session tokens and cost on the session view (#53785)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-13 23:14:04 +00:00
Kit Langton 46e4695b96 fix(app): retain raw media bytes up to 256 MiB for artifact previews (#55018) 2026-10-13 15:52:31 -07:00
usrnk1andLukeParkerDev 3ec856b6c2 feat(desktop): refresh question composer (#54822)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-14 08:02:38 +10:00
Kit Langton 2a593c0d1b fix(app): suppress missing-file toast on restored and open file tabs (#55022) 2026-10-13 14:24:55 -07:00
Aiden Cline a3f7f42ac9 feat(core): continue responses after output token limits (#53876) 2026-10-13 16:24:45 -05:00
Kit Langton 7497b89b6d feat(file): add trackpad zoom and pan to image previews (#54590) 2026-10-13 14:19:45 -07:00
opencode-agent[bot] 80d43eb707 chore: update nix node_modules hashes 2026-10-13 20:53:16 +00:00
Dax d0a2ce32de feat(tui): report pending session status to the terminal (#55172) 2026-10-13 16:48:09 -04:00
Kit Langton 8396de1759 fix(app): retain session model choices across catalog refreshes (#54481) 2026-10-13 13:46:52 -07:00
Kit Langton 1163e6cd91 fix(app): fill vertical tab hover gaps (#54866) 2026-10-13 13:15:45 -07:00
Kit Langton 573fd296d0 feat(app): cycle side-panel tabs when side panel is focused (#54899) 2026-10-13 13:13:34 -07:00
Kit Langton 84e095526c fix(app): close browser tabs from the address field (#54501) 2026-10-13 13:13:28 -07:00
opencode-agent[bot]andopencode-agent[bot] e9def88a77 docs(console): remove Cat Jam Free from models privacy exceptions (#55209)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-10-13 11:16:10 -07:00
opencode-agent[bot] 0af756b30f docs(console): show Gemini 4 Argon discount with strikethrough pricing (#55206)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-10-13 10:56:06 -07:00
Aiden Cline 55c863daf3 feat(update): index artifact rollout lookups (#55202) 2026-10-13 12:36:48 -05:00
James Long d8e31d846a fix(server): align connect route checks (#55177) 2026-10-13 13:33:55 -04:00
opencode-agent[bot] a00475a4dd docs(console): add Gemini 4 Argon to Console models (#55196)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-10-13 10:32:09 -07:00
Jack 7410b1c13c docs(console): add Cat Jam Free to Go (#55188) 2026-10-14 00:48:52 +08:00
23e3493ab2 fix(update): cache D1 reads and use partial indexes for lookups (#55109)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-13 10:07:40 -05:00
Victor Navarro 82833dac90 refactor(console): stop stripping legacy Zen headers in proxy (#55120) 2026-10-13 16:43:43 +02:00
opencode-agent[bot] 1015cb67d0 chore: update nix node_modules hashes 2026-10-13 13:07:44 +00:00
Simon Klee 65f8907708 chore(tui): upgrade OpenTUI to 0.6.2 (#55083) 2026-10-13 14:59:21 +02:00
Victor Navarro c1615b1db9 refactor(console): call inference through a service binding (#55103) 2026-10-13 12:12:52 +02:00
Victor Navarro 4ffede4941 chore(console): remove unused translation strings (#55093) 2026-10-13 11:38:57 +02:00
Luke Parker be674e3158 fix(app): stop project store eviction loop on launch (#55078) 2026-10-13 19:22:40 +10:00
usrnk1andLukeParkerDev 5b22e915d8 feat(desktop): clarify variant-only timeline notices (#54880)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-10-13 09:03:44 +00:00
opencode c5aabae288 sync release versions for v2.0.28 [skip ci] 2026-10-13 08:17:45 +00:00
Aiden Cline 044d8b7d2e feat(ai): continue split Gemini responses (Gemini transport) (#55056) 2026-10-13 02:39:11 -05:00
opencode-agent[bot]andBrendonovich c7bddc38b3 fix(session-ui): drop stale todowrite tool label (#53966)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-13 07:35:36 +00:00
Aiden Cline 5a4dd5b430 feat(plugin): bundle complete Figma skills (#55061) 2026-10-13 02:10:45 -05:00
Luke Parker 7e858f474d fix(ui): return dialog focus from the stack, not from each caller (#55038) 2026-10-13 07:05:31 +00:00
opencode-agent[bot]andBrendonovich 417f120ee3 refactor(app): derive unread state from server viewed watermarks (#54780)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-13 14:58:13 +08:00
opencode-agent[bot]andBrendonovich bf8c48de73 fix(app): include subagent sessions in desktop session cost (#55052)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-13 06:55:33 +00:00
vprdev de65b9f9cc fix(console): keep Go limits chart headers from overflowing in long locales (#55054) 2026-10-12 23:49:47 -07:00
Aiden Cline 836c0e5e65 feat(plugin): add figma plugin (#55049) 2026-10-13 01:37:19 -05:00
Luke Parker 9693c31233 test(app): end the Working geometry fixture at an idle marker (#55045) 2026-10-13 16:28:40 +10:00
Luke Parker 4836431048 fix(storybook): declare @tanstack/solid-query for the pairing mock (#55047) 2026-10-13 16:25:02 +10:00
opencode-agent[bot]andBrendonovich b8f925e51c feat(app): per-project auto-accept permissions overriding a default (#54839)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-13 14:16:45 +08:00
opencode-agent[bot]andBrendonovich e65080dd08 refactor(app): drop per-message model and agent from mobile session timeline (#54775)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-13 06:10:24 +00:00
Aiden Cline d8129ca1e0 fix(ai): raise default chunk timeout to fifteen minutes (#55032) 2026-10-13 00:03:44 -05:00
opencode-agent[bot]anddc85 b4f19bfe74 docs(www): add Cat Jam Free to Console models (#55029)
Co-authored-by: dc85 <83497+dc85@users.noreply.github.com>
2026-10-12 21:57:28 -07:00
Tommy D. RossiandAiden Cline d78267503d feat(plugin): expose credential.activate to server plugins (#52797)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-12 23:29:22 -05:00
Luke Parker 06425d2bd7 fix(app): show Working above undelivered steers (#55023) 2026-10-13 14:24:30 +10:00
Aiden Cline 460c626acd fix(core): disable Bun's fetch idle timeout for provider requests (#55007) 2026-10-12 23:12:44 -05:00
Luke Parker 738f77e470 fix(app): load images and audio in the review panel (#55015) 2026-10-13 04:10:50 +00:00
Aiden ClineandRenato Duarte ba82b370c1 fix(core): discover system configuration directory at lowest precedence (#54921)
Co-authored-by: Renato Duarte <renato.duarte@datacamp.com>
2026-10-12 23:02:06 -05:00
Frank b4a57f6849 fix(console): redirect old zen and go docs from docs proxy 2026-10-12 23:02:39 -04:00
Frank d7cfdaa3c7 docs: redirect zen docs to console models 2026-10-12 22:46:50 -04:00
opencode-agent[bot]andrekram1-node 15b24ae12d fix(console): renew DeepSeek ZDR date on Go landing page through October 2026 (#54987)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-12 20:31:07 -05:00
Aiden Cline 69a71426ce feat(core): configure Anthropic cache breakpoints with compatibility.cacheFormat (#54945) 2026-10-12 20:23:14 -05:00
Luke Parker 6a12383d3e fix(desktop): update emulated x64 installs to native ARM64 (#54983) 2026-10-13 01:11:18 +00:00
Luke Parker bd23808adf fix(tui): report x64 emulation on Windows ARM64 (#54984) 2026-10-13 01:01:49 +00:00
Luke Parker 3731aace4a fix(ci): fail releases that bundle native code for the wrong CPU (#54982) 2026-10-13 10:46:28 +10:00
Luke Parker 126b7b633b fix(core): extract ripgrep on Windows without PowerShell (#54981) 2026-10-13 10:45:55 +10:00
Luke Parker f7ac58f267 fix(desktop): stage the bundled CLI per architecture (#54979) 2026-10-13 10:39:52 +10:00
opencode-agent[bot]andrekram1-node 67f5c2197a fix(update): return 503 instead of 1101 when D1 is overloaded (#54920)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-10-12 19:27:14 -05:00
Luke Parker df63a72da4 feat(console): offer the Windows ARM64 desktop download (#54971) 2026-10-12 23:31:34 +00:00
Dax 2bf145b931 feat(tui): show release highlights after updating (#54969) 2026-10-12 23:20:11 +00:00
Aiden ClineandEmre Şafak 5673bfa4a8 feat(core): add request body encoding setting (#54932)
Co-authored-by: Emre Şafak <3928300+esafak@users.noreply.github.com>
2026-10-12 18:17:39 -05:00
Filip d46588fe90 feat(cli): publish and upgrade official WinGet packages (#54808) 2026-10-13 01:03:35 +02:00
Luke Parker 039eb85ba1 fix(cli): install the native binary on Windows ARM64 (#54955) 2026-10-13 08:43:52 +10:00
Aiden Cline a22754f761 fix(core): support provider-level compatibility and migrate setCacheKey (#54911) 2026-10-12 15:54:05 -05:00
Aiden Cline 1438073271 fix(plugin): return Promise tool throws as tool failures (#54913) 2026-10-12 15:37:34 -05:00
80fb31ebb7 fix(vscode): launch the V2 CLI without --port (#49084)
Co-authored-by: uyencss <uyencss@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-12 13:58:37 -05:00
Dax 22326d4bc3 feat(changelog): serve /changelog from a standalone worker (#54906) 2026-10-12 18:56:09 +00:00
Dax c57c448d31 feat(console): render CHANGELOG.md on /changelog (#54902) 2026-10-12 14:41:18 -04:00
OpeOginni 1c74b9f020 fix(tui): restrict command autocomplete to prompt start (#54857) 2026-10-12 17:39:16 +00:00
Filip 7bac892c73 fix(core): stop listing Azure resources at startup (#54870) 2026-10-12 17:03:58 +00:00
Aiden Cline 5683367e27 fix(core): send unknown v1 openai-compatible model options in the request body (#54694) 2026-10-12 10:57:36 -05:00
opencode-agent[bot]andrekram1-node 5b7bad23b9 docs: point READMEs at @opencode/cli npm package (#54855)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-12 10:44:32 -05:00
Victor Navarro e41e7e9064 refactor(ai): rename OpenCodeZen provider to OpenCode (#54767) 2026-10-12 16:35:49 +02:00
Filip 0cb434775c fix(core): preserve mixed line endings in edit tool (#53166) 2026-10-12 16:22:58 +02:00
Victor Navarro bcf23b6775 docs(www): point Go endpoints to inference base (#54777) 2026-10-12 11:47:39 +02:00
Victor Navarro 25053bbdc7 docs: replace old Zen and Go pages with V2 console docs (#54761) 2026-10-12 11:30:55 +02:00
Victor Navarro a34961825b chore(infra): remove legacy Console Stripe products, prices and coupons (#54740) 2026-10-12 10:39:13 +02:00
Jack 60a212c076 docs(go): raise DeepSeek V4.1 Flash allowances in V2 (#54753) 2026-10-12 16:27:35 +08:00
Jack 337e933d4a docs(go): raise DeepSeek V4.1 Flash allowances (#54752) 2026-10-12 16:26:26 +08:00
Kit Langton 3e78cd58e0 fix(app): ignore collapsed side-panel tabs when closing a tab (#54695) 2026-10-12 17:32:29 +10:00
Kit Langton 6d1be26251 fix(app): expand video previews into in-window theatre mode (#54696) 2026-10-12 17:32:12 +10:00
opencode-agent[bot]andBrendonovich d306a15cf6 fix(app): show a submitted prompt in the frame the composer clears (#54047)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-12 07:31:41 +00:00
opencode-agent[bot] 8ede63f4f6 chore: update nix node_modules hashes 2026-10-12 07:26:39 +00:00
Victor Navarro b9ca4d21b4 refactor(console): remove legacy database from Console (#54585) 2026-10-12 09:17:22 +02:00
opencode-agent[bot] 773d8621d6 chore: update nix node_modules hashes 2026-10-12 05:37:34 +00:00
Max WeinbachandLuke Parker 854b3bb9d7 fix(desktop): bundle arm64 binaries for windows arm (#54689)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-10-12 15:27:39 +10:00
Luke Parker e5f4bf4c24 test(core): match codemode file fetch error message (#54699) 2026-10-12 15:16:24 +10:00
opencode 03cb9b9946 sync release versions for v2.0.27 [skip ci] 2026-10-12 04:17:06 +00:00
opencode-agent[bot]andHona 4d9678a79d fix(release): exclude V1 history from the release review range (#54674)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-10-12 13:37:19 +10:00
Aiden Cline 4d3d8fd769 docs: trim agent instructions (#54678) 2026-10-11 22:32:18 -05:00
Aiden Cline e5a557efee fix(cli): return unavailable in updater.check when install method is unknown (#54672) 2026-10-11 22:17:03 -05:00
Aiden Cline 35ef229454 feat(core): raise ChatGPT OAuth context for gpt-6.1-sol to 500k (#54668) 2026-10-11 21:25:51 -05:00
Aiden Cline 707de58326 fix(cli): prioritize path-matched package manager and extend probe timeout (#54622) 2026-10-11 16:23:56 -05:00
Adam ca19780de1 fix(stats): bound public page caching 2026-10-11 16:20:48 -05:00
Aiden Cline 47e5e71599 fix(cli): resolve symlinked home path when detecting curl installs (#54609) 2026-10-11 15:33:19 -05:00
Aiden Cline 1c63fe9afe docs: use update service latest redirect URLs for downloads (#54598) 2026-10-11 15:19:25 -05:00
Aiden Cline 727fd603be feat(update): redirect artifact filenames to latest release URLs (#54597) 2026-10-11 15:01:56 -05:00
Bence Ferdinandy 4fbdea2d13 fix(core): throw error on codemode fetch using file:// (#54510) 2026-10-11 15:00:30 -05:00
Dax 38454c6e9c feat(cli): ask before pair --remote first exposes the server (#54561) 2026-10-11 18:28:16 +00:00
opencode-agent[bot] 82dd3e3dd3 chore: update nix node_modules hashes 2026-10-11 18:08:57 +00:00
Victor Navarro 83f2b6f67a refactor(console): make Zen routes a thin inference proxy (#54563) 2026-10-11 19:58:32 +02:00
Kit Langton d21b0790a6 fix(ui): render formatting inside markdown link labels (#54451) 2026-10-11 19:27:08 +10:00
Kit Langton e31136bdbd fix(app): follow the first palette result until explicit selection (#54275) 2026-10-11 14:23:34 +10:00
vprdev 0d61729b3f fix(console): link Go and Zen login directly to Console (#54425) 2026-10-10 19:03:06 -07:00
Dax 4be28c9749 feat(core): archive projects whose directories are gone (#54354) 2026-10-10 21:11:05 -04:00
Aiden Cline 823ac87f58 fix(core): follow models.dev packages for Copilot fallback routing (#54210) 2026-10-10 18:19:15 -05:00
Dax Raad 7b3d4ce3a7 chore(ci): deploy the update service from production 2026-10-10 11:29:34 -04:00
1123 changed files with 52932 additions and 222060 deletions

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
name: deploy-changelog
on:
push:
branches:
- dev
paths:
- services/changelog/**
- .github/workflows/deploy-changelog.yml
workflow_dispatch:
concurrency:
group: deploy-changelog
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'dev'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Typecheck
working-directory: services/changelog
run: bun typecheck
- name: Test
working-directory: services/changelog
run: bun test
- name: Deploy
working-directory: services/changelog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+33
View File
@@ -0,0 +1,33 @@
name: deploy-update
on:
push:
branches:
- production
workflow_dispatch:
concurrency:
group: deploy-update-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'production'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Typecheck
working-directory: services/update
run: bun typecheck
- name: Deploy
working-directory: services/update
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+6 -7
View File
@@ -49,13 +49,6 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'dev'
working-directory: services/update
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
@@ -119,6 +112,7 @@ jobs:
BUN_COMPILE_RELEASE: bun-v1.4.2
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
OPENCODE_RELEASE_NOTES: ${{ (github.ref_name == 'dev' && inputs.release_notes) || '' }}
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
@@ -265,6 +259,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
OPENCODE_RELEASE_NOTES: ${{ (github.ref_name == 'dev' && inputs.release_notes) || '' }}
- name: Verify service lifecycle
if: matrix.settings.target != 'windows-arm64'
@@ -376,6 +371,7 @@ jobs:
- host: "windows-2025"
target: aarch64-pc-windows-msvc
platform_flag: --win --arm64
bun_install_flags: --cpu=*
- host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc
platform_flag: --win
@@ -478,6 +474,7 @@ jobs:
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_KEYCHAIN: build.keychain
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
@@ -491,6 +488,7 @@ jobs:
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Create macOS .app.tar.gz
if: runner.os == 'macOS' && needs.version.outputs.release
@@ -656,6 +654,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
OPENCODE_DESKTOP_DIST: /tmp/desktop
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1
View File
@@ -151,6 +151,7 @@
"**/.sst",
"**/*.d.ts",
"**/sdk.gen.ts",
"packages/plugins/figma/skills/**",
".agent/**",
".agents/**",
".claude/**",
+1
View File
@@ -2,3 +2,4 @@ sst-env.d.ts
packages/desktop/src/bindings.ts
packages/client/src/generated/
packages/client/src/generated-effect/
packages/plugins/figma/skills/
+7 -13
View File
@@ -1,19 +1,16 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
- The default branch in this repo is `dev`.
- Default new branches and worktrees to `dev`, or `origin/dev` when the local `dev` ref is unavailable, and default pull requests to target `dev`. Use another base or target branch when the requester explicitly instructs it.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- The default branch is `dev`. Base branches and worktrees on `origin/dev`, target PRs at `dev`, and diff against `origin/dev` unless told otherwise.
## Live V2 TUI Testing
## Live TUI Testing
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode service status`, injects its private local credential from `opencode service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## V2 TUI Stories
## TUI Stories
- When a user asks for a TUI story, add a fixture-driven story under `packages/tui/src/feature-plugins/system/storybook` and register it in `index.tsx`.
- Render the real production component rather than a visual copy. Keep submissions and other side effects local to the story so it is safe to explore repeatedly.
@@ -54,7 +51,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent.js"`) when adding a new config module.
- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
Reduce total variable count by inlining when a value is only used once.
@@ -175,18 +172,15 @@ const table = sqliteTable("session", {
- Run `bun run check` from the repository root as the canonical full lint and type-check verification.
- During focused iteration, run `bun typecheck` from the affected package directory (for example, `packages/core`). Never run `tsc` directly.
## V2 Session Core
## Session Core
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. Interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Each step is one explicit `llm.stream` call that continues from reloaded history. Do not add an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep provider-specific native compaction mechanisms in `@opencode/ai` behind `LLMClient.compact`. `SessionCompaction` chooses a summary or native compaction from the model's `compaction` setting and owns route provenance, request shrinking, the retry policy, interruption, usage accounting, and checkpoint persistence.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores changed source keys and content hashes and may freeze rendered chronological update text. Blob values live once in `instruction_blob`; the projected `instruction_state` row is the normal boundary-processing source of current and initial values. Request assembly renders the epoch baseline from stored values, while later frozen updates enter history as durable System messages. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Forks adopt the parent's newest instruction values even when copied message history ends at an earlier boundary. Unavailable sources retain the last value and block only the initial complete delta.
+223
View File
@@ -2,6 +2,229 @@
Release notes for OpenCode V2, newest first.
## v2.0.29 — 2026-10-14
### Highlights
- **Remote Device Pairing**: In **Settings → Pairing**, select **Pair device** to enable OpenTunnel remote access and get a QR code or copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
- **Provider Account Management**: Use the connect dialog or **Settings → Providers** to switch, rename, remove, or add provider accounts. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
- **Automatic Response Continuation**: Responses that reach their output-token limit now continue automatically, preserving partial output instead of stopping abruptly. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
- **Trackpad Image Navigation**: Pinch to zoom and use two-finger scrolling to pan image and SVG previews. Select **Fit** to reset the view. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
### Core
#### Improvements
- Automatically continue responses after an output-token limit, with bounded retries and clearer recovery for truncated tool calls. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
- Stabilize access policies under the top-level `policies` configuration key while retaining compatibility with `experimental.policies`. ([#55297](https://github.com/anomalyco/opencode/pull/55297))
#### Bug fixes
- Give centrally managed configuration precedence over local and user configuration. ([#55267](https://github.com/anomalyco/opencode/pull/55267))
- Allow migrated V1 npm provider configuration to target built-in provider routes. ([#55254](https://github.com/anomalyco/opencode/pull/55254))
### TUI
#### Improvements
- Report working sessions and sessions awaiting permission or input through terminal program-status notifications. ([#55172](https://github.com/anomalyco/opencode/pull/55172))
### Desktop
#### Improvements
- Pair devices from **Settings → Pairing** through OpenTunnel remote access, with a QR code and copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
- Manage provider accounts from the connect dialog and **Settings → Providers**, including switching, renaming, removal, and adding another account. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
- Show session context tokens and accumulated family cost in the session view. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
- Pinch to zoom and use two-finger scrolling to pan image and SVG previews; select **Fit** to reset. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
- Cycle side-panel tabs with the configured previous-tab and next-tab shortcuts while the side panel is focused. ([#54899](https://github.com/anomalyco/opencode/pull/54899))
- Refresh the question composer and clarify timeline notices that apply only to a model variant. ([#54822](https://github.com/anomalyco/opencode/pull/54822), [#54880](https://github.com/anomalyco/opencode/pull/54880))
- Refine diff colors and move unread indicators into session tabs. ([#52551](https://github.com/anomalyco/opencode/pull/52551), [#53570](https://github.com/anomalyco/opencode/pull/53570))
#### Bug fixes
- Show steered messages as pending immediately and render inline attachments before their server echo arrives. ([#55251](https://github.com/anomalyco/opencode/pull/55251), [#54650](https://github.com/anomalyco/opencode/pull/54650))
- Preserve raw media data up to 256 MiB so artifact previews remain available. ([#55018](https://github.com/anomalyco/opencode/pull/55018))
- Retain session model selections when the model catalog refreshes. ([#54481](https://github.com/anomalyco/opencode/pull/54481))
- Close browser tabs from the address field and suppress incorrect missing-file notices for restored or already-open tabs. ([#54501](https://github.com/anomalyco/opencode/pull/54501), [#55022](https://github.com/anomalyco/opencode/pull/55022))
- Prevent a project-store eviction loop during launch and remove hover gaps between vertical tabs. ([#55078](https://github.com/anomalyco/opencode/pull/55078), [#54866](https://github.com/anomalyco/opencode/pull/54866))
### SDK
#### Improvements
- Export `lastAssistantWithUsage` from the Solid client to consistently locate the assistant step that determines current context usage. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
#### Bug fixes
- Include all paginated child sessions when loading a session family for usage totals. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
- Admit inline data-URL files into optimistic pending messages so attachments render immediately. ([#54650](https://github.com/anomalyco/opencode/pull/54650))
### Extensions
#### Improvements
- Add `ctx.permission.assert` for Effect and Promise plugins, allowing plugins to use configured permission rules and the native approval prompt. ([#54930](https://github.com/anomalyco/opencode/pull/54930))
#### Bug fixes
- Use Figma's public endpoint and default OAuth configuration, and support Git installations and development releases. ([#55252](https://github.com/anomalyco/opencode/pull/55252), [#55304](https://github.com/anomalyco/opencode/pull/55304))
- Resolve the Figma plugin peer dependency from its workspace. ([#55269](https://github.com/anomalyco/opencode/pull/55269))
- Associate each rejected Promise hook with its own tool call. Thanks [@1052326311](https://github.com/1052326311). ([#55263](https://github.com/anomalyco/opencode/pull/55263))
## v2.0.28 — 2026-10-13
### Highlights
- **Per-Project Auto-Accept**: Set **Settings → Projects → Auto-accept permissions** to override the default for one project. You can also press `mod+shift+a` in a session. ([#54839](https://github.com/anomalyco/opencode/pull/54839))
- **Release Highlights in the TUI**: After updating, the home screen shows the release highlights once. Run `/highlights` to reopen them, or disable automatic display with `highlights: false` in `cli.json`. ([#54969](https://github.com/anomalyco/opencode/pull/54969))
- **Native Windows ARM64 Support**: Windows ARM64 downloads, CLI installs, and desktop updates now use native ARM64 builds instead of x64 emulation. ([#54689](https://github.com/anomalyco/opencode/pull/54689), [#54971](https://github.com/anomalyco/opencode/pull/54971))
- **Continuous Gemini Responses**: Long Gemini and Vertex Gemini responses split by the provider now continue automatically as one response, including recovery from a dropped stream after a continuation checkpoint. ([#55056](https://github.com/anomalyco/opencode/pull/55056))
- **Official WinGet Packages**: Install V2 with `winget install --id Anomaly.OpenCode -e` or `winget install --id Anomaly.OpenCode.Desktop -e`. OpenCode upgrades WinGet installations through WinGet. ([#54808](https://github.com/anomalyco/opencode/pull/54808))
### Core
#### Improvements
- Continued split Gemini and Vertex Gemini responses automatically using provider continuation tokens. ([#55056](https://github.com/anomalyco/opencode/pull/55056))
- Added `settings.encoding` with `zstd`, `gzip`, and `none` values for provider, model, and variant HTTP request bodies. ChatGPT authentication defaults to Zstandard compression when no custom `baseURL` is set. Thanks [@esafak](https://github.com/esafak). ([#54932](https://github.com/anomalyco/opencode/pull/54932))
- Added provider-level compatibility defaults and `compatibility.cacheFormat` for Anthropic- and Alibaba-style prompt-cache markers. ([#54911](https://github.com/anomalyco/opencode/pull/54911), [#54945](https://github.com/anomalyco/opencode/pull/54945))
- Added official WinGet packages and WinGet-aware upgrade and uninstall support. ([#54808](https://github.com/anomalyco/opencode/pull/54808))
- Raised the default streamed-chunk timeout from 5 to 15 minutes for models that pause while reasoning. ([#55032](https://github.com/anomalyco/opencode/pull/55032))
#### Bug fixes
- Disabled Bun’s fetch idle timeout for provider requests, preventing long-running streams from ending early. ([#55007](https://github.com/anomalyco/opencode/pull/55007))
- Preserved mixed CRLF and LF line endings when the edit tool changes files. ([#53166](https://github.com/anomalyco/opencode/pull/53166))
- Preserved unknown V1 OpenAI-compatible model options in migrated request bodies. ([#54694](https://github.com/anomalyco/opencode/pull/54694))
- Stopped querying Azure resources during startup; Azure connections now ask for the resource name directly. ([#54870](https://github.com/anomalyco/opencode/pull/54870))
- Loaded the system configuration directory at the lowest precedence. Thanks [@rntdrts](https://github.com/rntdrts). ([#54921](https://github.com/anomalyco/opencode/pull/54921))
- Installed the native CLI and extracted ripgrep without PowerShell on Windows ARM64. ([#54955](https://github.com/anomalyco/opencode/pull/54955), [#54981](https://github.com/anomalyco/opencode/pull/54981))
### TUI
#### Improvements
- Added the post-update highlights dialog, `/highlights`, and the `highlights` alert setting. ([#54969](https://github.com/anomalyco/opencode/pull/54969))
#### Bug fixes
- Restricted slash-command autocomplete to the start of the prompt. Thanks [@OpeOginni](https://github.com/OpeOginni). ([#54857](https://github.com/anomalyco/opencode/pull/54857))
- Reported x64 emulation correctly when the TUI runs on Windows ARM64. ([#54984](https://github.com/anomalyco/opencode/pull/54984))
- Removed the stale `todowrite` label from tool output. ([#53966](https://github.com/anomalyco/opencode/pull/53966))
### Desktop
#### Improvements
- Added per-project auto-accept permission overrides under **Settings → Projects**, with `mod+shift+a` controlling the current project. ([#54839](https://github.com/anomalyco/opencode/pull/54839))
- Added a native **Windows (ARM64)** option to the download page. ([#54971](https://github.com/anomalyco/opencode/pull/54971))
#### Bug fixes
- Bundled native ARM64 terminal and CLI binaries in Windows ARM64 desktop builds, and moved emulated x64 installations to native ARM64 updates. Thanks [@mweinbach](https://github.com/mweinbach). ([#54689](https://github.com/anomalyco/opencode/pull/54689), [#54983](https://github.com/anomalyco/opencode/pull/54983))
- Included subagent sessions in displayed session costs. ([#55052](https://github.com/anomalyco/opencode/pull/55052))
- Loaded image and audio attachments in the review panel and opened video previews in an in-window theatre view. ([#55015](https://github.com/anomalyco/opencode/pull/55015), [#54696](https://github.com/anomalyco/opencode/pull/54696))
- Showed submitted prompts immediately and kept the **Working** marker above undelivered steers. ([#54047](https://github.com/anomalyco/opencode/pull/54047), [#55023](https://github.com/anomalyco/opencode/pull/55023))
- Fixed tab closing when collapsed side-panel tabs exist and restored focus correctly after stacked dialogs close. ([#54695](https://github.com/anomalyco/opencode/pull/54695), [#55038](https://github.com/anomalyco/opencode/pull/55038))
### SDK
#### Improvements
- Added request-body encoding and model/provider cache-format fields to generated client types. ([#54932](https://github.com/anomalyco/opencode/pull/54932), [#54945](https://github.com/anomalyco/opencode/pull/54945))
- Exported `CredentialApi` from the Promise client API. ([#52797](https://github.com/anomalyco/opencode/pull/52797))
### Extensions
#### Improvements
- Added an optional official Figma plugin with its MCP connection and six bundled Figma skills. ([#55049](https://github.com/anomalyco/opencode/pull/55049), [#55061](https://github.com/anomalyco/opencode/pull/55061))
- Exposed `credential.activate` to Effect and Promise server plugins. Thanks [@remorses](https://github.com/remorses). ([#52797](https://github.com/anomalyco/opencode/pull/52797))
#### Bug fixes
- Converted rejected Promise-based plugin tools into normal tool failures instead of crashing their execution path. ([#54913](https://github.com/anomalyco/opencode/pull/54913))
- Fixed the VS Code extension launching the V2 CLI with the removed `--port` argument. Thanks [@uyencss](https://github.com/uyencss). ([#49084](https://github.com/anomalyco/opencode/pull/49084))
## v2.0.27 — 2026-10-12
### Highlights
- **Built-In Worktree Tools**: Ask OpenCode to use `worktree_create`, `worktree_list`, and `worktree_remove` in Code Mode. You can create isolated worktrees, move sessions into them, and remove them with normal edit approval. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
- **Safer Remote Pairing**: The first `opencode pair --remote` now warns that it will expose your server and asks for confirmation. For non-interactive use, pass `opencode pair --remote --yes`. ([#54561](https://github.com/anomalyco/opencode/pull/54561))
- **Installable Web App**: Use Chrome or Edge’s **Install** button to install the OpenCode web app, including when it runs behind an authenticating proxy. ([#53905](https://github.com/anomalyco/opencode/pull/53905))
- **Clear Session Failures**: Desktop and TUI timelines now show execution errors that happen before or between model steps instead of ending without an explanation. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
- **Automatic Project Cleanup**: Projects whose directories no longer exist are automatically hidden from the project list. Opening the directory again restores the project. ([#54354](https://github.com/anomalyco/opencode/pull/54354))
### Core
#### Improvements
- Added the built-in Code Mode tools `opencode.worktree_create`, `opencode.worktree_list`, and `opencode.worktree_remove`. Removing a worktree requires edit approval, and `force: true` can discard modified or untracked files. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
- Added confirmation before `opencode pair --remote` first enables internet access, plus `--yes`/`-y` for scripts and other non-interactive use. ([#54561](https://github.com/anomalyco/opencode/pull/54561))
- Automatically archived projects when all known directories disappear. Reopening a project restores it, while surviving clones and linked worktrees keep it active. ([#54354](https://github.com/anomalyco/opencode/pull/54354))
- Raised the ChatGPT OAuth input limit for `gpt-6.1-sol` and `gpt-6.1-sol-fast` to 500,000 tokens. ([#54668](https://github.com/anomalyco/opencode/pull/54668))
- Added Vertex AI routes for Interactions and Mistral models. ([#54175](https://github.com/anomalyco/opencode/pull/54175), [#54058](https://github.com/anomalyco/opencode/pull/54058))
#### Bug fixes
- Fixed updater detection for symlinked home directories and package-manager installations, increased probe timeouts, and now report unavailable updates clearly when the installation method cannot be detected. ([#54609](https://github.com/anomalyco/opencode/pull/54609), [#54622](https://github.com/anomalyco/opencode/pull/54622), [#54672](https://github.com/anomalyco/opencode/pull/54672))
- Fixed GitHub Copilot routing for Gemini, Anthropic, and Responses models when live model metadata is unavailable, and accepted interleaved Claude reasoning values. ([#54208](https://github.com/anomalyco/opencode/pull/54208), [#54210](https://github.com/anomalyco/opencode/pull/54210), [#54201](https://github.com/anomalyco/opencode/pull/54201))
- Fixed Google Vertex Application Default Credentials when `CLOUDSDK_CONFIG` points to a custom gcloud configuration directory. Thanks [@H-Max](https://github.com/H-Max). ([#54164](https://github.com/anomalyco/opencode/pull/54164))
- Prevented ChatGPT token-sharing sessions from sending unsupported mid-session reasoning-effort updates. ([#54085](https://github.com/anomalyco/opencode/pull/54085))
- Blocked `file://` access through Code Mode’s `fetch`, which now returns a clear filesystem-access error. Thanks [@ferdinandyb](https://github.com/ferdinandyb). ([#54510](https://github.com/anomalyco/opencode/pull/54510))
- Fixed remote workspace file operations so driver-provided filesystem overrides are used correctly.
- Preserved subagent notifications when location services are evicted, and propagated well-known configuration updates across locations with bounded HTTP timeouts. ([#54072](https://github.com/anomalyco/opencode/pull/54072), [#53821](https://github.com/anomalyco/opencode/pull/53821))
### TUI
#### Bug fixes
- Displayed session execution failures that occur before or between model steps directly in the timeline. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
- Preserved a saved session’s unavailable model selection instead of silently replacing it with a fallback. ([#53822](https://github.com/anomalyco/opencode/pull/53822))
- Added syntax highlighting for C++ module interface files such as `.cppm`, `.ixx`, and `.cxxm`. ([#53853](https://github.com/anomalyco/opencode/pull/53853))
- Summarized integrations with multiple credentials as a connection count, preventing long labels from crowding the integration name. ([#54054](https://github.com/anomalyco/opencode/pull/54054))
### Desktop
#### Improvements
- Made the web app installable from Chrome and Edge, including deployments behind cookie-based authenticating proxies. ([#53905](https://github.com/anomalyco/opencode/pull/53905))
- Updated the session column layout and text wrapping for clearer long content. ([#54020](https://github.com/anomalyco/opencode/pull/54020))
#### Bug fixes
- Displayed session execution failures that occur before or between model steps directly in the timeline. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
- Fixed command-palette selection so newly arriving higher-ranked results become active until you explicitly choose a result. ([#54275](https://github.com/anomalyco/opencode/pull/54275))
- Preserved bold, code, emphasis, and strike-through formatting inside Markdown link labels. ([#54451](https://github.com/anomalyco/opencode/pull/54451))
- Fixed timeline file links and complete patch rendering, including unusual filenames and patches whose original alignment can be reused. ([#53641](https://github.com/anomalyco/opencode/pull/53641), [#54017](https://github.com/anomalyco/opencode/pull/54017))
- Showed complete tool error text when an error is expanded. ([#53816](https://github.com/anomalyco/opencode/pull/53816))
- Fixed browser-panel controls, page visibility, corner masks, and closed side-region overlays. ([#53907](https://github.com/anomalyco/opencode/pull/53907), [#54038](https://github.com/anomalyco/opencode/pull/54038), [#52461](https://github.com/anomalyco/opencode/pull/52461), [#54052](https://github.com/anomalyco/opencode/pull/54052))
- Made the third-party notices dialog scrollable. ([#53911](https://github.com/anomalyco/opencode/pull/53911))
### SDK
#### Improvements
- Added startup plugin seeding to Effect embedded hosts and workerd through `OpenCode.create({ plugins })` and `OpenCodeWorkerd.create({ plugins })`.
- Re-exported `WorkspaceDriver`, `EnvironmentDriver`, and `EnvironmentFiles` from `@opencode/sdk/effect`.
#### Bug fixes
- Ensured embedded hosts activate supplied plugins before model discovery, generation, and recovery begin. ([#54219](https://github.com/anomalyco/opencode/pull/54219))
### Extensions
#### Improvements
- Extended `BridgeLayout` with viewport dimensions and optional border information so native extension surfaces can track window resizing and preserve rounded card borders.
#### Bug fixes
- Kept desktop browser-panel corner masks and borders aligned while resizing or using custom themes. ([#52461](https://github.com/anomalyco/opencode/pull/52461))
- Kept the browser site button clickable after the embedded page takes focus. ([#53907](https://github.com/anomalyco/opencode/pull/53907))
## v2.0.26 — 2026-10-08
### Highlights
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# مديري الحزم
npm i -g opencode-ai@latest # او bun/pnpm/yarn
npm i -g @opencode/cli@latest # او bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">ওপেন সোর্স এআই কোডিং এজেন্ট।</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Package managers
npm i -g opencode-ai@latest # or bun/pnpm/yarn
npm i -g @opencode/cli@latest # or bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">O agente de programação com IA de código aberto.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Gerenciadores de pacotes
npm i -g opencode-ai@latest # ou bun/pnpm/yarn
npm i -g @opencode/cli@latest # ou bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">OpenCode je open source AI agent za programiranje.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Package manageri
npm i -g opencode-ai@latest # ili bun/pnpm/yarn
npm i -g @opencode/cli@latest # ili bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">Den open source AI-kodeagent.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Pakkehåndteringer
npm i -g opencode-ai@latest # eller bun/pnpm/yarn
npm i -g @opencode/cli@latest # eller bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">Der Open-Source KI-Coding-Agent.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Paketmanager
npm i -g opencode-ai@latest # oder bun/pnpm/yarn
npm i -g @opencode/cli@latest # oder bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">El agente de programación con IA de código abierto.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Gestores de paquetes
npm i -g opencode-ai@latest # o bun/pnpm/yarn
npm i -g @opencode/cli@latest # o bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">L'agent de codage IA open source.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Gestionnaires de paquets
npm i -g opencode-ai@latest # ou bun/pnpm/yarn
npm i -g @opencode/cli@latest # ou bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">Ο πράκτορας τεχνητής νοημοσύνης ανοικτού κώδικα για προγραμματισμό.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Διαχειριστές πακέτων
npm i -g opencode-ai@latest # ή bun/pnpm/yarn
npm i -g @opencode/cli@latest # ή bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS και Linux (προτείνεται, πάντα ενημερωμένο)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">L’agente di coding AI open source.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Package manager
npm i -g opencode-ai@latest # oppure bun/pnpm/yarn
npm i -g @opencode/cli@latest # oppure bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">オープンソースのAIコーディングエージェント。</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# パッケージマネージャー
npm i -g opencode-ai@latest # bun/pnpm/yarn でもOK
npm i -g @opencode/cli@latest # bun/pnpm/yarn でもOK
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">오픈 소스 AI 코딩 에이전트.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# 패키지 매니저
npm i -g opencode-ai@latest # bun/pnpm/yarn 도 가능
npm i -g @opencode/cli@latest # bun/pnpm/yarn 도 가능
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">The open source AI coding agent.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Package managers
npm i -g opencode-ai@latest # or bun/pnpm/yarn
npm i -g @opencode/cli@latest # or bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">AI-kodeagent med åpen kildekode.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Pakkehåndterere
npm i -g opencode-ai@latest # eller bun/pnpm/yarn
npm i -g @opencode/cli@latest # eller bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">Otwartoźródłowy agent kodujący AI.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Menedżery pakietów
npm i -g opencode-ai@latest # albo bun/pnpm/yarn
npm i -g @opencode/cli@latest # albo bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">Открытый AI-агент для программирования.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Менеджеры пакетов
npm i -g opencode-ai@latest # или bun/pnpm/yarn
npm i -g @opencode/cli@latest # или bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">เอเจนต์การเขียนโค้ดด้วย AI แบบโอเพนซอร์ส</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="สถานะการสร้าง" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# ตัวจัดการแพ็กเกจ
npm i -g opencode-ai@latest # หรือ bun/pnpm/yarn
npm i -g @opencode/cli@latest # หรือ bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">Açık kaynaklı yapay zeka kodlama asistanı.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Paket yöneticileri
npm i -g opencode-ai@latest # veya bun/pnpm/yarn
npm i -g @opencode/cli@latest # veya bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">AI-агент для програмування з відкритим кодом.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Менеджери пакетів
npm i -g opencode-ai@latest # або bun/pnpm/yarn
npm i -g @opencode/cli@latest # або bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">Trợ lý lập trình AI mã nguồn mở.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# Các trình quản lý gói (Package managers)
npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn
npm i -g @opencode/cli@latest # hoặc bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">开源的 AI Coding Agent。</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# 软件包管理器
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
npm i -g @opencode/cli@latest # 也可使用 bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
+2 -2
View File
@@ -10,7 +10,7 @@
<p align="center">開源的 AI Coding Agent。</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
@@ -50,7 +50,7 @@
curl -fsSL https://opencode.ai/install | bash
# 套件管理員
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
npm i -g @opencode/cli@latest # 也可使用 bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
+424 -1575
View File
File diff suppressed because it is too large. Load diff
+16 -257
View File
@@ -2,225 +2,7 @@ import { domain } from "./stage"
import { EMAILOCTOPUS_API_KEY } from "./app"
import { SECRET } from "./secret"
////////////////
// DATABASE
////////////////
const cluster = planetscale.getDatabaseOutput({
name: "opencode",
organization: "anomalyco",
})
const branch =
$app.stage === "production"
? planetscale.getBranchOutput({
name: "production",
organization: cluster.organization,
database: cluster.name,
})
: new planetscale.Branch("DatabaseBranch", {
database: cluster.name,
organization: cluster.organization,
name: $app.stage,
parentBranch: "production",
})
const password = new planetscale.Password("DatabasePassword", {
name: $app.stage,
database: cluster.name,
organization: cluster.organization,
branch: branch.name,
})
export const database = new sst.Linkable("Database", {
properties: {
host: password.accessHostUrl,
database: cluster.name,
username: password.username,
password: password.plaintext,
port: 3306,
},
})
new sst.x.DevCommand("Studio", {
link: [database],
dev: {
command: "bun db studio",
directory: "packages/console/core",
autostart: true,
},
})
////////////////
// AUTH
////////////////
const GITHUB_CLIENT_ID_CONSOLE = new sst.Secret("GITHUB_CLIENT_ID_CONSOLE")
const GITHUB_CLIENT_SECRET_CONSOLE = new sst.Secret("GITHUB_CLIENT_SECRET_CONSOLE")
const GOOGLE_CLIENT_ID = new sst.Secret("GOOGLE_CLIENT_ID")
const authStorage = new sst.cloudflare.Kv("AuthStorage")
export const auth = new sst.cloudflare.Worker("AuthApi", {
domain: `auth.${domain}`,
handler: "packages/console/function/src/auth.ts",
url: true,
link: [database, authStorage, GITHUB_CLIENT_ID_CONSOLE, GITHUB_CLIENT_SECRET_CONSOLE, GOOGLE_CLIENT_ID],
})
////////////////
// GATEWAY
////////////////
export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", {
url: $interpolate`https://${domain}/stripe/webhook`,
enabledEvents: [
"checkout.session.async_payment_failed",
"checkout.session.async_payment_succeeded",
"checkout.session.completed",
"checkout.session.expired",
"charge.refunded",
"invoice.payment_succeeded",
"invoice.payment_failed",
"invoice.payment_action_required",
"customer.created",
"customer.deleted",
"customer.updated",
"customer.discount.created",
"customer.discount.deleted",
"customer.discount.updated",
"customer.source.created",
"customer.source.deleted",
"customer.source.expiring",
"customer.source.updated",
"customer.subscription.created",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.pending_update_applied",
"customer.subscription.pending_update_expired",
"customer.subscription.resumed",
"customer.subscription.trial_will_end",
"customer.subscription.updated",
],
})
const zenLiteProduct = new stripe.Product("ZenLite", {
name: "OpenCode Go",
})
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
name: "First month 50% off",
percentOff: 50,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
name: "First month 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLiteCouponThreeMonths100 = new stripe.Coupon("ZenLiteCoupon3Months100", {
name: "3 months 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "repeating",
durationInMonths: 3,
})
const zenLiteCouponSixMonths100 = new stripe.Coupon("ZenLiteCoupon6Months100", {
name: "6 months 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "repeating",
durationInMonths: 6,
})
const zenLiteCouponTwelveMonths100 = new stripe.Coupon("ZenLiteCoupon12Months100", {
name: "12 months 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "repeating",
durationInMonths: 12,
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,
currency: "usd",
recurring: {
interval: "month",
intervalCount: 1,
},
unitAmount: 1000,
})
const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
// Use existing Go resources in dev's checkout Stripe account.
product: $app.stage === "dev" ? "prod_U1tUscpmwtV2bG" : zenLiteProduct.id,
price: $app.stage === "dev" ? "price_1T3phhE7fOCwHSD4zS6w2NPy" : zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
threeMonths100Coupon: zenLiteCouponThreeMonths100.id,
sixMonths100Coupon: zenLiteCouponSixMonths100.id,
twelveMonths100Coupon: zenLiteCouponTwelveMonths100.id,
},
})
const zenBlackProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black",
})
const zenBlackPriceProps = {
product: zenBlackProduct.id,
currency: "usd",
recurring: {
interval: "month",
intervalCount: 1,
},
}
const zenBlackPrice200 = new stripe.Price("ZenBlackPrice", { ...zenBlackPriceProps, unitAmount: 20000 })
const zenBlackPrice100 = new stripe.Price("ZenBlack100Price", { ...zenBlackPriceProps, unitAmount: 10000 })
const zenBlackPrice20 = new stripe.Price("ZenBlack20Price", { ...zenBlackPriceProps, unitAmount: 2000 })
const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
properties: {
product: zenBlackProduct.id,
plan200: zenBlackPrice200.id,
plan100: zenBlackPrice100.id,
plan20: zenBlackPrice20.id,
},
})
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
new sst.Secret("ZEN_MODELS2"),
new sst.Secret("ZEN_MODELS3"),
new sst.Secret("ZEN_MODELS4"),
new sst.Secret("ZEN_MODELS5"),
new sst.Secret("ZEN_MODELS6"),
new sst.Secret("ZEN_MODELS7"),
new sst.Secret("ZEN_MODELS8"),
new sst.Secret("ZEN_MODELS9"),
new sst.Secret("ZEN_MODELS10"),
new sst.Secret("ZEN_MODELS11"),
new sst.Secret("ZEN_MODELS12"),
new sst.Secret("ZEN_MODELS13"),
new sst.Secret("ZEN_MODELS14"),
new sst.Secret("ZEN_MODELS15"),
new sst.Secret("ZEN_MODELS16"),
new sst.Secret("ZEN_MODELS17"),
new sst.Secret("ZEN_MODELS18"),
new sst.Secret("ZEN_MODELS19"),
new sst.Secret("ZEN_MODELS20"),
new sst.Secret("ZEN_MODELS21"),
new sst.Secret("ZEN_MODELS22"),
new sst.Secret("ZEN_MODELS23"),
new sst.Secret("ZEN_MODELS24"),
new sst.Secret("ZEN_MODELS25"),
new sst.Secret("ZEN_MODELS26"),
new sst.Secret("ZEN_MODELS27"),
new sst.Secret("ZEN_MODELS28"),
new sst.Secret("ZEN_MODELS29"),
new sst.Secret("ZEN_MODELS30"),
]
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
})
// Preview branches have independent databases; do not send their workspaces to shared dev.
// Preview stages have no new Console or inference deployment to forward to.
const migrationDomain =
$app.stage === "production" ? "opencode.ai" : $app.stage === "dev" ? "dev.opencode.ai" : undefined
const consoleMigration = new sst.Linkable("ConsoleMigration", {
@@ -229,16 +11,14 @@ const consoleMigration = new sst.Linkable("ConsoleMigration", {
inferenceUrl: migrationDomain ? `https://${migrationDomain}/inference` : "",
},
})
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret },
})
////////////////
// CONSOLE
////////////////
const bucket = new sst.cloudflare.Bucket("ZenData")
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
// Archived Zen request/response dumps (writer removed May 2026). Retained on purpose; nothing reads or writes them.
new sst.cloudflare.Bucket("ZenData")
new sst.cloudflare.Bucket("ZenDataNew")
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
@@ -248,6 +28,10 @@ const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAI
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const CONSOLE_CRM_TOKEN = new sst.Secret("CONSOLE_CRM_TOKEN")
const CONSOLE_CRM_LOOKUP_URL = new sst.Linkable("CONSOLE_CRM_LOOKUP_URL", {
properties: { value: `https://${domain}/console/api/internal/console-account` },
})
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
@@ -258,18 +42,9 @@ new sst.cloudflare.x.SolidStart("Console", {
domain,
path: "packages/console/app",
link: [
bucket,
bucketNew,
database,
SECRET.UpstashRedisRestUrl,
SECRET.UpstashRedisRestToken,
AUTH_API_URL,
consoleMigration,
STRIPE_WEBHOOK_SECRET,
SECRET.SupportApiKey,
DISCORD_INCIDENT_WEBHOOK_URL,
SECRET.HoneycombWebhookSecret,
STRIPE_SECRET_KEY,
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
@@ -277,24 +52,12 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
new sst.Secret("CLOUDFLARE_ACCESS_CLIENT_ID"),
...ZEN_MODELS,
...($dev
? [
new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!),
new sst.Secret("CLOUDFLARE_API_TOKEN", process.env.CLOUDFLARE_API_TOKEN!),
]
: []),
CONSOLE_CRM_TOKEN,
CONSOLE_CRM_LOOKUP_URL,
],
environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!),
//VITE_API_URL: gateway.url.apply((url) => url!),
VITE_AUTH_URL: auth.url.apply((url) => url!),
VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value,
VITE_CONSOLE_MARKETING_ENABLED: $app.stage === "dev" ? "true" : "false",
},
transform: {
@@ -306,19 +69,15 @@ new sst.cloudflare.x.SolidStart("Console", {
...(flags ?? []),
"global_fetch_strictly_public",
])
// Same-zone fetch to inference re-enters as a public request and loses the client IP.
if (migrationDomain)
args.bindings = $resolve(args.bindings).apply((bindings) => [
...(bindings ?? []),
{ type: "service", name: "INFERENCE", service: `opencode-inference-${$app.stage}` },
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
},
},
},
},
})
////////////////
// HELPERS
////////////////
export const stat = new sst.cloudflare.Worker("Stat", {
handler: "packages/console/function/src/stat.ts",
link: [database],
url: true,
})
-2
View File
@@ -10,6 +10,4 @@ export const SECRET = {
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
}
+29 -50
View File
@@ -100,9 +100,17 @@ else
fi
fi
# Git Bash and MSYS2 run as emulated x64 on Windows ARM64, so `uname -m` reports x86_64.
# The host CPU still shows in the `uname -s` suffix and in PROCESSOR_IDENTIFIER.
if [ "$os" = "windows" ] && [ "$arch" = "x64" ]; then
if [[ "$raw_os" == *-ARM64 || "${PROCESSOR_IDENTIFIER:-}" == ARM* ]]; then
arch="arm64"
fi
fi
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64|windows-arm64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
@@ -235,17 +243,6 @@ check_version() {
fi
}
unbuffered_sed() {
if echo | sed -u -e "" >/dev/null 2>&1; then
sed -nu "$@"
elif echo | sed -l -e "" >/dev/null 2>&1; then
sed -nl "$@"
else
local pad="$(printf "\n%512s" "")"
sed -ne "s/$/\\${pad}/" "$@"
fi
}
print_progress() {
local bytes="$1"
local length="$2"
@@ -275,52 +272,34 @@ download_with_progress() {
exec 4>/dev/null
fi
local tmp_dir=${TMPDIR:-/tmp}
local basename="${tmp_dir}/opencode_install_$$"
local tracefile="${basename}.trace"
rm -f "$tracefile"
mkfifo "$tracefile"
local headers="${output}.headers"
# Hide cursor
printf "\033[?25l" >&4
trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
trap "trap - RETURN; rm -f \"$headers\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
(
curl --trace-ascii "$tracefile" -fsL -o "$output" "$url"
) &
# Poll the file size on a timer so rendering cost stays fixed no matter how many bytes arrive.
# Read the size with ls because busybox wc -c reads the whole file.
curl -fsL -D "$headers" -o "$output" "$url" &
local curl_pid=$!
unbuffered_sed \
-e 'y/ACDEGHLNORTV/acdeghlnortv/' \
-e '/^0000: content-length:/p' \
-e '/^<= recv data/p' \
"$tracefile" | \
{
local length=0
local bytes=0
local length bytes
while kill -0 "$curl_pid" 2>/dev/null; do
if [ -f "$output" ]; then
length=$(grep -i '^content-length:' "$headers" 2>/dev/null | tail -n 1 | tr -dc '0-9' || true)
bytes=$(ls -ln "$output" | awk '{print $5}')
print_progress "$bytes" "${length:-0}"
fi
sleep 0.1 2>/dev/null || sleep 1
done
while IFS=" " read -r -a line; do
[ "${#line[@]}" -lt 2 ] && continue
local tag="${line[0]} ${line[1]}"
if [ "$tag" = "0000: content-length:" ]; then
length="${line[2]}"
length=$(echo "$length" | tr -d '\r')
bytes=0
elif [ "$tag" = "<= recv" ]; then
local size="${line[3]}"
bytes=$(( bytes + size ))
if [ "$length" -gt 0 ]; then
print_progress "$bytes" "$length"
fi
fi
done
}
wait $curl_pid
local ret=$?
local ret=0
wait "$curl_pid" || ret=$?
if [ "$ret" -eq 0 ]; then
bytes=$(ls -ln "$output" | awk '{print $5}')
print_progress "$bytes" "$bytes"
fi
echo "" >&4
return $ret
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-xUEdCdS+BbEWb2TsUdr088KLYLGqIOBdknGObo3xc+o=",
"aarch64-linux": "sha256-f/MB/0TkxBmW1Q+w2qJe39jiOu5UUHX36VwRBYQWmjs=",
"aarch64-darwin": "sha256-h9An+psN04P9QRfFpkvZByJPdoRrOSJUQIzYu+WJGd0="
"x86_64-linux": "sha256-wmSDJ8NZOJyfSaBD8hnAAtbYrp3qnMS4eJzgFdyrhFc=",
"aarch64-linux": "sha256-6WE4BHW11/gAQ75ktPbWU2Aw7uqivueMrElDAqu7QdE=",
"aarch64-darwin": "sha256-6aAic4o1Zx6QiMk8IFU6lV7typClc3yXoWH7Qiq+hFs="
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.26",
"version": "2.0.29",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
@@ -38,6 +38,7 @@
"workspaces": {
"packages": [
"packages/*",
"packages/plugins/*",
"services/*",
"packages/console/*",
"packages/stats/*"
@@ -68,7 +69,6 @@
"@tsconfig/node22": "22.0.2",
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.5.1",
"opentui-spinner": "0.0.7",
"@solid-primitives/event-listener": "2.4.6",
+12 -316
View File
@@ -1,324 +1,20 @@
# AI Package Guide
## Effect
## Principles
- Prefer `HttpClient.HttpClient` / `HttpClientResponse.HttpClientResponse` over web `fetch` / `Response` at package boundaries.
- Use `Stream.Stream` for streaming data flow. Avoid ad hoc async generators or manual web reader loops unless an Effect `Stream` API cannot model the behavior.
- Use Effect Schema codecs for JSON encode/decode (`Schema.fromJsonString(...)`) instead of direct `JSON.parse` / `JSON.stringify` in implementation code.
- In `Effect.gen`, yield yieldable errors directly (`return yield* new MyError(...)`) instead of `Effect.fail(new MyError(...))`.
- Use `Effect.void` instead of `Effect.succeed(undefined)` when the successful value is intentionally void.
- **Forward compatibility.** Providers ship new models, options, and values constantly. Accept unknown pass-through values (`Known | (string & {})` for string options); use closed literals only when OpenCode branches on the value.
- **One canonical model.** The Schema classes in `src/schema/` are the data model. Do not add a parallel shape or a second construction path.
- **Independent of Session.** Auth, permissions, plugins, and orchestration belong in Core. `LLM.stream`/`LLM.generate` run exactly one model call.
- **Reuse protocols.** Providers compose existing protocols with `Route.make(...)` instead of cloning them.
## Conventions
## Code style
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream`, and the same for `Video`, `Speech`, and `Transcription`. Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.requireInlineMedia` / `inlineRequired` / `mediaUrl` / `mediaReference` and `MediaInput.inlineBytes` / `refID` rather than re-deriving base64 or URL handling.
`schema/messages.ts → media.ts → route/executor-service.ts` is an accepted runtime dependency from the schema layer on the executor service tag: `Media.Asset.bytes()` must be able to download `url` sources, and the tag lives in that leaf module precisely so the schema barrel never imports the executor implementation (which imports the schema barrel back). Do not move the tag into `route/executor.ts` or import `route/executor.ts` from `src/schema/*` or `src/media.ts`.
Nothing in `src/*` except `src/promise.ts` may know about Promises. `@opencode/ai/promise` (`AI.make({ layer? })`, default `ai`) is the single Promise/`AsyncIterable` surface for LLM and media; it runs the Effect APIs in one `ManagedRuntime` and rethrows `AIError` unchanged.
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
- Use `HttpClient`, `Stream`, and Schema codecs instead of `fetch`, async generators, and `JSON.parse`/`JSON.stringify`.
- `return yield* new MyError(...)` instead of `Effect.fail(...)`; `Effect.void` instead of `Effect.succeed(undefined)`.
- Only `src/promise.ts` may use Promises.
## Tests
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
- Keep provider tests fixture-first. Live provider calls must stay behind `RECORD=true` and required API-key checks.
## Errors
- `AIError` wraps a union of tagged reason errors. It stores only `reason`, derives `message` from the reason, and exposes the reason as its `cause`.
- Each reason owns its readable `message`, category-specific fields, and optional `body`, `http`, and underlying exception in `cause`.
- `reason.body` is the sole original-response or triggering-event payload field. Preserve original text before schema decoding removes fields; do not replace the complete event with only its nested error.
- `reason.http` describes an observed HTTP response with required `url`, `status`, and response `headers`. Do not invent status codes or derive a separate request ID from headers.
- Reclassification and transport recovery must preserve the reason's body, HTTP context, and underlying cause. Error `message` and `cause` are non-enumerable: copy them explicitly when constructing an enriched reason with its constructor or `AIErrorReason.make`.
## Architecture
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
Session integration lives in `packages/core/src/session`: `runner/llm.ts` owns orchestration, `model-request.ts` lowers Session state into `LLMRequest`, and `model-transport.ts` selects transport behavior.
Keep this package independent of Session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in Core.
### Request Flow
The intended callsite is:
```ts
const request = LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-4o-mini"),
system: "You are concise.",
prompt: "Say hello.",
})
const response = yield * LLMClient.generate(request) // inside Effect.gen
```
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`.
Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code.
### Routes
A route is the runnable composition of four orthogonal pieces:
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenResponses.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
Compose them via `Route.make(...)`:
```ts
export const route = Route.make({
id: "openai-chat",
provider: "openai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", {
baseURL: "https://api.openai.com/v1",
}),
auth: Auth.bearer(Auth.config("OPENAI_API_KEY")),
framing: Framing.sse,
})
```
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider owns a small `Route.make(...)` composition instead of a protocol clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
### Media Routes
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.inline(...)` / `queued(...)` / `stream(...)` (`src/route/media.ts`) compose a `MediaProtocol` kind with `Endpoint` and `Auth` and own the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; each protocol declares `const route = MediaProtocol.identity({ id, name, provider })` once and decodes through `route.decodeJson` / `route.text` / `route.decodeStarted` so decode failures retain the raw body and HTTP context, raising `route.unsupported(operation, message)` for requests it cannot lower, and passes `route` as the first argument to `MediaProtocol.inline` / `queued` / `stream`. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through the protocol's `unsupported` list.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`; a `failed` generation maps the provider's error code through a per-protocol `FAILURE` table via `MediaProtocol.failure` so rejected inputs are not reported as retryable `ProviderInternal`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram and ElevenLabs are inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
### URL Construction
`Endpoint` owns `{ baseURL, path, query }`. Each protocol route includes a canonical endpoint when the provider has one (e.g. `https://api.openai.com/v1`); provider helpers override endpoint fields by configuring the route before selecting a model. Generic OpenAI-compatible routes have no canonical URL and require configuration before execution.
For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper configures the route endpoint before calling `.model(...)`. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`).
### Provider Facades
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, `.speech(id)`, `.transcription(id)`, `google.video(id)`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
```ts
const openai = OpenAI.configure({ apiKey, baseURL })
const model = openai.responses("gpt-4o-mini")
const image = openai.image("gpt-image-2")
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
const deployment = azure.responses("my-deployment")
const gateway = CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey })
const proxied = gateway.model("openai/gpt-4o-mini")
```
Keep provider facades small and explicit:
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses` and `chat`.
- Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path.
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
- Give every named provider its own file and top-level export. Keep its endpoint, auth defaults, and route setup in that file. Compose shared protocols directly; do not nest named provider presets under generic compatible facades or keep their endpoints in a shared provider profile registry.
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are one flat serializable object: the connection keys the entrypoint declares (`apiKey`, `baseURL`, `region`, …), the common `headers` and `body` overlays, and the protocol's request options (`reasoningEffort`, `thinking`, …) side by side. Each entrypoint destructures its own connection keys and passes the rest to the route as `providerOptions`; there is no nested `providerOptions` at the entrypoint.
```ts
import { model } from "@opencode/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
reasoningEffort: "high",
})
```
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Transport is execution policy: OpenAI Responses uses HTTP by default and may receive a per-call WebSocket channel executor through `StreamOptions` without changing model or route identity.
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
### Shared protocol helpers
`ProviderShared` exports a small toolkit used inside protocol implementations to keep them focused on provider-native shapes:
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
### Chronological System Updates
`LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only.
Native chronological system messages are route/model-specific. Open Responses lowers them to standard `developer` messages, while Anthropic Messages lowers them to native system messages for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
```text
<system-update>
...
</system-update>
```
The wrapped-user fallback preserves ordering while visibly lowering authority. Never silently pass a raw chronological `role: "system"` through a route that might reject it. Do not insert raw retrieved documents, tool output, or web content into privileged chronological system updates; keep untrusted content in ordinary user/tool channels.
### Effort Updates
`Message.effort({ effort, previous })` is a chronological "reasoning effort changed here" marker (`undefined` means the model default). Changing a top-level effort invalidates the whole provider prompt cache, so protocols with a native per-message update (`Protocol.supportsEffortUpdates`) keep the top-level effort at the first marker's `previous` and lower each marker in place: Anthropic Messages emits an empty `role: "system"` message with `output_config.effort` plus the `mid-conversation-output-config-2026-07-01` beta, and OpenAI Responses emits `configuration_update` items. `applyEffortUpdates` runs in `prepareRequest` and strips the markers for every other route, so a protocol without support keeps today's plain top-level behaviour. When the last marker disagrees with the effort the request asks for (reverted or forked history), `resolveEffortUpdates` strips the markers and falls back to a plain top-level change.
### Tools
Tool loops are represented in common messages and events:
```ts
const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })
const result = Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } })
const followUp = LLM.request({
model,
messages: [Message.user("Weather?"), Message.assistant([call]), result],
})
```
Routes lower these into provider-native assistant tool-call messages and tool-result messages. Streaming providers should emit `tool-input-delta` events while arguments arrive, then a final `tool-call` event with parsed input.
### Tool dispatch
`LLM.stream(request)` and `LLM.generate(request)` each run exactly one model call. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`.
```ts
const get_weather = Tool.make({
description: "Get current weather for a city",
parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
execute: (input) =>
Effect.gen(function* () {
const data = yield* WeatherApi.fetch(input.city)
return { temperature: data.temp, condition: data.cond }
}),
})
const tools = { get_weather, get_time, ... }
const events = yield* LLM.stream(
LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }),
).pipe(Stream.runCollect)
const call = Array.from(events).find(LLMEvent.is.toolCall)
if (call && !call.providerExecuted) {
const dispatched = yield* ToolRuntime.dispatch(tools, call)
// Persist call + dispatched.result, then construct the next request explicitly.
}
```
The dispatcher:
- On `tool-call`: looks up the named tool, decodes input against `parameters` Schema, dispatches to the typed `execute`, encodes the result against `success` Schema, and returns canonical `tool-result` events.
- Does not stream providers, construct Session events, schedule fibers, append history, count steps, or continue model rounds.
- Leaves persistence and continuation to the enclosing product flow.
Handler dependencies (services, permissions, plugin hooks, abort handling) are closed over by the consumer at tool-construction time. Build the tools record inside an `Effect.gen` once and reuse it across many dispatches.
Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `tool-error` event, then a `tool-result` of `type: "error"`, so the model can self-correct on the next step. Anything that is not a `ToolFailure` is treated as a defect and fails the stream. Three recoverable error paths produce `tool-error` events:
- The model called an unknown tool name.
- 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` / `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.
- Callers that continue should retain both events in explicit history when the protocol requires it. Anthropic encodes them back as `server_tool_use` + `web_search_tool_result` (or `code_execution_tool_result` / `web_fetch_tool_result`) blocks; OpenAI Responses callers typically use `previous_response_id` instead of resending hosted-tool items.
Add provider-defined tools to `request.tools` (no runtime entry needed). The matching route must know how to lower the tool definition into the provider-native shape; right now Anthropic accepts `web_search` / `code_execution` / `web_fetch` and OpenAI Responses accepts the hosted tool names listed above.
## Protocol File Style
Protocol files should look self-similar. Provider quirks belong behind named helpers so a new route can be reviewed by comparing the same sections across files.
### Section order
Use this order for every protocol module:
1. Public model input
2. Request body schema
3. Streaming event schema
4. Parser state
5. Request body construction (`fromRequest`)
6. Stream parsing (`step` and per-event handlers)
7. Protocol and route
8. Protocol route export
### Rules
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
- Send `tool.inputSchema` as given. `prepareRequest` applies the tool schema rules (`ToolSchemaProjection.tools`) once per request, including tools in namespaces. A protocol whose API needs a model family's rules for every model declares `sanitizer` instead of transforming schemas itself.
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
- Prefer dispatched per-event handlers (`onMessageStart`, `onContentBlockDelta`, ...) called from a small top-level `step` switch over a long if-chain. The dispatcher keeps the event surface visible at a glance.
- Keep tests in the same conceptual order as the protocol: basic prepare, tools prepare, unsupported lowering, text/usage parsing, tool streaming, finish reasons, provider errors.
### Review checklist
- Can the file be skimmed side-by-side with `openai-chat.ts` without hunting for equivalent sections?
- Are provider quirks named, isolated, and covered by focused tests?
- Does request body construction validate unsupported common content at the protocol boundary?
- Does stream parsing emit stable common events without leaking provider event order to callers?
- Does `toolChoice: "none"` behavior read as intentional?
## Recording Tests
Recorded tests use one cassette file per scenario. A cassette holds an ordered array of `{ request, response }` interactions, so multi-step flows (tool loops, retries, polling) record into a single file. Use `recordedTests({ prefix, requires })` and let the helper derive cassette names from test names:
```ts
const recorded = recordedTests({ prefix: "openai-chat", requires: ["OPENAI_API_KEY"] })
recorded.effect("streams text", () =>
Effect.gen(function* () {
// test body
}),
)
```
Replay is the default. `RECORD=true` records fresh cassettes locally and requires the listed env vars; unset `CI` before recording because CI always forces replay. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable.
Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file:
- `RECORDED_PROVIDER=openai` matches tests tagged with `provider:openai`; comma-separated values are allowed.
- `RECORDED_PREFIX=openai-chat` matches cassette groups by `recordedTests({ prefix })`; comma-separated values are allowed.
- `RECORDED_TAGS=tool` requires all listed tags to be present, e.g. `RECORDED_TAGS=provider:togetherai,tool`.
- `RECORDED_TEST="streams text"` matches by test name, kebab-case test id, or cassette path.
Filters apply in replay and record mode. Combine them with `RECORD=true` when refreshing only one provider or scenario.
**Binary response bodies.** Most providers stream text (SSE, JSON). The recorder treats known textual media types (`text/*`, JSON/XML structured types, JavaScript, forms, YAML, and SVG) as text and stores every other response as base64 with `bodyEncoding: "base64"`. This preserves binary formats such as AWS event-stream frames without a lossy UTF-8 round trip.
**Matching strategy.** A runtime request atomically claims the first unused recorded interaction that matches its method, URL, allow-listed headers, and canonical JSON body. Distinct requests may replay in any order or concurrently. Repeated identical requests consume their matching responses in cassette order, preserving deterministic retry and polling behavior. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed.
- Use `testEffect(...)` from `test/lib/effect.ts` for tests that need Effect layers.
- Provider tests replay cassettes: recorded HTTP exchanges stored as JSON in `test/fixtures/recordings/`, one file per scenario. Replay is the default and needs no API keys.
- To record, unset `CI` and run with `RECORD=true` plus the provider's API key. Re-record only the cassette you are changing; recordings contain volatile IDs that create noisy diffs.
+1 -1
View File
@@ -182,7 +182,7 @@ console.log(response.answers.department.choice)
console.log(response.answers.refund.probability)
```
`TypeSafeAI` reads `TYPESAFE_API_KEY`. `OpenCodeZen` exposes the same selector and reads
`TypeSafeAI` reads `TYPESAFE_API_KEY`. `OpenCode` exposes the same selector and reads
`OPENCODE_API_KEY`. OpenRouter and Vercel AI Gateway use the same provider shape:
```ts
-484
View File
@@ -1,484 +0,0 @@
# Media generation in `@opencode/ai` — public API direction
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
## Goal
`@opencode/ai` becomes the one package you reach for to generate anything: text, images, video, speech, transcripts, and later music and realtime. The LLM surface already exists and is shaped by three constraints: Effect-first, used by OpenCode Core, usable externally. Media has a different priority order: **external DX first**, Effect and Promise as peers, Core as one consumer among many.
The design below is derived from a survey of the raw provider APIs (OpenAI, Gemini/Veo/Imagen, xAI, Stability, BFL, fal, Replicate, Runway, Luma, Kling, MiniMax, ElevenLabs, Deepgram, Cartesia, AssemblyAI, Lyria) and of existing multi-provider SDKs.
## What the survey forces
1. **Three execution shapes, everywhere.** Inline sync (OpenAI images, all TTS, Gemini), async job with polling or webhook (every video provider, BFL, fal, Replicate, AssemblyAI), and bidirectional streams (ElevenLabs/Cartesia/Deepgram WS, realtime). Video has no sync provider at all.
2. **Output is never just bytes.** base64, signed URLs with TTLs from 10 minutes (BFL, so its route downloads before returning via `PollContext.materialize`) to 2 days (Veo), URLs that need auth plus redirect (Veo), separate download endpoints (Sora `/content?variant=`), raw bodies (Stability, TTS). Multi-output is the norm.
3. **Inputs have roles.** First/last frame, mask, style/subject reference, source video for edit/extend, reference audio, prior generation id, provider-side file handles (`file_id`, `gs://`, `runway://`, `mm_file://`).
4. **Partial streaming is modality-specific.** Images: a few whole partial frames. Audio: ordered chunks plus timestamp events. Jobs: status/progress/logs. Video: none.
5. **Usage is a union**: tokens, seconds, characters (often only in headers), credits, compute time.
6. **Moderation can be partial success** (Veo strips audio but returns video). Deprecations are constant (Sora API shuts down 2026-09-24; Imagen is shut down on the Gemini API and past its 2026-06-30 discontinuation date on Vertex).
## Where existing SDKs are weak and we should not be
- No streaming TTS.
- Video handles are experimental start/status pairs; the polling loop lives inside the generate call.
- Unsupported inputs become silent warnings arrays, so a request can succeed while dropping your mask.
- `n` is fanned out into hidden parallel calls, which obscures cost and idempotency.
- Each modality has its own bespoke result type; the file abstraction is a lazy base64/bytes pair with no URL, expiry, or provider ref.
- Effect's own `unstable/ai` has no media generation. Nothing in the Effect ecosystem owns this.
## Design principles
- **Same shape as LLM.** `X.request(...)` → Schema class; `X.generate(request)` / `X.stream(request)`; `XClient.Service` + `layer`; typed `AIError`. If you know `LLM`, you know `Video`.
- **Execution shape is route policy, not API shape.** `Image.generate` returns an image whether the provider is inline or queued. Job control is available uniformly when you want it.
- **Errors, not warnings.** Unsupported common fields fail at the protocol boundary with a typed `AIError`, as the LLM routes do today. Provider-side partial results (filtered audio, moderated sample) surface as `notices` on the response, never as silent drops.
- **One asset type in, one asset type out**, shared with LLM messages and tool results.
- **Typed per-model options**, no hidden fan-out, no implicit retries that spend money.
- **Promise API is one mechanism for the whole package**, not a media-only wrapper.
- **One construction path per model.** Media models come from per-modality selectors on the configured facade (`openai.image("gpt-image-2")`), the same shape as `openai.responses("gpt-5")`.
## Public API
### Model selection
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `.image(id)`, `.video(id)`, `.speech(id)`, `.transcription(id)` on the facades that offer each — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
```ts
import { OpenAI, Google } from "@opencode/ai/providers"
const openai = OpenAI.configure({ apiKey }) // OpenAI(...) alone uses env auth (OPENAI_API_KEY)
LLM.request({ model: openai.responses("gpt-5"), prompt })
Image.request({ model: openai.image("gpt-image-2"), prompt })
Video.request({ model: google.video("veo-3.1-generate-preview"), prompt })
Speech.request({ model: openai.speech("gpt-4o-mini-tts"), text })
Transcription.request({ model: openai.transcription("gpt-4o-transcribe"), audio })
```
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). The facade selector (`openai.image(id)`) is the public path for media models. Modality-specific package entrypoints (`model(modelID, settings)` beside today's LLM paths such as `@opencode/ai/providers/openai/responses`) are deferred until Core has a modality-aware model resolver; Core's resolver accepts only `LanguageModel` today.
### `Media` — the asset type
Replaces `MediaPart.data: string | Uint8Array`, `ImageInput`, `GeneratedImage`, and aligns `Tool.FileContent`.
```ts
import { Media } from "@opencode/ai"
Media.Source =
| { type: "bytes"; data: Uint8Array; mediaType: string }
| { type: "base64"; data: string; mediaType: string }
| { type: "url"; url: string; mediaType?: string; expiresAt?: number }
| { type: "ref"; provider: ProviderID; id: string; mediaType?: string } // file_id, gs://, runway://, prior generation
class Media.Asset {
readonly source: Media.Source
readonly mediaType: string // always resolved (sniffed when the provider omits it)
readonly kind: "image" | "video" | "audio" | "document" | "other"
readonly info?: { width?; height?; durationSeconds?; sampleRate?; channels?; encoding?; format? }
readonly expiresAt?: number
readonly providerMetadata?: ProviderMetadata
readonly headers?: Record<string, string> // transient download credentials (Veo); never in source/JSON
bytes(): Effect<Uint8Array, AIError, RequestExecutor.Service> // downloads/decodes lazily, cached
base64(): Effect<string, AIError, RequestExecutor.Service>
dataUrl(): Effect<string, AIError, RequestExecutor.Service>
materialize(): Effect<Media.Asset, AIError, RequestExecutor.Service> // url/ref → bytes, before the URL dies
}
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
Media.url(url, options?) Media.ref(provider, id)
Media.file(path) // Effect<Asset, AIError, FileSystem>: reads + sniffs
Media.write(asset, path) // Effect<void, AIError, FileSystem | RequestExecutor.Service>
```
`Media.file` and `Media.write` stay Effect-only: bring your platform's `FileSystem` layer. The Promise client owns the
runtime path: `ai.file(path)` and `ai.write(asset, path)` read and write through `node:fs/promises` (loaded on first
use) with the same media-type sniffing and `InvalidRequest` failures, and `ai.bytes`, `ai.base64`, and
`ai.materialize` run the asset methods in its runtime.
A `ref` source is accepted as input only by routes whose provider issues file handles. No shipped route produces one
yet, so `bytes()` and `materialize()` on a ref fail by design until a producer exists.
Raw-PCM outputs (Gemini TTS, Cartesia raw, Deepgram WS) carry `info.encoding/sampleRate/channels` because there is no container header.
### Modality namespaces
Each namespace mirrors `LLM` exactly.
```ts
import { Image, Video, Speech, Transcription } from "@opencode/ai"
import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
```
#### Image
```ts
Effect.gen(function* () {
const request = Image.request({
model: openai.image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
images: [yield* Media.file("./ref.png")], // references / edit sources
mask: yield* Media.file("./mask.png"),
n: 2,
size: "1536x1024", // OpenAI sizes by pixels; Gemini/xAI take aspectRatio instead
format: "webp",
providerOptions: { quality: "high", background: "transparent" }, // typed per model
})
const response = yield* Image.generate(request) // ImageResponse
response.image // Media.Asset (first)
response.images // Media.Asset[]
response.usage // Usage union (see below)
response.notices // moderation / partial-result notices
Image.stream(request) // Stream<ImageEvent>
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
})
```
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the portability table
in the README's Image generation section.
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`, like every modality route. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
#### Video
Shipped in phase 2 (`src/video.ts`, `src/video-client.ts`, protocols `google-video`, `xai-video`, `fal-video`, `runway-video`).
```ts
Effect.gen(function* () {
const request = Video.request({
model: google.video("veo-3.1-generate-preview"),
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
frames: { first: yield* Media.file("./start.png"), last: yield* Media.file("./end.png") },
references: [yield* Media.file("./style.png")],
video: Media.bytes(previous, "video/mp4"), // edit / extend source
durationSeconds: 8,
aspectRatio: "16:9",
resolution: "1080p",
audio: true,
n: 1,
seed: 7,
negativePrompt: "text, watermark", // common, not provider-native
providerOptions: { personGeneration: "allow_adult" },
})
// Simple: wait for it.
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
response.video // Media.Asset: url (expiresAt on Veo and Runway; transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none (xAI's usage.cost_in_usd_ticks is not decoded)
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
yield* response.video.materialize() // pull bytes before the URL expires
// Explicit generation control.
const generation = yield* Video.start(request) // Generation<VideoResponse>
generation.id; generation.status; generation.progress; generation.position; generation.token
yield* generation.await({ poll }) // VideoResponse
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; Veo and xAI succeed without a request
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
const resumed = yield* Video.resume(model, JSON.parse(saved))
// Progress as a stream.
Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
})
```
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
`{ requestID, statusURL, responseURL, cancelURL }` (fal's follow-up URLs are authoritative and absolute). Common-field
lowering per provider: Veo takes inline media only, rejects `audio: false` and `n > 1`, and requires `frames.first`
when `frames.last` is set; xAI rejects `n`, `seed`, and `negativePrompt` and routes a `video` input to edits or
(`providerOptions.mode: "extend"`) extensions; fal rejects `n`, plus `durationSeconds`, `references`, and `frames.last`
because the field names and enums differ per model; Runway passes `aspectRatio` through as its pixel `ratio` and
rejects `n`.
Deferred: `Video.complete(model, token, webhook)` (finish from a webhook payload without polling) and provider poll
hints (none of the four providers emit one). Later providers: Luma, Kling, MiniMax, Replicate.
#### Speech (TTS)
Shipped in phase 3 (`src/speech.ts`, `src/speech-client.ts`, protocols `openai-speech`, `google-speech`,
`elevenlabs-speech`, `cartesia-speech`, `deepgram-speech`; new `ElevenLabs`, `Cartesia`, and `Deepgram` facades).
```ts
const request = Speech.request({
model: elevenlabs.speech("eleven_flash_v2_5"),
text: "Hello from OpenCode.",
voice: "JBFqnCBsd6RMkjVDRZzb", // provider-native identifier, or { id }
format: "mp3", // mp3 | wav | pcm | opus | aac | flac | (string & {})
speed: 1.0,
language: "en",
instructions: "Warm, unhurried.", // only OpenAI; elsewhere fails typed
timestamps: true, // request alignment; routes without it fail typed
providerOptions: { voice_settings: { stability: 0.5 } },
})
const response = yield* Speech.generate(request) // SpeechResponse: audio: Media.Asset, timestamps?, usage?, providerMetadata?
yield* Speech.stream(request) // Stream<SpeechEvent>: generation-queued | generation-progress | audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
```
Execution is `MediaProtocol.stream` for every provider: one request whose body is framed and folded by a `step`
state machine, with `generate` running the same stream and collecting it. The route submits the request with its
`mode` (`"generate" | "stream"`), which lets one provider stay one protocol — OpenAI adds `stream_format: "sse"` (except `tts-1`/`tts-1-hd`, which stream raw bytes), ElevenLabs appends
`/stream`, Cartesia switches `/tts/bytes` to `/tts/sse`, Gemini switches `generateContent` to
`streamGenerateContent`. The terminal `finish` event carries the assembled asset (every provider's stream is
concatenable chunks), so stream consumers also get the whole file and `generate` is just "take `finish`, gather
`timestamps`". The cost is memory: a stream holds every chunk until `finish`, so even a consumer that only plays deltas
keeps the whole clip in memory. That is bounded by the providers' input text limits (a few minutes of audio); a
long-form or session API would need an opt-out.
**Voice.** `voice?: string | { id: string }`. A string is passed through as the provider's native identifier — a
name on OpenAI and Gemini, a voice id on ElevenLabs (path segment) and Cartesia. `{ id }` selects an OpenAI custom
voice and is treated as the plain string on routes that do not distinguish custom from built-in. Deepgram's voice is
the model id (`aura-2-thalia-en`), so `voice` is `unsupported` there. There is no cross-provider voice catalog or
name→id resolution. Multi-speaker (Gemini `speechConfig.multiSpeakerVoiceConfig`) and per-voice settings
(ElevenLabs `voice_settings`) go through `providerOptions`.
**Format and PCM.** `format` is container-level; provider sample rates and bitrates live under `providerOptions`
(ElevenLabs `outputFormat`, Cartesia `sampleRate`/`bitRate`/`encoding`, Deepgram `encoding`/`container`/`sampleRate`/
`bitRate`). Each protocol maps `format` to its native value (ElevenLabs `mp3_44100_128`/`pcm_24000`/`wav_24000`/
`opus_48000_64`, Cartesia `{ container, encoding, sample_rate }`, Deepgram `encoding`+`container`) and declares the
asset's media type rather than sniffing, because headerless PCM can look like an MPEG frame sync. Headerless PCM
always carries `info.encoding`, `info.sampleRate`, and `info.channels`; its media type is the provider's declaration
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini's asset follows the
provider's declared type: WAV for Gemini 3.8 TTS `generate`, headerless PCM otherwise. The route never wraps PCM as WAV,
so `pcm` is the only explicit `format` it accepts, and not on Gemini 3.8 `generate`. Every `format` value a route cannot
produce (unknown to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but `pcm` on Gemini, `pcm` on
Gemini 3.8 `generate`) fails the same way as an unsupported field: `UnsupportedOperation` with
`operation: "media.format"`.
**Timestamps.** `timestamps: true` on the request asks for alignment. ElevenLabs selects the `with-timestamps`
endpoints (character-level, NDJSON when streaming); Cartesia sets `add_timestamps` on `/tts/sse` (word-level; a
`generate` with timestamps collects the SSE stream). OpenAI, Gemini, and Deepgram reject it.
Common-field lowering per provider:
| Provider | `voice` | `speed` | `language` | `instructions` | `timestamps` | Usage |
|---|---|---|---|---|---|---|
| OpenAI | `voice` (name or `{ id }`) | `speed` | unsupported | `instructions` | unsupported | `tokens` from SSE `speech.audio.done` only |
| Gemini | `prebuiltVoiceConfig.voiceName` | unsupported | `speechConfig.languageCode` | unsupported (direct in text) | unsupported | `tokens` from `usageMetadata` |
| ElevenLabs | path voice id (required) | `voice_settings.speed` | `language_code` | unsupported | `with-timestamps` | `credits` from `character-cost` header |
| Cartesia | `voice` (required) | `generation_config.speed` | `language` | unsupported | `add_timestamps` | none |
| Deepgram | unsupported (voice is the model) | `speed` query | unsupported | unsupported | unsupported | `characters` from `dg-char-count` header |
Deferred: `Speech.session(...)` — input-streaming TTS where text arrives incrementally over a WebSocket (ElevenLabs
`stream-input`, Cartesia WebSocket contexts, Deepgram WebSocket speak) — is a separate scoped resource, not part of
`generate`/`stream`, and ships with the realtime work in phase 5.
#### Transcription (STT)
Shipped as the second half of phase 3 (`src/transcription.ts`, `src/transcription-client.ts`, protocols
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `elevenlabs-transcription`,
`assemblyai-transcription`; new `AssemblyAI` facade).
```ts
const request = Transcription.request({
model: openai.transcription("gpt-4o-transcribe-diarize"),
audio: yield* Media.file("./call.wav"),
language: "en", // provider-native passthrough
timestamps: "segment", // none | segment | word
diarize: true,
speakers: 2, // speaker count (AssemblyAI exact, ElevenLabs maximum)
providerOptions: { known_speaker_names: ["agent"] },
})
const response = yield* Transcription.generate(request)
response.text; response.segments; response.words; response.language; response.durationSeconds; response.usage
yield* Transcription.stream(request) // Stream<TranscriptionEvent>: generation-queued | generation-progress | text-delta | segment | finish
const generation = yield* Transcription.start(request) // queued routes only
yield* Transcription.resume(model, token)
```
Transcription is the first modality whose providers span all three protocol kinds, and it needed no fourth kind.
Every `MediaRoute` now carries its `kind`; `TranscriptionRoute` is the union of the inline, stream, and queued routes;
`TranscriptionModel.fromRoute` is overloaded per protocol kind (arity picks the overload: `<Options>`,
`<Options, Frame, State>`, `<Options, Token>`) and composes through the shared `composeRoute` (`src/media-model.ts`),
which picks `MediaRoute.inline` / `stream` / `queued`; and `TranscriptionClient`, like every modality client, is
`MediaClient.make` (`src/media-client.ts`), which dispatches on `route.kind`. `generate` on a queued route is `start`
then `await`; `stream` on an inline route is the response as a single `finish`, and on a queued route it is the status
observations followed by `finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
`finish` event carries the whole transcript (text, segments, words, language, duration, usage), so the stream route's
`collect` is just "take `finish`".
The route layer gained a `binary` body with array-valued `query` (Deepgram) and `Queued.start.prepare` (AssemblyAI's
upload); `packages/ai/AGENTS.md` (Media Routes) describes both.
Settled rules:
- **Timestamps.** A granularity the selected route or model cannot produce fails as `UnsupportedOperation`
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram, ElevenLabs, and AssemblyAI
always return words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
word offsets, so segment timestamps and diarization also request word offsets there.
- **Diarization.** `diarize` means segments (and words, where the provider labels them) carry `speaker`. Labels are
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A`,
ElevenLabs `speaker_0` — with no cross-provider speaker model. `speakers` is the number of speakers to label:
AssemblyAI (`speakers_expected`) treats it as an exact constraint rather than a hint, and ElevenLabs
(`num_speakers`) as the maximum. Both turn on diarization for it; the other routes reject it.
- **Segments from words.** ElevenLabs returns only a token list (`word`, `spacing`, `audio_event`), so its segments
are speaker turns: consecutive words and spacing with one `speaker_id`, text joined from the provider's own spacing
tokens. `words` drops spacing and audio events. Segments therefore need diarization, which `timestamps: "segment"`
turns on, as AssemblyAI's utterances need speaker labels.
- **Language** is passed through (`language`, OpenAI `gpt-transcribe` `languages[]`, Gemini `languageCodes`,
AssemblyAI and ElevenLabs `language_code`). `response.language` is the provider's own value, lowercased but not
normalized: an ISO code on most routes (AssemblyAI's detection returns `en`, ElevenLabs ISO 639-3 `eng`), `english`
from whisper-1. Deepgram and AssemblyAI assume English unless asked to detect, so a missing `language` enables their
detection.
- **Gemini** requires a transcribe model; other model ids fail with `UnsupportedOperation` before the call, because
general models ignore `audioTranscriptionConfig` and answer conversationally. Streamed chunks carry whole speaker
turns (one part per turn), which join with a space.
- **Streaming inline providers** emit only `finish`; deltas are never faked.
- **Units.** AssemblyAI milliseconds and Gemini protobuf durations (`"0.400s"`) are normalized to seconds at the
protocol boundary.
| Provider | Kind | Audio input | `timestamps` | `diarize` | Unsupported | Usage |
|---|---|---|---|---|---|---|
| OpenAI | stream (`stream: true` in `stream` mode; `whisper-1` ignores `stream`, so it emits only `finish`) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model | `tokens` or `seconds` |
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
| ElevenLabs | inline | multipart `file`, or `source_url` | words always; `segment` → `diarize` (speaker turns) | `diarize` | `prompt`; `webhook`, per-channel `use_multi_channel` | `seconds` (`audio_duration_secs`) |
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
Deferred: `Transcription.session(...)` — realtime STT over WebSocket (Deepgram live, AssemblyAI streaming, ElevenLabs
realtime, OpenAI realtime transcription) — is the same future scoped `session` shape as input-streaming TTS and ships
with the realtime work in phase 5.
### `Generation` — shared async execution
```ts
class Generation<Response> {
readonly id: string
readonly route: GenerationRoute<Response> // token-free: { status, result, cancel?: Effect } closed over the decoded token
readonly token: unknown // route-owned serializable JSON
readonly status: "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
readonly progress?: number // 0..1, normalized
readonly position?: number
refresh(): Effect<Generation<Response>, AIError>
result(): Effect<Response, AIError>
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
cancel(): Effect<void, AIError>
events(options?: GenerationAwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
}
GenerationAwaitOptions = { poll?: Poll }
Poll = { interval?: Duration; timeout?: Duration }
```
`Generation` is not video-specific. Image routes on BFL, fal, Replicate, and Stability `upscale()` are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
Status polls and result reads retry transient failures (rate limits, provider 5xx, and transport errors, classified by the same `isRetryable` the Session runner uses) inside `MediaRoute.queued`. Only the HTTP exchange retries, never the decoded document: a terminal `failed` generation also surfaces as `ProviderInternal` and must not be re-read. Gaps grow exponentially from 1s with jitter, up to 30s each, honoring a provider `retry-after` up to that cap, for at most 8 retries. `await`, `events`, and `Video.stream` cut retries off at `poll.timeout` and fail with `Timeout`, so retries never extend the caller's deadline; a direct `result()` or `resume` read is bounded by the retry cap alone. `start` and `cancel` never retry: a repeated submit can start and bill a second job. The policy is internal; there is no option for it.
Interrupting `await`, `events`, or `Video.stream` (or aborting the promise API's `signal`) stops waiting only. The provider job keeps running and billing; call `cancel()` explicitly to stop it.
### Usage
```ts
Usage =
| { type: "tokens"; input; output; total; details? }
| { type: "seconds"; seconds }
| { type: "characters"; characters }
| { type: "credits"; credits }
| { type: "compute"; seconds }
```
Header-only usage (ElevenLabs `character-cost`, Deepgram `dg-char-count`) is lifted into `usage` by the route.
### Promise API — `@opencode/ai/promise`
Mirrors the `packages/plugin/src/effect` and `packages/plugin/src/promise` split that already exists in this repo. One mechanism for LLM and media.
```ts
import { AI } from "@opencode/ai/promise"
const ai = AI.make() // ManagedRuntime over RequestExecutor.fetchLayer + all clients
// AI.make({ layer }) to inject a custom executor / recorder / middleware
const image = await ai.image.generate({ model, prompt })
await ai.bytes(image.image) // also ai.base64, ai.materialize, ai.write(asset, path)
const reference = await ai.file("./ref.png")
for await (const event of ai.speech.stream({ model, text, voice })) { … }
const generation = await ai.video.start({ model, prompt }) // snapshot handle; refresh() returns a new one
for await (const event of generation.events({ poll: { interval: 10_000 } })) { … }
const video = await generation.await({ poll: { interval: 10_000 }, signal })
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
const request = ai.llm.request({ model, prompt })
const text = await ai.llm.generate(request)
for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
```
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. Aborting an `AbortSignal` interrupts the work and, like `fetch`, rejects the Promise or throws from the stream with `signal.reason` instead of ending the stream as if complete. Nothing in `src/*` except this entrypoint knows about promises.
### Providers
Existing facades gain per-modality selectors; the modality routes each facade provides (*italics* are not
implemented):
| Facade | llm | image | video | speech | transcription | other |
|---|---|---|---|---|---|---|
| `OpenAI` | responses (default), chat | Images API (stream) | *Sora skipped (decision 8)* | ✓ | ✓ | |
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
| `XAI` | ✓ | ✓ | ✓ | | | |
| `ElevenLabs` | | | | ✓ | Scribe | *soundEffect, music (phase 5)* |
| `Cartesia` | | | | ✓ | | |
| `Deepgram` | | | | Aura | ✓ | |
| `Fal` | | ✓ (queued) | ✓ | | | |
| `AssemblyAI` | | | | | ✓ (queued) | |
| `BlackForestLabs` | | ✓ (queued) | | | | |
| `Replicate` | | ✓ (queued) | | | | |
| `Stability` | | `image` (inline), `upscale()` (queued) | | | | |
| `Runway` | | | ✓ | | | |
| `Luma`, `Kling`, `MiniMax` | | *deferred* | *deferred* | | | |
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. As with `LanguageModel`, the route type does not carry `Options`, so `ImageModel<OpenAIImageOptions>` is an `ImageModel` and client methods take plain `ImageRequestFor`. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
### Routes and protocols
Media does not fit the LLM four-axis route (SSE frames → event state machine) except for streaming TTS/STT. Reuse `Endpoint`, `Auth`, `Framing`, `RequestExecutor`, and add media protocol kinds:
- `MediaProtocol.inline` — `body.from(request)` (JSON, multipart, or query), `response.decode(response)` (JSON, or binary body → `Media.Asset`).
- `MediaProtocol.queued` — `start` (body + decode to `{ token, snapshot }`), `status`, `result`, optional `cancel`, and a `token` codec. `result` is always a separate GET (against the status document for Veo/xAI/Runway, fal's `response_url` otherwise) so `await` after `start` and after `resume` share one path. `PollContext.auth` hands the auth headers the route sent to the protocol for output URLs that need them (Veo downloads); they become transient `Media.Asset.headers`, never part of `source`. There is no separate `download` step: `Media.Asset.bytes()` downloads through the executor with those headers. `MediaRoute.inline(...)` / `MediaRoute.queued(...)` compose each kind with endpoint and auth; the queued route decodes the token once and hands `Generation` a token-free `{ status, result, cancel? }`.
- `MediaProtocol.stream` — `body.from(request)` over the request plus its `mode`, `frames` (a function that picks the framing for the call: `Framing.sse`, `lines`, `document`, or the raw bytes), fresh per-response `initial()` state, `step` emitting modality events, and `finish(state, context)` — with the observed response for header-only usage — emitting exactly one terminal event or failing as an incomplete stream. The route fills `reason.http` on stream errors. `MediaRoute.stream(...)` exposes `stream` and `generate` (the same stream folded by the modality's `collect`).
`MediaRoute.inline` / `MediaRoute.queued` / `MediaRoute.stream` compose one protocol kind with endpoint/auth and tag the route with its `kind`; `ImageModel`/`VideoModel`/`SpeechModel`/`TranscriptionModel` share the `MediaModel` base (`src/media-model.ts`).
### LLM integration
- `MediaPart` becomes `{ type: "media"; media: Media.Asset; … }` so protocols branch on `kind` and can pass `url`/`ref` sources through natively (OpenAI `image_url`, Gemini `fileData`).
- New `LLMEvent`s: `media { media: Media.Asset }` so Gemini inline image output is first-class instead of dropped. OpenAI Responses `image_generation_call` keeps its single carrier — the provider-executed `tool-result` with `file` content — because Core consumes hosted tool-result content today and has no `media` event handling yet; it switches to the `media` carrier when Core adopts the event, so the image is never emitted twice.
- `Message.assistant([...])` accepts media parts; Gemini multi-turn image editing replays them.
- `Tool.FileContent` aligns with `Media.Source`.
## Decisions
All settled:
1. **Per-modality selectors** (`openai.image(id)`, `.video`, `.speech`, `.transcription`) name media models, mirroring `openai.responses(id)`. The one-word overlap with the request namespace is accepted over a callable-facade `ModelRef` as a second construction path.
2. **`providerOptions` everywhere** (rename current `Image.options`) for consistency with LLM.
3. **No hidden `n` fan-out.** `n` lowers natively; routes that cannot do `n > 1` fail typed. Callers use `Effect.all` / `Promise.all` explicitly.
4. **Errors over warnings** for unsupported common fields; `notices` for provider-side partial results only.
5. **`Media.Asset` is a class** (lazy bytes, cached) with `Media.Source` as the serializable Schema for wire/persistence. `Asset.from(source)` / `asset.source` round-trip losslessly. Same pattern as `LanguageModel` today.
6. **Promise entrypoint**: `@opencode/ai/promise` exporting `AI.make(options?: { layer? })` plus a module-level default `ai` for scripts, covering LLM too.
7. **Modality set for v1**: `Image`, `Video`, `Speech`, `Transcription`. `Music`/`SoundEffect` and `session` (bidirectional WS, realtime) are designed-for but deferred.
8. **Sora is skipped** (API shuts down 2026-09-24). Video launches with Veo, xAI, fal, Runway.
## Build order
Foundation + Image ship together as the reference implementation, serially. Video, Speech, and Transcription then proceed in parallel on separate branches. Image jobs and partial streaming come last, after Video has hardened `Generation`.
## Phasing
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, ElevenLabs Scribe, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
4. **Image queued routes and partials** — ✅ BFL, fal, Replicate, and Stability creative upscale queued; Stability generate inline; OpenAI `partial_images` streaming (`image-partial` restored). Imagen dropped: shut down on the Gemini API and discontinued on Vertex (2026-06-30). Deferred: Stability's synchronous edit and fast/conservative upscale endpoints.
5. **Later** — ElevenLabs music/SFX, Lyria, `Speech.session` / `Transcription.session`, realtime.
Core adoption (session attachments beyond png/jpeg/gif/webp/pdf, image-generation tool, TUI rendering) comes after phase 1 and is a Core concern.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.26",
"version": "2.0.29",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+15 -11
View File
@@ -171,17 +171,7 @@ const countHints = (request: LLMRequest) =>
)
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
const route = request.model.route.id
if (!RESPECTS_INLINE_HINTS.has(route)) return request
const policy =
(route === "openrouter" || route === "vercel-ai-gateway-messages") &&
(request.cache === undefined || request.cache === "auto")
? gatewayPolicy(request.model.id)
: route === "alibaba-chat" && (request.cache === undefined || request.cache === "auto")
? request.model.id.toLowerCase().startsWith("qwen")
? QWEN
: NONE
: resolve(request.cache)
const policy = selectPolicy(request)
if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds)
@@ -193,3 +183,17 @@ export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (tools === request.tools && system === request.system && messages === request.messages) return request
return LLMRequest.update(request, { tools, system, messages })
}
// A declared cache format overrides the route allowlist and its model-ID heuristics.
const selectPolicy = (request: LLMRequest): CachePolicyObject => {
const format = request.model.compatibility?.cacheFormat
const route = request.model.route.id
const auto = request.cache === undefined || request.cache === "auto"
if (format === "alibaba") return auto ? QWEN : { ...resolve(request.cache), tools: false }
if (format === "anthropic") return resolve(request.cache)
if (!RESPECTS_INLINE_HINTS.has(route)) return NONE
if (!auto) return resolve(request.cache)
if (route === "openrouter" || route === "vercel-ai-gateway-messages") return gatewayPolicy(request.model.id)
if (route === "alibaba-chat") return request.model.id.toLowerCase().startsWith("qwen") ? QWEN : NONE
return AUTO
}
+161 -21
View File
@@ -1,10 +1,15 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import {
HttpTransport,
type Transport,
type TransportExecution,
type TransportPrepareInput,
} from "../route/transport/index.js"
import {
AIError,
LLMEvent,
@@ -25,6 +30,7 @@ import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
import { Lifecycle } from "./utils/lifecycle.js"
const ADAPTER = "gemini"
const CONTINUATION = "CONTINUATION"
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
// Gemini 2.5 rejects a budget under the model's minimum: 512 on Flash-Lite, the highest, and 128 on Pro.
@@ -212,6 +218,7 @@ const GeminiBodyFields = {
tools: optionalArray(GeminiTool),
toolConfig: Schema.optional(GeminiToolConfig),
generationConfig: Schema.optional(GeminiGenerationConfig),
continuationToken: Schema.optional(Schema.String),
}
const GeminiBody = Schema.Struct(GeminiBodyFields)
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
@@ -254,7 +261,9 @@ interface ParserState {
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly promptFeedback?: GeminiPromptFeedback
readonly usage?: Usage
readonly usage?: GeminiUsage
/** Usage of the earlier requests of a response Gemini continued across requests. */
readonly continuedUsage?: ReadonlyArray<GeminiUsage>
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
readonly textSignature?: string
@@ -497,7 +506,11 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
// to produce the inclusive `outputTokens` the rest of the contract expects.
const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
const mapUsage = (
usage: GeminiUsage | undefined,
metadataKey: string,
metadata: Record<string, unknown> = usage ?? {},
) => {
if (!usage) return undefined
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
// token arithmetic below treats them like absent counts.
@@ -518,10 +531,35 @@ const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
cacheReadInputTokens: cached,
reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
providerMetadata: providerMetadata(metadataKey, usage),
providerMetadata: providerMetadata(metadataKey, metadata),
})
}
// Each continued request re-bills the output so far as input. Reporting the first request's input with all output keeps
// input plus output equal to the response's final context size, under-reporting billed input; the summed billed counts
// remain in provider metadata.
const mapContinuedUsage = (requests: ReadonlyArray<GeminiUsage>, metadataKey: string) => {
const sum = (field: keyof GeminiUsage) =>
ProviderShared.sumTokens(...requests.map((usage) => usage[field] ?? undefined))
return mapUsage(
{
promptTokenCount: requests[0].promptTokenCount,
cachedContentTokenCount: requests[0].cachedContentTokenCount,
candidatesTokenCount: sum("candidatesTokenCount"),
thoughtsTokenCount: sum("thoughtsTokenCount"),
},
metadataKey,
{
promptTokenCount: sum("promptTokenCount"),
cachedContentTokenCount: sum("cachedContentTokenCount"),
candidatesTokenCount: sum("candidatesTokenCount"),
thoughtsTokenCount: sum("thoughtsTokenCount"),
totalTokenCount: sum("totalTokenCount"),
requests: requests.length,
},
)
}
const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
if (finishReason === undefined) return hasToolCalls ? "tool-calls" : "unknown"
if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
@@ -545,7 +583,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
const promptBlockReason =
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
const finishReason = state.finishReason ?? promptBlockReason
if (finishReason === undefined && state.usage === undefined) return []
if (finishReason === undefined && state.usage === undefined && state.continuedUsage === undefined) return []
const events: LLMEvent[] = []
let lifecycle = state.lifecycle
@@ -573,7 +611,10 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
promptBlockReason === undefined ? mapFinishReason(finishReason, state.hasToolCalls) : "content-filter",
raw: finishReason,
},
usage: state.usage,
usage:
state.continuedUsage === undefined
? mapUsage(state.usage, state.providerMetadataKey)
: mapContinuedUsage([...state.continuedUsage, state.usage ?? {}], state.providerMetadataKey),
providerMetadata:
state.promptFeedback === undefined
? undefined
@@ -582,7 +623,19 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
return events
}
const step = (state: ParserState, event: GeminiEvent) => {
const step = (state: ParserState, event: GeminiEvent | NextRequest) => {
// Usage is cumulative within one request, so the next request of the same response counts from zero.
if (event instanceof NextRequest)
return Effect.succeed([
{
...state,
finishReason: undefined,
promptFeedback: undefined,
usage: undefined,
continuedUsage: [...(state.continuedUsage ?? []), state.usage ?? {}],
},
[],
] as const)
if (ProviderShared.isRecord(event.error)) {
const body = ProviderShared.encodeJson(event)
return Effect.fail(
@@ -604,14 +657,13 @@ const step = (state: ParserState, event: GeminiEvent) => {
return Effect.fail(
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const candidate = event.candidates?.[0]
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
usage: event.usageMetadata
? (mapUsage(event.usageMetadata, state.providerMetadataKey) ?? state.usage)
: state.usage,
usage: event.usageMetadata ?? state.usage,
finishReason: candidate?.finishReason ?? state.finishReason,
}
const candidate = event.candidates?.[0]
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
return Effect.fail(
ProviderShared.eventError(
@@ -620,11 +672,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
ProviderShared.encodeJson(event),
),
)
if (!candidate?.content)
return Effect.succeed([
{ ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
[],
] as const)
if (!candidate?.content) return Effect.succeed([nextState, []] as const)
const events: LLMEvent[] = []
let hasToolCalls = nextState.hasToolCalls
@@ -784,12 +832,104 @@ const step = (state: ParserState, event: GeminiEvent) => {
nextReasoningId,
nextTextId,
seenCallIds,
finishReason: candidate.finishReason ?? nextState.finishReason,
},
events,
] as const)
}
// =============================================================================
// Transport
// =============================================================================
const GeminiCheckpoint = Schema.Struct({
candidates: optionalNull(
Schema.Array(
Schema.Struct({
finishReason: optionalNull(Schema.String),
continuationToken: optionalNull(Schema.String),
}),
),
),
})
const decodeCheckpoint = Schema.decodeUnknownOption(GeminiCheckpoint)
/** Marks where the transport starts another request for the same response; provider JSON can never match it. */
class NextRequest {}
export interface Prepared {
readonly http: HttpTransport.HttpPrepared<string>
readonly input: TransportPrepareInput<GeminiBody>
}
const sse = HttpTransport.sseJson.with<GeminiBody>()
/**
* SSE transport that continues responses Gemini splits across requests. Chunks carry `continuationToken` checkpoints,
* and a request that reaches its output cap finishes with `CONTINUATION`. The transport resends the original body with
* the latest token, which also resumes a request that ended or dropped before finishing.
*/
export const transport: Transport<GeminiBody, Prepared, unknown> = {
id: sse.id,
prepare: (input) => sse.prepare(input).pipe(Effect.map((http) => ({ http, input }))),
execute: (prepared, request, runtime) =>
Effect.gen(function* () {
const route = request.model.route.id
const label = `${request.model.provider}/${route}`
// Frames are parsed once here, so errors recover each frame's raw text for their body.
const raw = new WeakMap<object, string>()
const respond = (execution: TransportExecution<string>): Stream.Stream<unknown, AIError> =>
Stream.suspend(() => {
let token: string | undefined
let finishReason: string | undefined
let dropped: AIError | undefined
return execution.frames.pipe(
Stream.mapEffect((frame) =>
ProviderShared.parseJson(route, frame, `Invalid ${label} stream event`).pipe(
Effect.map((event) => {
if (typeof event === "object" && event !== null) raw.set(event, frame)
const candidate = Option.getOrUndefined(decodeCheckpoint(event))?.candidates?.[0]
token = candidate?.continuationToken ?? (candidate?.finishReason ? undefined : token)
finishReason = candidate?.finishReason ?? finishReason
return event
}),
),
),
Stream.catchIf(
// A connection failure may resume; a timeout keeps its meaning and ends the response.
(error) => error.reason._tag === "Transport" && error.reason.code !== "Timeout",
(error) => {
dropped = error
return Stream.empty
},
),
Stream.concat(
Stream.suspend(() => {
if (token !== undefined && (finishReason === undefined || finishReason === CONTINUATION))
return Stream.concat(Stream.succeed(new NextRequest()), next(token))
if (dropped) return Stream.fail(dropped)
if (finishReason === CONTINUATION)
return Stream.fail(ProviderShared.eventError(label, "Gemini continued the response without a token"))
return Stream.empty
}),
),
)
})
const next = (continuationToken: string) =>
Stream.unwrap(
sse
.prepare({ ...prepared.input, body: { ...prepared.input.body, continuationToken } })
.pipe(
Effect.flatMap((continued) => sse.execute(continued, request, runtime)),
Effect.map(respond),
),
)
const first = yield* sse.execute(prepared.http, request, runtime)
return {
frames: respond(first),
http: first.http,
body: (frame) => (typeof frame === "object" && frame !== null ? raw.get(frame) : undefined),
}
}),
}
// =============================================================================
// Protocol And Gemini Route
// =============================================================================
@@ -806,8 +946,8 @@ export const protocol = Protocol.make({
// Gemini's schema rules are this API's default, including for tuned endpoints whose IDs do not name Gemini.
sanitizer: "gemini",
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: (request) => ({
event: Schema.Union([Schema.instanceOf(NextRequest), GeminiEvent]),
initial: (request): ParserState => ({
route: `${request.model.provider}/${request.model.route.id}`,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
hasToolCalls: false,
@@ -830,7 +970,7 @@ export const route = Route.make({
baseURL: DEFAULT_BASE_URL,
}),
auth: Auth.none,
framing: Framing.sse,
transport,
})
export * as Gemini from "./gemini.js"
+3 -1
View File
@@ -417,7 +417,9 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
frequency_penalty: request.generation?.frequencyPenalty,
presence_penalty: request.generation?.presencePenalty,
stop: request.generation?.stop,
prompt_cache_key: request.cache === "none" ? undefined : (options.promptCacheKey ?? request.promptCacheKey),
prompt_cache_key: ProviderShared.sendsPromptCacheKey(request)
? (options.promptCacheKey ?? request.promptCacheKey)
: undefined,
safe_prompt: options.safePrompt,
document_image_limit: options.documentImageLimit,
document_page_limit: options.documentPageLimit,
@@ -169,7 +169,12 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
})
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
request: ProviderShared.jsonPost({
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
encoding: input.request.http?.encoding,
}),
framing: Framing.sse,
middleware: input.middleware,
},
+4 -2
View File
@@ -334,14 +334,14 @@ export const StreamItem = Schema.StructWithRest(
id: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
namespace: Schema.optional(Schema.String),
namespace: optionalNull(Schema.String),
arguments: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
export type OutputItem = StreamItem & { readonly id: string; readonly namespace?: string }
// Responses-compatible providers put error details at the top level, under `error`, or under
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
@@ -946,6 +946,8 @@ const resolveItem = (
index: number | undefined,
): OutputItem => ({
...item,
// Some gateways serialize an absent namespace as null.
namespace: item.namespace ?? undefined,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
+8 -2
View File
@@ -24,6 +24,7 @@ import {
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { cacheControl } from "./utils/cache.js"
import { OpenAIOptions } from "./utils/openai-options.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolStream } from "./utils/tool-stream.js"
@@ -827,15 +828,16 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = flattened.tools.length > 0
const lowering = { ...options, cacheControl: options.cacheControl ?? declaredCacheControl(request) }
return {
model: request.model.id,
messages: yield* lowerMessages(flattened.request, options),
messages: yield* lowerMessages(flattened.request, lowering),
tools:
flattened.tools.length === 0
? hasHistory
? []
: undefined
: flattened.tools.map((tool) => lowerTool(tool, options, supportsStrictMode)),
: flattened.tools.map((tool) => lowerTool(tool, lowering, supportsStrictMode)),
tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
@@ -853,6 +855,10 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
}
})
// Provider wrappers that pass their own marker writer keep it; otherwise a declared cache format adds one.
const declaredCacheControl = (request: LLMRequest) =>
request.model.compatibility?.cacheFormat === undefined ? undefined : cacheControl()
// =============================================================================
// Stream Parsing
// =============================================================================
+40 -5
View File
@@ -14,6 +14,7 @@ import {
type MediaPart,
type OpenString,
type ProviderID,
type RequestEncoding,
type TextPart,
type ToolEntry,
type ToolResultPart,
@@ -39,10 +40,15 @@ export const knownString = <Known extends string>() =>
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// Body-level `prompt_cache_key` is off when caching is disabled or the model
// explicitly rejects the field. Header-based affinity is unaffected.
export const sendsPromptCacheKey = (request: LLMRequest) =>
request.cache !== "none" && request.model.compatibility?.supportsPromptCacheKey !== false
// 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 promptCacheKey = (request: LLMRequest): string | undefined => {
if (request.cache === "none" || request.promptCacheKey === undefined) return undefined
if (!sendsPromptCacheKey(request) || request.promptCacheKey === undefined) return undefined
const chars = Array.from(request.promptCacheKey)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return request.promptCacheKey
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
@@ -373,11 +379,40 @@ export const validateWith =
* send JSON with a stale content type. The body is passed pre-encoded so
* routes can choose between
* `Schema.encodeSync(payload)` and `ProviderShared.encodeJson(payload)`.
* A `zstd` or `gzip` encoding compresses the body and sets `content-encoding`;
* it falls back to plain JSON when the runtime has no zlib.
*/
export const jsonPost = (input: { readonly url: string; readonly body: string; readonly headers?: Headers.Input }) =>
HttpClientRequest.post(input.url).pipe(
HttpClientRequest.setHeaders(Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")),
HttpClientRequest.bodyText(input.body, "application/json"),
export const jsonPost = (input: {
readonly url: string
readonly body: string
readonly headers?: Headers.Input
readonly encoding?: RequestEncoding
}) => {
const headers = Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")
const compressed = compressBody(input.body, input.encoding)
if (!compressed)
return HttpClientRequest.post(input.url).pipe(
HttpClientRequest.setHeaders(headers),
HttpClientRequest.bodyText(input.body, "application/json"),
)
return HttpClientRequest.post(input.url).pipe(
HttpClientRequest.setHeaders(Headers.set(headers, "content-encoding", compressed.encoding)),
HttpClientRequest.bodyUint8Array(compressed.body, "application/json"),
)
}
function compressBody(text: string, encoding: RequestEncoding | undefined) {
if (encoding === undefined || encoding === "none") return undefined
// Looked up at call time instead of imported: a runtime without node:zlib or its zstd export
// (browsers, older Node, some workerd setups) sends plain JSON rather than failing to load this module.
const zlib = globalThis.process?.getBuiltinModule?.("node:zlib")
try {
const body = encoding === "zstd" ? zlib?.zstdCompressSync?.(text) : zlib?.gzipSync(text)
return body && { encoding, body }
} catch {
// Compression is an optimization, so a failure sends plain JSON instead of failing the request.
return undefined
}
}
export * as ProviderShared from "./shared.js"
@@ -117,7 +117,12 @@ export const make = (
encodeBody: Schema.encodeSync(Schema.fromJsonString(Body)),
})
const response = yield* executor.execute(
ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
ProviderShared.jsonPost({
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
encoding: request.http?.encoding,
}),
options?.http,
)
const text = yield* RequestExecutor.responseStream(response).pipe(
+1 -2
View File
@@ -5,7 +5,6 @@ import { ProviderShared } from "../protocols/shared.js"
import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
@@ -69,7 +68,7 @@ const route = Route.make({
const serviceTier = request.providerOptions?.serviceTier
return typeof serviceTier === "string" ? { "x-vertex-ai-llm-shared-request-type": serviceTier } : {}
},
framing: Framing.sse,
transport: Gemini.transport,
})
export const routes = [route]
+1 -1
View File
@@ -34,7 +34,7 @@ export * as Moonshot from "./moonshot.js"
export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenCodeZen from "./opencode-zen.js"
export * as OpenCode from "./opencode.js"
export * as OpenRouter from "./openrouter.js"
export * as Replicate from "./replicate.js"
export * as Runway from "./runway.js"
@@ -3,7 +3,7 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { SystemOne } from "../experimental/system-one.js"
export const id = ProviderID.make("opencode")
const baseURL = "https://opencode.ai/zen/v1"
const baseURL = "https://opencode.ai/inference/v1"
export type Options = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
@@ -28,4 +28,4 @@ export const configure = (input: Options = {}) => {
export const provider = configure()
export const experimental = provider.experimental
export * as OpenCodeZen from "./opencode-zen.js"
export * as OpenCode from "./opencode.js"
+5 -3
View File
@@ -7,6 +7,7 @@ import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.j
import * as ProviderShared from "../../protocols/shared.js"
import {
AIError,
DEFAULT_CHUNK_TIMEOUT_MS,
DEFAULT_HTTP_TIMEOUT_MS,
mergeJsonRecords,
TransportError,
@@ -88,6 +89,7 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
encoding: prepareInput.request.http?.encoding,
})
return {
request,
@@ -123,7 +125,7 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
frames: prepared.framing.frame(
RequestExecutor.responseStream(response).pipe(
Stream.timeoutOrElse({
duration: timeoutDuration(request.http?.chunkTimeout),
duration: timeoutDuration(request.http?.chunkTimeout, DEFAULT_CHUNK_TIMEOUT_MS),
orElse: () => Stream.fail(timeout("read", "Timed out waiting for response data", http)),
}),
Stream.interruptWhen(
@@ -139,8 +141,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
}),
})
const timeoutDuration = (value: HttpTimeout | undefined) =>
value === false ? Duration.infinity : Duration.millis(value ?? DEFAULT_HTTP_TIMEOUT_MS)
const timeoutDuration = (value: HttpTimeout | undefined, fallback = DEFAULT_HTTP_TIMEOUT_MS) =>
value === false ? Duration.infinity : Duration.millis(value ?? fallback)
export const sseJson = {
id: "http-json/sse",
+32 -5
View File
@@ -52,9 +52,19 @@ export const mergeProviderOptions = (
export const HttpTimeout = Schema.Union([Schema.Number.check(Schema.isGreaterThan(0)), Schema.Literal(false)])
export type HttpTimeout = Schema.Schema.Type<typeof HttpTimeout>
/** Applied to `headerTimeout` and `chunkTimeout` when a request leaves them unset. */
/** Applied to `headerTimeout` when a request leaves it unset. */
export const DEFAULT_HTTP_TIMEOUT_MS = 300_000
/**
* Applied to `chunkTimeout` when a request leaves it unset. Some providers, such as DeepSeek, have specifically asked
* clients to allow at least 15 minutes, because their models can stream nothing for minutes while they reason.
*/
export const DEFAULT_CHUNK_TIMEOUT_MS = 900_000
/** `Content-Encoding` for JSON request bodies; `none` sends plain JSON. */
export const RequestEncoding = Schema.Literals(["zstd", "gzip", "none"])
export type RequestEncoding = Schema.Schema.Type<typeof RequestEncoding>
export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
body: Schema.optional(JsonSchema),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -65,6 +75,8 @@ export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
headerTimeout: Schema.optional(HttpTimeout),
/** Time allowed between streamed response chunks once headers have arrived. */
chunkTimeout: Schema.optional(HttpTimeout),
/** Compression for JSON request bodies. Only set it for endpoints known to decode it. */
encoding: Schema.optional(RequestEncoding),
}) {}
export namespace HttpOptions {
@@ -86,9 +98,18 @@ export const mergeHttpOptions = (...items: ReadonlyArray<HttpOptions | undefined
const timeout = items.findLast((item) => item?.timeout !== undefined)?.timeout
const headerTimeout = items.findLast((item) => item?.headerTimeout !== undefined)?.headerTimeout
const chunkTimeout = items.findLast((item) => item?.chunkTimeout !== undefined)?.chunkTimeout
if (!body && !headers && !query && timeout === undefined && headerTimeout === undefined && chunkTimeout === undefined)
const encoding = items.findLast((item) => item?.encoding !== undefined)?.encoding
if (
!body &&
!headers &&
!query &&
timeout === undefined &&
headerTimeout === undefined &&
chunkTimeout === undefined &&
encoding === undefined
)
return undefined
return new HttpOptions({ body, headers, query, timeout, headerTimeout, chunkTimeout })
return new HttpOptions({ body, headers, query, timeout, headerTimeout, chunkTimeout, encoding })
}
export class GenerationOptions extends Schema.Class<GenerationOptions>("LLM.GenerationOptions")({
@@ -186,6 +207,9 @@ export type LanguageModelMaxTokensFieldCompatibility = Schema.Schema.Type<
typeof LanguageModelMaxTokensFieldCompatibility
>
export const LanguageModelCacheFormatCompatibility = Schema.Literals(["anthropic", "alibaba"])
export type LanguageModelCacheFormatCompatibility = Schema.Schema.Type<typeof LanguageModelCacheFormatCompatibility>
export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompatibility>(
"LLM.LanguageModelCompatibility",
)({
@@ -199,9 +223,12 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
// Accepts `prompt_cache_key` in the Chat Completions body. Chat omits the
// key unless this is set; session-affinity headers still flow regardless.
// Accepts `prompt_cache_key` in the request body. Chat omits the key unless
// this is true; other protocols omit it only when this is false.
// Session-affinity headers still flow regardless.
supportsPromptCacheKey: Schema.optional(Schema.Boolean),
/** Prompt-cache marker syntax the endpoint accepts. Overrides the route default; both send `cache_control`, and `alibaba` skips tool definitions. */
cacheFormat: Schema.optional(LanguageModelCacheFormatCompatibility),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
+5 -5
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/http"
import { Evaluation, EvaluationClient } from "../src/experimental.js"
import { OpenCodeZen, OpenRouter, TypeSafeAI, VercelAIGateway } from "../src/providers.js"
import { OpenCode, OpenRouter, TypeSafeAI, VercelAIGateway } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse } from "./lib/http.js"
@@ -116,9 +116,9 @@ describe("experimental Evaluation", () => {
),
)
it.effect("configures the OpenCode Zen System One endpoint", () =>
it.effect("configures the OpenCode System One endpoint", () =>
Evaluation.run({
model: OpenCodeZen.configure({ apiKey: "zen-key", baseURL: "https://zen.test/v1" }).experimental.evaluation(
model: OpenCode.configure({ apiKey: "opencode-key", baseURL: "https://opencode.test/v1" }).experimental.evaluation(
"jev-1.13",
),
state: "hello",
@@ -136,8 +136,8 @@ describe("experimental Evaluation", () => {
EvaluationClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(input.request.url).toBe("https://zen.test/v1/systemone")
expect(input.request.headers.authorization).toBe("Bearer zen-key")
expect(input.request.url).toBe("https://opencode.test/v1/systemone")
expect(input.request.headers.authorization).toBe("Bearer opencode-key")
return Effect.succeed(
input.respond(
JSON.stringify({
+2 -2
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { Evaluation, EvaluationClient, EvaluationModel, type EvaluationRoute } from "../src/experimental.js"
import type { Service } from "../src/experimental/evaluation-client.js"
import { OpenCodeZen, OpenRouter, TypeSafeAI, VercelAIGateway } from "../src/providers.js"
import { OpenCode, OpenRouter, TypeSafeAI, VercelAIGateway } from "../src/providers.js"
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
type Success<T> = T extends Effect.Effect<infer A, infer _E, infer _R> ? A : never
@@ -34,7 +34,7 @@ void (true satisfies ClientRequirements)
Effect.gen(function* () {
const response = yield* Evaluation.run({
model: OpenCodeZen.experimental.evaluation("jev-1.13"),
model: OpenCode.experimental.evaluation("jev-1.13"),
state: ["hello"],
questions: { greeting: { type: "boolean", instructions: "Greeting?" } },
})
+2 -2
View File
@@ -34,7 +34,7 @@ import {
Fireworks,
Google,
Meta,
OpenCodeZen,
OpenCode,
OpenAI,
OpenAICompatible,
OpenRouter,
@@ -149,7 +149,7 @@ describe("public exports", () => {
expect(OpenRouter.model).toBeFunction()
expect(OpenRouter.experimental.evaluation).toBeFunction()
expect(TypeSafeAI.experimental.evaluation).toBeFunction()
expect(OpenCodeZen.experimental.evaluation).toBeFunction()
expect(OpenCode.experimental.evaluation).toBeFunction()
expect(VercelAIGateway.experimental.evaluation).toBeFunction()
expect(XAI.model).toBeFunction()
expect(XAI.provider.responses).toBe(XAI.responses)
@@ -3,11 +3,11 @@
"metadata": {
"model": "jev-1.13-free",
"tags": [
"prefix:opencode-zen-evaluation",
"prefix:opencode-evaluation",
"provider:opencode",
"protocol:system-one"
],
"name": "opencode-zen-evaluation/evaluates-choice-score-and-boolean-questions",
"name": "opencode-evaluation/evaluates-choice-score-and-boolean-questions",
"recordedAt": "2026-09-22T03:44:57.511Z"
},
"interactions": [
@@ -15,7 +15,7 @@
"transport": "http",
"request": {
"method": "POST",
"url": "https://opencode.ai/zen/v1/systemone",
"url": "https://opencode.ai/inference/v1/systemone",
"headers": {
"content-type": "application/json"
},
+2 -2
View File
@@ -69,7 +69,7 @@ describe("HTTP transport timeouts", () => {
}),
)
it.effect("fails when the response body stalls for five minutes", () =>
it.effect("fails when the response body stalls for fifteen minutes", () =>
Effect.gen(function* () {
const server = yield* stalledServer
const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
@@ -79,7 +79,7 @@ describe("HTTP transport timeouts", () => {
)
yield* Deferred.await(server.stalled)
yield* Effect.yieldNow
yield* TestClock.adjust("5 minutes")
yield* TestClock.adjust("15 minutes")
const error = yield* Fiber.join(fiber)
expect(error.reason).toMatchObject({ _tag: "Transport", transport: "http", operation: "read", code: "Timeout" })
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Evaluation, EvaluationModel, type EvaluationOptions } from "../../src/experimental.js"
import { OpenCodeZen, OpenRouter, TypeSafeAI } from "../../src/providers.js"
import { OpenCode, OpenRouter, TypeSafeAI } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
const questions = {
@@ -32,8 +32,8 @@ const typesafe = recordedTests({
metadata: { model: "jev-latest" },
})
const zen = recordedTests({
prefix: "opencode-zen-evaluation",
const opencode = recordedTests({
prefix: "opencode-evaluation",
provider: "opencode",
protocol: "system-one",
requires: ["OPENCODE_API_KEY"],
@@ -55,9 +55,9 @@ describe("experimental Evaluation recorded", () => {
),
)
zen.effect("evaluates choice score and boolean questions", () =>
opencode.effect("evaluates choice score and boolean questions", () =>
assertEvaluation(
OpenCodeZen.configure({ apiKey: process.env.OPENCODE_API_KEY ?? "fixture" }).experimental.evaluation(
OpenCode.configure({ apiKey: process.env.OPENCODE_API_KEY ?? "fixture" }).experimental.evaluation(
"jev-1.13-free",
),
),
+317 -3
View File
@@ -1,11 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage, Media } from "../../src/index.js"
import { Deferred, Effect, Fiber } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { LLM, AIError, LLMEvent, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage, Media } from "../../src/index.js"
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as Gemini from "../../src/protocols/gemini.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents, sseRaw } from "../lib/sse.js"
const model = Gemini.route
@@ -1832,3 +1833,316 @@ describe("Gemini route", () => {
}),
)
})
describe("Gemini response continuation", () => {
const continuedRequest = LLM.request({
model: Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-test" }),
prompt: "Count.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
// Serves one scripted SSE body per request, or a body whose connection drops after it, and records every sent body.
const scripted = (responses: ReadonlyArray<string | { readonly dropped: string } | ReadableStream<Uint8Array>>) => {
const bodies: Array<Record<string, unknown>> = []
const layer = dynamicResponse((input) =>
Effect.sync(() => {
bodies.push(JSON.parse(input.text))
const response = responses[bodies.length - 1]
if (typeof response === "string" || response instanceof ReadableStream)
return input.respond(response, { headers: { "content-type": "text/event-stream" } })
let sent = false
return input.respond(
new ReadableStream({
pull(controller) {
if (sent) return controller.error(new Error("connection reset"))
sent = true
controller.enqueue(new TextEncoder().encode(response.dropped))
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
)
return { bodies, layer }
}
const chunks = (...values: ReadonlyArray<unknown>) =>
values.map((value) => sseRaw(`data: ${JSON.stringify(value)}`)).join("")
const text = (value: string, extra: Record<string, unknown> = {}) => ({
candidates: [{ content: { role: "model", parts: [{ text: value }] }, ...extra }],
})
const continuation = (token?: string) => ({
candidates: [
{ content: { role: "model", parts: [{ text: "" }] }, finishReason: "CONTINUATION", continuationToken: token },
],
})
it.effect("stitches text split across three requests into one block", () =>
Effect.gen(function* () {
const server = scripted([
sseEvents(
{
candidates: [
{ content: { role: "model", parts: [{ text: "plan", thought: true, thoughtSignature: "sig_1" }] } },
],
},
text("THIRTEEN THOUSAND TWO"),
continuation("token-1"),
),
sseEvents(
text(" HUNDRED SEVEN"),
// The finishing chunk's token supersedes an earlier checkpoint's.
text("TY-TWO", { continuationToken: "checkpoint" }),
continuation("token-2"),
),
sseEvents(text(" DONE", { finishReason: "STOP" })),
])
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
expect(response.text).toBe("THIRTEEN THOUSAND TWO HUNDRED SEVENTY-TWO DONE")
expect(response.reasoning).toBe("plan")
expect(response.events.filter((event) => event.type !== "text-delta").map((event) => event.type)).toEqual([
"step-start",
"reasoning-start",
"reasoning-delta",
"reasoning-end",
"text-start",
"text-end",
"step-finish",
"finish",
])
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { google: { thoughtSignature: "sig_1" } },
})
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
expect(server.bodies).toHaveLength(3)
expect(server.bodies[0]).not.toHaveProperty("continuationToken")
expect(server.bodies[1]).toEqual({ ...server.bodies[0], continuationToken: "token-1" })
expect(server.bodies[2]).toEqual({ ...server.bodies[0], continuationToken: "token-2" })
}),
)
it.effect("finishes with a signed tool call from a later request", () =>
Effect.gen(function* () {
const server = scripted([
sseEvents(text("Looking"), continuation("token-1")),
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: " it up." },
{ functionCall: { id: "call_1", name: "lookup", args: {} }, thoughtSignature: "call_sig" },
],
},
finishReason: "STOP",
},
],
}),
])
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
expect(response.text).toBe("Looking it up.")
expect(response.events.filter((event) => event.type === "text-start")).toHaveLength(1)
expect(response.events.find((event) => event.type === "tool-call")).toMatchObject({
id: "call_1",
providerMetadata: { google: { thoughtSignature: "call_sig" } },
})
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "STOP" })
}),
)
it.effect("forgets a block once the resumed request finishes", () =>
Effect.gen(function* () {
const server = scripted([
sseEvents(text("THIRTEEN", { continuationToken: "checkpoint-1" }), { promptFeedback: { blockReason: "OTHER" } }),
sseEvents(text(" THOUSAND", { finishReason: "STOP" })),
])
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
expect(response.text).toBe("THIRTEEN THOUSAND")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
expect(response.events.filter(LLMEvent.is.finish).map((event) => event.providerMetadata)).toEqual([undefined])
}),
)
it.effect("resumes a dropped stream from its latest checkpoint", () =>
Effect.gen(function* () {
const server = scripted([
{ dropped: chunks(text("THIRTEEN"), text(" THOUSAND", { continuationToken: "checkpoint-1" })) },
sseEvents(text(" TWO", { finishReason: "STOP" })),
])
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
expect(response.text).toBe("THIRTEEN THOUSAND TWO")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
expect(server.bodies[1]).toEqual({ ...server.bodies[0], continuationToken: "checkpoint-1" })
}),
)
it.effect("counts the usage of a dropped request once resumed", () =>
Effect.gen(function* () {
const server = scripted([
{
dropped: chunks(
{ ...text("THIRTEEN"), usageMetadata: { promptTokenCount: 120, candidatesTokenCount: 5 } },
{
...text(" THOUSAND", { continuationToken: "checkpoint-1" }),
usageMetadata: { promptTokenCount: 142, candidatesTokenCount: 32768, totalTokenCount: 32910 },
},
),
},
sseEvents({
...text(" TWO", { finishReason: "STOP" }),
usageMetadata: { promptTokenCount: 32910, candidatesTokenCount: 100, totalTokenCount: 33010 },
}),
])
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
expect(response.usage).toEqual(
new Usage({
inputTokens: 142,
outputTokens: 32868,
nonCachedInputTokens: 142,
totalTokens: 33010,
providerMetadata: {
google: {
promptTokenCount: 33052,
candidatesTokenCount: 32868,
totalTokenCount: 65920,
requests: 2,
},
},
}),
)
}),
)
it.effect("continues a stream that ends at a checkpoint without finishing", () =>
Effect.gen(function* () {
const server = scripted([
sseEvents(text("THIRTEEN", { continuationToken: "checkpoint-1" })),
sseEvents(text(" THOUSAND", { finishReason: "STOP" })),
])
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
expect(response.text).toBe("THIRTEEN THOUSAND")
expect(server.bodies[1]).toEqual({ ...server.bodies[0], continuationToken: "checkpoint-1" })
}),
)
it.effect("fails a stream dropped before any checkpoint", () =>
Effect.gen(function* () {
const server = scripted([{ dropped: chunks(text("Partial")) }])
const error = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer), Effect.flip)
expect(error.reason._tag).toBe("Transport")
expect(server.bodies).toHaveLength(1)
}),
)
it.effect("does not resume a response cut off by its own timeout", () =>
Effect.gen(function* () {
const stalled = yield* Deferred.make<void>()
let sent = false
const server = scripted([
new ReadableStream({
pull(controller) {
if (sent) return new Promise<void>(() => Deferred.doneUnsafe(stalled, Effect.void))
sent = true
controller.enqueue(new TextEncoder().encode(chunks(text("THIRTEEN", { continuationToken: "checkpoint-1" }))))
},
}),
])
const fiber = yield* LLMClient.generate(
LLMRequest.update(continuedRequest, { http: { timeout: 1_000 } }),
).pipe(Effect.provide(server.layer), Effect.flip, Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(stalled)
yield* TestClock.adjust("1 second")
const error = yield* Fiber.join(fiber)
expect(error.reason).toMatchObject({ _tag: "Transport", code: "Timeout" })
expect(server.bodies).toHaveLength(1)
}),
)
it.effect("fails a continuation without a token", () =>
Effect.gen(function* () {
const server = scripted([sseEvents(text("Partial"), continuation())])
const error = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer), Effect.flip)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput", http: { status: 200 } },
message: "Gemini continued the response without a token",
})
expect(server.bodies).toHaveLength(1)
}),
)
it.effect("does not continue a length finish", () =>
Effect.gen(function* () {
const server = scripted([
sseEvents(text("Partial", { finishReason: "MAX_TOKENS", continuationToken: "token-1" })),
])
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
expect(response.finishReason).toEqual({ normalized: "length", raw: "MAX_TOKENS" })
expect(server.bodies).toHaveLength(1)
}),
)
it.effect("reports the first request's input with all output", () =>
Effect.gen(function* () {
const usage = [
{ promptTokenCount: 142, candidatesTokenCount: 32768, totalTokenCount: 32910 },
{ promptTokenCount: 32910, candidatesTokenCount: 32768, totalTokenCount: 65678 },
{
promptTokenCount: 65678,
cachedContentTokenCount: 32911,
candidatesTokenCount: 32768,
totalTokenCount: 98446,
},
{
promptTokenCount: 98446,
cachedContentTokenCount: 65679,
candidatesTokenCount: 7257,
totalTokenCount: 105703,
},
]
const server = scripted([
sseEvents(
{ ...text("a"), usageMetadata: { promptTokenCount: 120, candidatesTokenCount: 10, totalTokenCount: 130 } },
{ ...continuation("token-1"), usageMetadata: usage[0] },
),
sseEvents({ ...continuation("token-2"), usageMetadata: usage[1] }),
sseEvents({ ...continuation("token-3"), usageMetadata: usage[2] }),
sseEvents({ ...text("b", { finishReason: "STOP" }), usageMetadata: usage[3] }),
])
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
expect(response.usage).toEqual(
new Usage({
inputTokens: 142,
outputTokens: 105561,
nonCachedInputTokens: 142,
totalTokens: 105703,
providerMetadata: {
google: {
promptTokenCount: 197176,
cachedContentTokenCount: 98590,
candidatesTokenCount: 105561,
totalTokenCount: 302737,
requests: 4,
},
},
}),
)
}),
)
})
@@ -539,6 +539,50 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("continues split Gemini responses with the Vertex request body", () =>
Effect.gen(function* () {
const sent: Array<{ readonly authorization: string | null; readonly body: Record<string, unknown> }> = []
const responses = [
sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "Hel" }] },
finishReason: "CONTINUATION",
continuationToken: "token-1",
},
],
}),
sseEvents({ candidates: [{ content: { role: "model", parts: [{ text: "lo." }] }, finishReason: "STOP" }] }),
]
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertex.configure({
accessToken: "vertex-token",
project: "vertex-project",
providerOptions: { labels: { component: "opencode" }, serviceTier: "flex" },
}).model("gemini-test"),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
sent.push({ authorization: input.request.headers.authorization ?? null, body: JSON.parse(input.text) })
return input.respond(responses[sent.length - 1], { headers: { "content-type": "text/event-stream" } })
}),
),
),
)
expect(response.text).toBe("Hello.")
expect(response.events.filter((event) => event.type === "text-start")).toHaveLength(1)
expect(sent.map((request) => request.authorization)).toEqual(["Bearer vertex-token", "Bearer vertex-token"])
expect(sent[0].body).toMatchObject({ labels: { component: "opencode" } })
expect(sent[0].body).not.toHaveProperty("serviceTier")
expect(sent[1].body).toEqual({ ...sent[0].body, continuationToken: "token-1" })
}),
)
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
expect.objectContaining({
+11 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/http"
import { LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
import { LanguageModel, LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
import { Mistral } from "../../src/providers/index.js"
import { MistralChat } from "../../src/protocols/index.js"
import { LLMClient } from "../../src/route.js"
@@ -188,6 +188,16 @@ describe("Mistral Chat", () => {
)
expect(uncached.body.prompt_cache_key).toBeUndefined()
const unsupported = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }),
prompt: "Hello",
promptCacheKey: "common-key",
providerOptions: { promptCacheKey: "native-key" },
}),
)
expect(unsupported.body.prompt_cache_key).toBeUndefined()
const longKey = "cache-key-".repeat(10)
const unbounded = yield* compileRequest(
LLM.request({
@@ -99,6 +99,43 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("places cache breakpoints only when compatibility declares the format", () =>
Effect.gen(function* () {
const prepare = (cacheFormat: "anthropic" | "alibaba" | undefined) =>
compileRequest(
LLM.request({
model: OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "claude-sonnet-5", compatibility: cacheFormat === undefined ? undefined : { cacheFormat } }),
system: "You are concise.",
prompt: "Say hello.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: {} })],
}),
)
const declared = yield* prepare("anthropic")
expect(declared.body).toMatchObject({
messages: [
{ role: "system", content: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }] },
{ role: "user", content: [{ type: "text", text: "Say hello.", cache_control: { type: "ephemeral" } }] },
],
tools: [{ function: { name: "lookup" }, cache_control: { type: "ephemeral" } }],
})
expect(JSON.stringify(declared.body)).not.toContain('"ttl"')
const alibaba = yield* prepare("alibaba")
expect(alibaba.body).toMatchObject({
messages: [
{ role: "system", content: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }] },
{ role: "user", content: [{ type: "text", text: "Say hello.", cache_control: { type: "ephemeral" } }] },
],
})
expect(alibaba.body.tools?.[0]?.cache_control).toBeUndefined()
expect(JSON.stringify((yield* prepare(undefined)).body)).not.toContain("cache_control")
}),
)
it.effect("configures the max tokens request field", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
@@ -644,6 +644,27 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("treats a null function call namespace as absent", () =>
Effect.gen(function* () {
const item = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", namespace: null }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "" } },
{ type: "response.function_call_arguments.done", item_id: "fc_1", output_index: 0, arguments: "{}" },
{ type: "response.output_item.done", output_index: 0, item: { ...item, arguments: "{}" } },
{ type: "response.completed", response: { id: "resp_1", output: [{ ...item, arguments: "{}" }] } },
),
),
),
)
expect(response.toolCalls).toEqual([expect.objectContaining({ id: "call_1", name: "lookup", input: {} })])
expect(response.toolCalls[0]?.namespace).toBeUndefined()
}),
)
it.effect("rejects missing, null, and non-string event IDs even with a registered output index", () =>
Effect.gen(function* () {
yield* Effect.forEach(
@@ -2117,6 +2117,24 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("omits the prompt cache key when compatibility disables it", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }),
prompt: "Hello",
promptCacheKey: "request_cache",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
expect(model.compatibility?.supportsPromptCacheKey).toBeUndefined()
expect(
(yield* compileRequest(LLM.request({ model, prompt: "Hello", promptCacheKey: "request_cache" }))).body,
).toHaveProperty("prompt_cache_key", "request_cache")
}),
)
it.effect("parses text and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
+21 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../../src/index.js"
import { CacheHint, LanguageModel, LLM, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as OpenRouter from "../../src/providers/openrouter.js"
@@ -127,6 +127,26 @@ describe("OpenRouter", () => {
}),
)
it.effect("places breakpoints for any upstream when compatibility declares the cache format", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("vendor/claude-proxy")
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { cacheFormat: "anthropic" } }),
system: "Base agent",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
}),
)
expect(prepared.body.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" })
expect(prepared.body.messages).toMatchObject([
{ role: "system", content: [{ text: "Base agent", cache_control: { type: "ephemeral" } }] },
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
])
}),
)
it.effect("sends no default breakpoints to upstreams that cache without them", () =>
Effect.gen(function* () {
const openrouter = OpenRouter.configure({ apiKey: "test-key" })
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect } from "bun:test"
import { gunzipSync, zstdDecompressSync } from "node:zlib"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/http"
import { LLM, type RequestEncoding } from "../src/index.js"
import { LLMClient } from "../src/route.js"
import { configure } from "../src/providers/openai.js"
import { dynamicResponse } from "./lib/http.js"
import { deltaChunk, finishChunk } from "./lib/openai-chunks.js"
import { sseEvents } from "./lib/sse.js"
import { it } from "./lib/effect.js"
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4.1-mini")
const send = (prompt: string, encoding?: RequestEncoding) =>
Effect.gen(function* () {
const seen: { encoding: string | null; body: Uint8Array }[] = []
const server = dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
seen.push({
encoding: web.headers.get("content-encoding"),
body: new Uint8Array(yield* Effect.promise(() => web.arrayBuffer())),
})
return input.respond(sseEvents(deltaChunk({ role: "assistant", content: "Hi" }), finishChunk("stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
)
yield* LLMClient.generate(LLM.request({ model, prompt, http: encoding ? { encoding } : undefined })).pipe(
Effect.provide(server),
)
return seen[0]
})
describe("HTTP request encoding", () => {
it.effect("compresses the JSON body with the requested encoding", () =>
Effect.gen(function* () {
const zstd = yield* send("Hello", "zstd")
expect(zstd.encoding).toBe("zstd")
expect(JSON.parse(zstdDecompressSync(zstd.body).toString())).toMatchObject({ model: "gpt-4.1-mini" })
const gzip = yield* send("Hello", "gzip")
expect(gzip.encoding).toBe("gzip")
expect(JSON.parse(gunzipSync(gzip.body).toString())).toMatchObject({ model: "gpt-4.1-mini" })
}),
)
it.effect("sends plain JSON when unset or none", () =>
Effect.gen(function* () {
for (const sent of [yield* send("Hello"), yield* send("Hello", "none")]) {
expect(sent.encoding).toBeNull()
expect(JSON.parse(new TextDecoder().decode(sent.body))).toMatchObject({ model: "gpt-4.1-mini" })
}
}),
)
})
@@ -1,4 +1,7 @@
import { expect, sourceURL, story } from "../../storybook/playwright/story"
import type { ParentProps } from "solid-js"
import type { Command } from "@opencode/gui-extensions/sdk"
import type { Item } from "../src/runtime/extension/host"
const source = (path: string) => sourceURL(new URL(path, import.meta.url))
@@ -9,21 +12,49 @@ const modules = {
language: source("../src/runtime/i18n/language.tsx"),
browser: source("../../gui-extensions/src/browser/index.ts"),
browserRenderer: source("../../gui-extensions/src/browser/renderer.tsx"),
browserPanel: source("../../gui-extensions/src/browser/panel.tsx"),
file: source("../../gui-extensions/src/file/index.ts"),
fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"),
commands: source("../src/shell/commands/command.tsx"),
sdk: source("../../gui-extensions/src/sdk/index.ts"),
render: source("../src/runtime/extension/render.tsx"),
settings: source("../src/settings/model.tsx"),
platform: source("../src/runtime/platform/platform.tsx"),
solid: source("./extension-host.fixture.tsx"),
}
story.beforeEach(async ({ mount, page }) => {
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
await mount("ui-line-comment--editor")
await page.evaluate(async (modules) => {
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
const [
{ mountBrowserRegion },
host,
panels,
language,
browser,
file,
commands,
sdk,
renderer,
settings,
platform,
solid,
] = await Promise.all([
import(modules.fixture),
import(modules.host),
import(modules.panels),
import(modules.language),
import(modules.browser),
import(modules.file),
import(modules.commands),
import(modules.sdk),
import(modules.render),
import(modules.settings),
import(modules.platform),
import(modules.solid),
// Load the pane before mounting: Vite must not reload the page to optimize its dependencies mid-fixture.
import(modules.browserPanel),
])
mountBrowserRegion({
@@ -35,10 +66,87 @@ story.beforeEach(async ({ mount, page }) => {
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
{ ...file.default, renderer: () => import(modules.fileRenderer) },
],
Shell: (props: ParentProps) =>
solid.createComponent(platform.PlatformProvider, {
value: { platform: "web", openExternal() {}, restart: async () => undefined, notify: async () => undefined },
get children() {
return solid.createComponent(settings.SettingsProvider, {
get children() {
return solid.createComponent(commands.CommandProvider, {
get children() {
return props.children
},
})
},
})
},
}),
Commands: (props: { close: () => void }) => {
const command = commands.useCommand()
const extensions = host.useExtensionHost()
command.register("fixture-session", () => [
{
id: "tab.close",
title: "Close session tab",
keybind: "mod+w",
onSelect: props.close,
},
])
// Storybook aliases the app's command hook to a no-op. Bridge the real extension contributions
// to the real dispatcher imported by source URL, so the shortcut runs rather than its alias.
command.register("fixture-extensions", () =>
extensions.items(sdk.Command).map((item: Item<Command>) => {
const scope = item.value.scope
return {
id: `${item.extension}.${item.value.id}`,
title: item.value.title,
keybind: item.value.bind,
disabled: item.value.enabled === false,
hidden: item.value.hidden,
editable: item.value.editable,
when: scope
? (event: KeyboardEvent) => event.target instanceof Element && !!event.target.closest(scope)
: undefined,
onSelect: () => item.value.run(),
}
}),
)
return null
},
Contribution: renderer.Contribution,
})
}, modules)
})
story("closes a blank browser tab from its address field without closing the session", async ({ page }) => {
const root = page.getByTestId("browser-region-fixture")
const outside = root.getByRole("button", { name: "Beta", exact: true })
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
await outside.click()
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true")
// Prove the scope while browser.close is still registered.
await outside.focus()
await outside.press("ControlOrMeta+w")
await expect(root.getByTestId("session-state")).toHaveText("closed")
await expect(root.getByTestId("browser-closes")).toHaveText("[]")
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toBeVisible()
await outside.click()
await expect(root.getByTestId("session-state")).toHaveText("open")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("example.com")
await expect(address).toBeFocused()
await address.press("ControlOrMeta+w")
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveCount(0)
await expect(root.getByTestId("browser-closes")).toHaveText('["tab_33333333-3333-3333-3333-333333333333"]')
await expect(root.getByTestId("session-state")).toHaveText("open")
})
story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => {
const root = page.getByTestId("browser-region-fixture")
const tabs = root.getByRole("tab")
@@ -1,19 +1,187 @@
import { expect, story } from "../../storybook/playwright/story"
story("shows question navigation shortcuts", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const shortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘⏎" : "Ctrl+⏎"))
const next = component.getByRole("button", { name: "Next", exact: true })
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
story("resizes the dock to fit different answer counts", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-answer-counts")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const next = question.getByRole("button", { name: "Next", exact: true })
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
await expect(next).toBeEnabled()
const height = () => question.evaluate((element) => element.getBoundingClientRect().height)
const initial = await height()
await next.click()
await expect(component.getByRole("button", { name: "Submit", exact: true })).toContainText(shortcut)
await expect.poll(height).toBeGreaterThan(initial)
await expect(question.getByRole("checkbox", { name: "Diagnostics", exact: true })).toBeVisible()
const expanded = await height()
const back = question.getByRole("button", { name: "Back", exact: true })
await expect(back).toBeEnabled()
await back.click()
await expect.poll(height).toBeLessThan(expanded)
await expect(question.getByRole("radio", { name: /Focused/ })).toBeVisible()
})
story("selects single answers and toggles multiple answers with number keys", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const wide = question.getByRole("radio", { name: /Wide/ })
const customRadio = question.getByRole("radio", { name: "Type your own answer…", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
const next = question.getByRole("button", { name: "Next", exact: true })
await expect(focused).toBeFocused()
await focused.press("2")
await expect(wide).toBeChecked()
await expect(focused).not.toBeChecked()
await expect(wide).toBeFocused()
await next.press("1")
await expect(focused).toBeChecked()
await expect(wide).not.toBeChecked()
await focused.press("3")
await expect(customRadio).toBeChecked()
await expect(input).toBeFocused()
await expect(focused).not.toBeChecked()
await expect(wide).not.toBeChecked()
await input.press("Enter")
await expect(customRadio).toBeFocused()
await customRadio.press("2")
await expect(wide).toBeChecked()
await expect(customRadio).not.toBeChecked()
await next.click()
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
const customCheckbox = question.getByRole("checkbox", { name: "Type your own answer…", exact: true })
await expect(files).toBeFocused()
await files.press("1")
await expect(files).toBeChecked()
await files.press("2")
await expect(terminal).toBeChecked()
await expect(files).toBeChecked()
await terminal.press("2")
await expect(terminal).not.toBeChecked()
await expect(files).toBeChecked()
await terminal.press("4")
await expect(customCheckbox).toBeChecked()
await expect(input).toBeFocused()
await expect(files).toBeChecked()
await expect(terminal).not.toBeChecked()
await input.press("Enter")
await expect(customCheckbox).toBeFocused()
await customCheckbox.press("4")
await expect(customCheckbox).not.toBeChecked()
await expect(files).toBeChecked()
await expect(terminal).not.toBeChecked()
})
story("ignores modified, out-of-range, and repeated number shortcuts", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const wide = question.getByRole("radio", { name: /Wide/ })
await expect(focused).toBeFocused()
await focused.press("1")
await focused.press("9")
await focused.press("Alt+2")
await expect(focused).toBeChecked()
await expect(wide).not.toBeChecked()
await question.getByRole("button", { name: "Next", exact: true }).click()
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeFocused()
await page.keyboard.down("2")
await expect(terminal).toBeChecked()
await page.keyboard.down("2")
await expect(terminal).toBeChecked()
await page.keyboard.up("2")
})
story("types digits into the custom answer without selecting numbered options", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
await expect(focused).toBeFocused()
await focused.press("3")
await expect(input).toBeFocused()
await input.press("1")
await input.press("2")
await input.press("3")
await expect(input).toHaveValue("123")
await expect(custom).toBeChecked()
await expect(focused).not.toBeChecked()
await expect(question.getByRole("radio", { name: /Wide/ })).not.toBeChecked()
})
story("shows question navigation shortcuts in buttons and tooltips", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform))
const shortcut = mac ? "⌘⏎" : "Ctrl+⏎"
const next = component.getByRole("button", { name: "Next", exact: true })
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toBeVisible()
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
await expect(next).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter")
await next.hover()
await expect(page.getByRole("tooltip")).toContainText("Next")
await expect(page.getByRole("tooltip")).toContainText(shortcut)
await next.press(mac ? "Meta+Enter" : "Control+Enter")
const submit = component.getByRole("button", { name: "Submit", exact: true })
await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toBeVisible()
await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
await expect(submit).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter")
await submit.hover()
await expect(page.getByRole("tooltip")).toContainText("Submit")
await expect(page.getByRole("tooltip")).toContainText(shortcut)
const back = component.getByRole("button", { name: "Back", exact: true })
const backShortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘[" : "Alt+←"))
await expect(back).toHaveText("Back")
await expect(back).toHaveAttribute("aria-keyshortcuts", mac ? "Meta+[" : "Alt+ArrowLeft")
await back.hover()
await expect(page.getByRole("tooltip")).toContainText(backShortcut)
await page.keyboard.press(
await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+[" : "Alt+ArrowLeft")),
)
await expect(page.getByRole("tooltip")).toContainText(mac ? "⌘[" : "Alt+←")
await back.press(mac ? "Meta+[" : "Alt+ArrowLeft")
await expect(next).toBeVisible()
})
story("keeps the custom answer input and dock geometry stable while editing", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const row = question.locator('[data-slot="question-option"][data-custom="true"]')
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
const toggle = question.getByRole("radio", { name: "Type your own answer…", exact: true })
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
await expect(input).toBeEditable()
await expect(input).toHaveValue("")
const node = await input.evaluateHandle((element) => element)
const height = () => question.evaluate((element) => element.getBoundingClientRect().height)
const initial = await height()
const draft = "A custom answer long enough to wrap if the editor becomes multiline instead of staying single-line"
const predefined = question.getByRole("radio", { name: /Focused/ })
await predefined.click()
await input.focus()
await expect(input).toBeFocused()
await expect(predefined).toBeChecked()
await expect(toggle).not.toBeChecked()
await row.click({ position: { x: 4, y: 4 } })
await expect(toggle).toBeChecked()
await expect(input).toBeFocused()
await expect(input).toHaveValue("")
await expect(predefined).not.toBeChecked()
await input.fill(draft)
await expect(input).toHaveValue(draft)
await expect(toggle).toBeChecked()
await expect(predefined).not.toBeChecked()
await expect.poll(height).toBe(initial)
await input.press("Enter")
await expect(toggle).toBeFocused()
await expect(input).toHaveValue(draft)
await row.click({ position: { x: 30, y: 20 } })
await expect(input).toBeFocused()
await expect(input).toHaveValue(draft)
await expect
.poll(() => input.evaluate((element, original) => element === original && original.isConnected, node))
.toBe(true)
await node.dispose()
})
@@ -85,13 +85,52 @@ test("appends search results without resetting the selected command", async ({ p
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
})
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
test("selects the first session when it arrives above automatically selected files", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
)
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
json: {
data: Array.from({ length: 18 }, (_, index) => ({
...paletteSession,
id: index === 0 ? paletteSession.id : `ses_palette_readme_${index}`,
location: { directory: paletteSession.directory },
title: `README work ${index + 1}`,
})),
},
})
})
await input.fill("README")
await expect(dialog.getByRole("option", { name: "/ README.md", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
sessions.resolve()
const first = dialog.getByRole("option", { name: /README work 1 command-palette/ })
const second = dialog.getByRole("option", { name: /README work 2 command-palette/ })
await expect(first).toHaveAttribute("aria-selected", "true")
await expect(first).toBeInViewport()
await input.press("ArrowDown")
await expect(second).toHaveAttribute("aria-selected", "true")
await expect(second).toBeInViewport()
await input.press("ArrowUp")
await expect(first).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})
test("keeps a keyboard-selected file when session results arrive later", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const { dialog, input } = await openCommandPalette(page)
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({ json: { data: [{ path: "README.txt", type: "file" }, { path: "README.md", type: "file" }] } }),
)
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
@@ -102,6 +141,11 @@ test("keeps the automatically selected file when session results arrive later",
})
await input.fill("README")
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
await expect(dialog.getByRole("option", { name: "/ README.txt", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await input.press("ArrowDown")
await expect(file).toHaveAttribute("aria-selected", "true")
sessions.resolve()
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
@@ -4,6 +4,89 @@ import { openDraft, openSession } from "../utils/workspace"
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
test("a catalog refresh cannot replace an existing session's model", async ({ page }) => {
const alpha = { id: "alpha", name: "Alpha", variants: { high: {} } }
const beta = { id: "beta", name: "Beta" }
const catalog = { current: provider(alpha, beta) }
const switches: unknown[] = []
const prompts: unknown[] = []
const sessionID = "ses_catalog_selection"
page.on("request", (request) => {
if (request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/model`)
switches.push(request.postDataJSON())
})
const view = await openSession(page, {
name: "CatalogSelection",
sessions: [{ id: sessionID, agent: "build", model: { id: "alpha", providerID: "opencode", variant: "high" } }],
provider: () => catalog.current,
onPrompt: (input) => void prompts.push(input.body),
seed: { storage: { "opencode.global.dat:model": { recent: [{ providerID: "opencode", modelID: "beta" }] } } },
})
const control = page.locator('[data-action="composer-model"]')
await expect(control).toHaveText("Alpha")
await view.editor.fill("Keep my selected model")
catalog.current = provider(beta)
await view.push([
{
id: "evt_catalog_missing",
created: T0 + 1,
type: "model.updated",
location: { directory: view.directory },
data: {},
},
])
await expect(control).toHaveText("Select model")
await view.editor.press("Enter")
await expect(page.getByText("Select an agent and model", { exact: true })).toBeVisible()
await expect(view.editor).toHaveText("Keep my selected model")
expect(switches).toEqual([])
expect(prompts).toEqual([])
catalog.current = provider(alpha, beta)
await view.push([
{
id: "evt_catalog_restored",
created: T0 + 2,
type: "model.updated",
location: { directory: view.directory },
data: {},
},
])
await expect(control).toHaveText("Alpha")
await expect(page.getByRole("button", { name: "Choose model variant", exact: true })).toHaveText("high")
await view.editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(switches).toEqual([{ model: { id: "alpha", providerID: "opencode", variant: "high" } }])
catalog.current = provider(beta)
await view.push([
{
id: "evt_catalog_removed",
created: T0 + 3,
type: "model.updated",
location: { directory: view.directory },
data: {},
},
])
await expect(control).toHaveText("Select model")
await control.click()
await page
.getByRole("dialog", { name: "Select model" })
.getByRole("button", { name: "Beta Free", exact: true })
.click()
await expect(control).toHaveText("Beta")
await view.editor.fill("Use the replacement I chose")
await view.editor.press("Enter")
await expect.poll(() => prompts.length).toBe(2)
expect(switches).toEqual([
{ model: { id: "alpha", providerID: "opencode", variant: "high" } },
{ model: { id: "beta", providerID: "opencode" } },
])
})
async function draft(page: Page) {
const { editor } = await openDraft(page, { name: "ComposerDraft", provider: NO_PROVIDER })
await editor.click()
@@ -227,6 +310,35 @@ test("keeps a narrow session composer contained when invoking a built-in", async
await expect(editor).toBeEmpty()
})
test("keeps focus in a dialog that replaces the model dialog and returns it to the composer caret", async ({
page,
}) => {
const { editor } = await openSession(page, { name: "ComposerDialogFocus", provider: NO_PROVIDER })
await editor.fill("keep me")
await editor.press("End")
await editor.press("ArrowLeft")
await editor.press("ArrowLeft")
await editor.press("ArrowLeft")
await editor.press("ControlOrMeta+'")
const models = page.getByRole("dialog", { name: "Select model", exact: true })
await expect(models.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
await models.getByRole("button", { name: "Manage models", exact: true }).click()
await expect(models).toBeHidden()
const manage = page.getByRole("dialog", { name: "Manage models", exact: true })
const search = manage.getByRole("searchbox", { name: "Search models", exact: true })
await expect(search).toBeFocused()
// Code behind the dialog that focuses the composer must not pull focus out of the open dialog.
await editor.evaluate((element) => element.focus())
await expect(search).toBeFocused()
await page.keyboard.press("Escape")
await expect(manage).toBeHidden()
await expect(editor).toBeFocused()
await page.keyboard.type(",")
await expect(editor).toHaveText("keep, me")
})
test("lists slash commands in their built-in order", async ({ page }) => {
const { editor } = await openSession(page, {
name: "ComposerSlash",
@@ -292,6 +404,57 @@ for (const row of [
})
}
test("marks a Console provider with its workspace only when it is also connected directly", async ({ page }) => {
const model = (id: string, name: string) => ({ [id]: { id, name, limit: { context: 200_000 } } })
const managed = (id: string, canonical: string, name: string, models: ReturnType<typeof model>) => ({
id,
canonical,
integrationID: "opencode",
name: `Anomaly / ${name}`,
models,
})
await openSession(page, {
name: "ComposerConsoleProviders",
provider: {
all: [
managed("opencode", "opencode", "OpenCode", model("console-model", "Console Model")),
managed("console-google", "google", "Google", model("managed-gemini", "Managed Gemini")),
managed("console-openai", "openai", "OpenAI", model("managed-gpt", "Managed GPT")),
{ id: "google", canonical: "google", name: "Google", models: model("direct-gemini", "Direct Gemini") },
],
connected: ["opencode", "console-google", "console-openai", "google"],
default: { providerID: "opencode", modelID: "console-model" },
},
seed: {
storage: {
"opencode.global.dat:model": {
user: [
["opencode", "console-model"],
["console-google", "managed-gemini"],
["console-openai", "managed-gpt"],
["google", "direct-gemini"],
].map(([providerID, modelID]) => ({ providerID, modelID, visibility: "show" })),
recent: [],
variant: {},
},
},
},
})
await page.locator('[data-component="composer"] [data-action="composer-model"]').click()
const menu = page.getByRole("menu")
await expect(menu.getByRole("menuitemradio", { name: "Direct Gemini", exact: true })).toBeVisible()
const headings = menu.locator('[data-slot="menu-v2-group-label"]')
await expect(headings).toHaveCount(4)
await expect(headings.filter({ hasText: /^GoogleAnomaly$/ })).toHaveCount(1)
await expect(headings.filter({ hasText: /^Google$/ })).toHaveCount(1)
await expect(headings.filter({ hasText: /^OpenAI$/ })).toHaveCount(1)
await expect(headings.filter({ hasText: /^OpenCode$/ })).toHaveCount(1)
await expect(menu.getByText(/Anomaly \//)).toHaveCount(0)
})
test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => {
const { editor: input } = await openSession(page, {
name: "ComposerThinking",
@@ -74,14 +74,32 @@ async function setup(page: Page, input: { tabs: TabSeed[]; a?: Partial<MockServe
)
.toBe(true)
const enableAutoAccept = async () => {
await page.keyboard.press("Control+,")
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
return {
replies,
sessionGets,
transport: servers[SERVER]!.transport,
listed,
enableAutoAccept: () => enableAutoAccept(page),
}
}
return { replies, sessionGets, transport: servers[SERVER]!.transport, listed, enableAutoAccept }
async function enableAutoAccept(page: Page) {
await page.keyboard.press("Control+,")
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
}
async function chooseProjectAutoAccept(page: Page, project: string, current: string, choice: "On" | "Off") {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project, exact: true }).click()
const autoAccept = settings.locator('[data-action="settings-project-auto-accept-permissions"]')
await expect(autoAccept).toHaveText(current)
await autoAccept.click()
await page.getByRole("option", { name: choice, exact: true }).click()
await expect(autoAccept).toHaveText(choice)
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
}
const reply = (sessionID: string, permissionID: string) => ({
@@ -201,3 +219,60 @@ test("auto-accept approves a request discovered by opening a session", async ({
await view.enableAutoAccept()
await expect.poll(() => view.replies).toEqual([reply(sessionA.id, "permission-synced-a")])
})
test("a project's auto-accept choice overrides the default in both directions", async ({ page }) => {
const api = session({ id: "ses_project_api", directory: "/work/api", title: "API session", projectID: "proj_api" })
const web = session({ id: "ses_project_web", directory: "/work/web", title: "Web session", projectID: "proj_web" })
const requests = { [api.id]: [pending("permission-api", api.id)], [web.id]: [pending("permission-web", web.id)] }
const replies: string[] = []
await mockServers(page, {
[SERVER]: {
directory: "/work/api",
project: project({ id: "proj_api", directory: "/work/api" }),
projects: [
project({ id: "proj_api", directory: "/work/api" }),
project({ id: "proj_web", directory: "/work/web" }),
],
provider: { all: [], connected: [], default: {} },
sessions: [api, web],
pageMessages: () => ({ items: [] }),
sessionStatus: { [api.id]: { type: "running" }, [web.id]: { type: "running" } },
permissions: () => Object.values(requests).flat(),
sessionPermissions: requests,
onPermissionReply: (reply) => replies.push(reply.permissionID),
},
})
await seed(page, {
projects: { local: [{ worktree: "/work/api" }, { worktree: "/work/web" }] },
tabs: [api.id, web.id],
})
const dock = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
const settings = page.getByTestId("settings-screen")
await page.goto(sessionHref(web.id))
await expectSessionTitle(page, web.title)
await expect(dock.getByText("git status")).toBeVisible()
// On for api while the default is off; off for web before the default turns on.
await page.keyboard.press("Control+,")
await chooseProjectAutoAccept(page, "api", "Use default (Off)", "On")
await expect.poll(() => replies).toEqual(["permission-api"])
await chooseProjectAutoAccept(page, "web", "Use default (Off)", "Off")
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await settings.getByRole("button", { name: "Back to app" }).click()
await expectSessionTitle(page, web.title)
await expect(dock.getByText("git status")).toBeVisible()
expect(replies).toEqual(["permission-api"])
// Back on the default, web follows it.
await page.keyboard.press("Control+,")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "web", exact: true }).click()
await settings.locator('[data-action="settings-project-auto-accept-permissions"]').click()
await page.getByRole("option", { name: "Use default (On)" }).click()
await expect.poll(() => replies).toEqual(["permission-api", "permission-web"])
})
+331 -7
View File
@@ -86,7 +86,9 @@ test("Git initialization belongs to its session and refreshes after a session sw
await expect(pending).toHaveCount(0)
})
test("a rejected Git init releases its session's button without showing an error in another session", async ({ page }) => {
test("a rejected Git init releases its session's button without showing an error in another session", async ({
page,
}) => {
await openSession(page, {
name: "ReviewInitFailure",
project: { vcs: undefined },
@@ -143,7 +145,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" })
const contextButton = page.getByRole("button", { name: "Toggle session context" })
const openFile = panel.getByRole("button", { name: "Open file" })
const tab = (name: string) => panel.getByRole("tab", { name, exact: true })
await contextButton.click()
@@ -181,7 +183,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
const missingResponse = page.waitForResponse((response) => response.url().includes("/api/fs/read/README.md"))
await workspace.push([filesystemEvent(directory, "README.md", "unlink")])
await missingResponse
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
await expect(page.getByText("Failed to load file", { exact: true })).toHaveCount(0)
await expect(tab("File not found: README.md")).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("File not found: README.md", { exact: true })).toBeVisible()
@@ -309,7 +311,7 @@ test("context closes the side region only when its button opened it", async ({ p
})
const panel = page.locator("#review-panel")
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
const contextButton = page.getByRole("button", { name: "View context usage" })
const contextButton = page.getByRole("button", { name: "Toggle session context" })
const context = panel.getByRole("tab", { name: "Context", exact: true })
await contextButton.click()
@@ -333,6 +335,83 @@ test("context closes the side region only when its button opened it", async ({ p
await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true")
})
test("context hides free-model cost but keeps paid-model zero cost", async ({ page }) => {
const cases = [
{ id: "ses_context_free", title: "Free model", model: "free", cost: 0, usage: false, text: "0" },
{ id: "ses_context_paid", title: "Paid model", model: "paid", cost: 0, usage: false, text: "0·$0.00" },
{ id: "ses_context_used", title: "Paid usage", model: "paid", cost: 1.25, usage: true, text: "2k·$1.25" },
{ id: "ses_context_free_used", title: "Free usage", model: "free", cost: 0, usage: true, text: "2k" },
{ id: "ses_context_mixed", title: "Previously paid", model: "free", cost: 1.25, usage: true, text: "2k·$1.25" },
{ id: "ses_context_unknown", title: "Unknown pricing", model: "missing", cost: 0, usage: false, text: "0·$0.00" },
{ id: "ses_context_output", title: "Output pricing", model: "output", cost: 0, usage: false, text: "0·$0.00" },
// The root's model is free, but its subagent spent on a paid model: the family total shows.
{ id: "ses_context_family", title: "Paid subagent", model: "free", cost: 0, usage: true, text: "2k·$1.25" },
// A completed compaction resets the context until the next reply reports usage, as in the TUI.
{ id: "ses_context_compacted", title: "Compacted", model: "paid", cost: 1.25, usage: true, text: "0·$1.25" },
]
await openSession(page, {
name: "ContextPricing",
provider: provider(
{ id: "free", name: "Free", cost: { input: 0, output: 0 } },
{ id: "paid", name: "Paid", cost: { input: 1, output: 3 } },
{ id: "output", name: "Output", cost: { input: 0, output: 1 } },
),
sessions: [
...cases.map((item) => ({
id: item.id,
title: item.title,
model: { id: item.model, providerID: "opencode" },
cost: item.cost,
})),
{
id: "ses_context_family_child",
parentID: "ses_context_family",
title: "Subagent run",
model: { id: "paid", providerID: "opencode" },
cost: 1.25,
},
],
pageMessages: (id) => ({
items: cases.flatMap((item): SessionMessageInfo[] =>
item.id === id && item.usage
? [
{
id: `msg_${item.id}`,
type: "assistant",
agent: "build",
model: { id: item.model, providerID: "opencode" },
tokens: { input: 1500, output: 500, reasoning: 0, cache: { read: 0, write: 0 } },
content: [{ type: "text", text: "Done." }],
time: { created: 1, completed: 2 },
},
...(item.id === "ses_context_compacted"
? [
{
id: `msg_${item.id}_compaction`,
type: "compaction" as const,
status: "completed" as const,
reason: "manual" as const,
summary: "Current state",
recent: "",
time: { created: 3 },
},
]
: []),
]
: [],
),
}),
})
const button = page.getByRole("button", { name: "Toggle session context", exact: true })
for (const item of cases) {
await page.locator("[data-titlebar-tab-slot]", { hasText: item.title }).click()
await expectSessionTitle(page, item.title)
await expect(button).toHaveText(item.text)
}
})
test("file tree expands Windows paths and scrolls long names in both directions", async ({ page }) => {
const directory = "C:/OpenCode/OpenFileExpand"
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
@@ -493,12 +572,156 @@ test("rereads a file each time an artifact link opens it", async ({ page }) => {
await expect(tab).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("second draft", { exact: true })).toBeVisible()
await expect(panel.getByText("first draft", { exact: true })).toHaveCount(0)
// A restored file tab whose file was deleted shows its inline not-found state without a toast; clicking a broken link
// when no tab is open for it shows the toast.
await page.route("**/api/fs/read/shopping.txt*", (route) =>
route.fulfill({
status: 404,
headers: { "access-control-allow-origin": "*" },
// SAFETY: the server's wire body for a missing file, which the client decodes into its own error.
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
json: { _tag: "FileNotFoundError", path: "shopping.txt", message: "File not found: shopping.txt" },
}),
)
await page.reload()
const missingTab = panel.getByRole("tab", { name: "File not found: shopping.txt", exact: true })
await expect(missingTab).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("File not found: shopping.txt", { exact: true })).toBeVisible()
await expect(page.getByText("Failed to load file", { exact: true })).toHaveCount(0)
await missingTab.locator("..").getByRole("button", { name: "Close tab" }).click()
await expect(missingTab).toHaveCount(0)
await link.click()
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
await expect(missingTab).toHaveCount(0)
})
/** What the image case's frame probe records on the window. */
type ImageProbe = { problems: string[]; frames: number; running: boolean }
test("image files keep the review panel painted while loading", async ({ page }) => {
test("image gestures anchor zoom, pan, and reset independently of the pane", async ({ page }) => {
// Some browsers expose no natural dimensions for a viewBox-only SVG.
await page.addInitScript(() => {
for (const name of ["naturalWidth", "naturalHeight"]) {
const original = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, name)!
Object.defineProperty(HTMLImageElement.prototype, name, {
...original,
get() {
return this.alt === "second.svg" ? 0 : original.get?.call(this)
},
})
}
})
await openSession(page, {
name: "ImageGestures",
fileList: () => [],
fileContent: (path) => ({
content:
path === "second.svg"
? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1600"><rect width="1600" height="1600" fill="#6699aa"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1600"><rect width="1600" height="1600" fill="#6699aa"/></svg>',
}),
pageMessages: () => ({
items: [
{
id: "msg_images",
type: "assistant",
agent: "build",
model: { id: "test", providerID: "opencode" },
content: [{ type: "text", text: "[First](first.svg) [Second](second.svg)" }],
time: { created: 1, completed: 2 },
},
],
}),
})
await page.getByRole("link", { name: "First", exact: true }).click()
const panel = page.locator("#review-panel")
const image = panel.getByRole("img", { name: "first.svg", exact: true })
const stage = panel.locator('[data-slot="artifact-stage"]')
const fit = panel.getByRole("button", { name: "Fit", exact: true })
await expect(image).toBeVisible()
await expect(fit).toBeDisabled()
// Wait for the side pane's opening motion before choosing a gesture anchor.
await image.hover()
const initial = (await image.boundingBox())!
const pane = (await panel.boundingBox())!
const anchor = { x: Math.round(initial.x + initial.width * 0.55), y: Math.round(initial.y + initial.height * 0.55) }
await page.mouse.move(anchor.x, anchor.y)
// A continuous pinch catches anchor drift while the image still fits one axis.
const cdp = await page.context().newCDPSession(page)
await cdp.send("Input.synthesizePinchGesture", {
...anchor,
scaleFactor: 2,
relativeSpeed: 500,
gestureSourceType: "mouse",
})
await cdp.detach()
await expect(fit).toBeEnabled()
const zoomed = (await image.boundingBox())!
expect(zoomed.width).toBeCloseTo(initial.width * 2, 1)
expect((anchor.x - zoomed.x) / zoomed.width).toBeCloseTo((anchor.x - initial.x) / initial.width, 4)
expect((anchor.y - zoomed.y) / zoomed.height).toBeCloseTo((anchor.y - initial.y) / initial.height, 4)
expect(await panel.boundingBox()).toEqual(pane)
expect(await page.evaluate(() => window.visualViewport?.scale)).toBe(1)
await page.mouse.wheel(40, 60)
await expect.poll(async () => (await image.boundingBox())!.x).toBeCloseTo(zoomed.x - 40, 1)
expect((await image.boundingBox())!.y).toBeCloseTo(zoomed.y - 60, 1)
const scrolled = (await image.boundingBox())!
await page.mouse.down()
await page.mouse.move(anchor.x + 60, anchor.y + 40, { steps: 5 })
await page.mouse.up()
await expect(fit).toBeEnabled()
expect((await image.boundingBox())!.x).toBeCloseTo(scrolled.x + 60, 1)
expect((await image.boundingBox())!.y).toBeCloseTo(scrolled.y + 40, 1)
// Touch uses the same captured-pointer path and must not generate a click-to-fit.
const touch = await page.context().newCDPSession(page)
await touch.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: anchor.x, y: anchor.y }] })
await touch.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: anchor.x - 30, y: anchor.y - 20 }],
})
await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
await touch.detach()
await expect(fit).toBeEnabled()
expect((await image.boundingBox())!.x).toBeCloseTo(scrolled.x + 30, 1)
expect((await image.boundingBox())!.y).toBeCloseTo(scrolled.y + 20, 1)
// Keyboard-accessible fit restores the exact original geometry.
await fit.focus()
await page.keyboard.press("Enter")
await expect(fit).toBeDisabled()
expect(await image.boundingBox()).toEqual(initial)
expect(
await stage.evaluate((element) =>
element.dispatchEvent(new WheelEvent("wheel", { deltaY: 40, bubbles: true, cancelable: true })),
),
).toBe(true)
await image.click()
await expect(fit).toBeEnabled()
await page.getByRole("link", { name: "Second", exact: true }).click()
await expect(panel.getByRole("img", { name: "second.svg", exact: true })).toBeVisible()
await expect(fit).toBeDisabled()
await panel.getByRole("img", { name: "second.svg", exact: true }).hover()
const second = (await panel.getByRole("img", { name: "second.svg", exact: true }).boundingBox())!
expect(second.width).toBeGreaterThan(0)
expect(second.height).toBeGreaterThan(0)
await page.keyboard.down("Control")
await page.mouse.wheel(0, -50)
await expect(fit).toBeEnabled()
await page.keyboard.up("Control")
await fit.click()
await page.setViewportSize({ width: 1200, height: 700 })
await expect
.poll(async () => {
const frame = (await stage.boundingBox())!
const media = (await panel.getByRole("img", { name: "second.svg", exact: true }).boundingBox())!
return media.width <= frame.width - 47 && media.height <= frame.height - 47
})
.toBe(true)
})
test("image files load into the review panel and keep it painted while loading", async ({ page }) => {
await page.setViewportSize({ width: 960, height: 900 })
const directory = "C:/OpenCode/ReviewImage"
const image = "assets/preview.png"
@@ -512,7 +735,13 @@ test("image files keep the review panel painted while loading", async ({ page })
read.resolve()
await release.promise
return { type: "binary", content: "iVBORw0KGgo=", encoding: "base64", mimeType: "image/png" }
// A 1x1 PNG.
return {
type: "binary",
content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
encoding: "base64",
mimeType: "image/png",
}
},
fileList: (path) => {
if (!path) return [fileNode(directory, "assets", "directory"), fileNode(directory, "src", "directory")]
@@ -568,6 +797,10 @@ test("image files keep the review panel painted while loading", async ({ page })
release.resolve()
await response
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("preview.png")
await expect(page.locator("#review-panel").getByRole("img", { name: image, exact: true })).toHaveJSProperty(
"naturalWidth",
1,
)
const loaded = await frames()
await expect.poll(frames).toBeGreaterThan(loaded)
expect(
@@ -581,6 +814,84 @@ test("image files keep the review panel painted while loading", async ({ page })
).toEqual([])
})
test("video files over 25 MB play in the viewer, expand into the theatre overlay, and collapse on Escape without remounting", async ({
page,
}) => {
const directory = "C:/OpenCode/ReviewVideo"
const file = "arcade-update-draft.mp4"
const head = Buffer.from(
"AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAAAMm1kYXSCSYNCAAPwAjYAOCQcGEIAADBgAABYS///mpmB/////7FMC////+OkgAAAAAL+bW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAil0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAEAAAAAkAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAAAAABAAAAAAGhbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAAQABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABTG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAQxzdGJsAAAAqHN0c2QAAAAAAAAAAQAAAJh2cDA5AAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAJABIAAAASAAAAAAAAAABF0xhdmM2My4xLjEwMiBsaWJ2cHgtdnA5AAAAAAAAAAAAGP//AAAAFHZwY0MBAAAAAAqCAgICAAAAAAAKZmllbAEAAAAAEHBhc3AAAAABAAAAAQAAABRidHJ0AAAAAAAAJxAAAAAAAAAAGHN0dHMAAAAAAAAAAQAAAAEAAEAAAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAABAAAAAQAAABRzdHN6AAAAAAAAACoAAAABAAAAFHN0Y28AAAAAAAAAAQAAACwAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYzLjEuMTAy",
"base64",
)
const mp4 = Buffer.alloc(38_400_000)
head.copy(mp4)
mp4.writeUInt32BE(mp4.length - head.length, head.length)
mp4.write("free", head.length + 4, "ascii")
await openSession(page, {
name: "ReviewVideo",
fileList: (path) => (path ? [] : [fileNode(directory, file)]),
seed: {
panes: { ses_reviewvideo: { review: true } },
},
})
await page.route(`**/api/fs/read/${file}*`, (route) =>
route.fulfill({
status: 200,
headers: { "access-control-allow-origin": "*", "content-type": "application/octet-stream" },
body: mp4,
}),
)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
await panel.getByRole("button", { name: file }).click()
await expect(panel.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
const toolbar = panel.locator('[data-slot="artifact-toolbar"]')
const stage = panel.locator('[data-slot="artifact-stage"]')
const video = stage.locator('video[data-slot="artifact-media"]')
const fullscreen = stage.getByRole("button", { name: "Enter full screen", exact: true })
const exitFullscreen = stage.getByRole("button", { name: "Exit full screen", exact: true })
await expect(video).toBeVisible()
await expect(toolbar).toContainText("64 × 36")
await expect(toolbar).toContainText("0:01")
await expect(toolbar).toContainText("38.4 MB")
await expect(panel.getByText(/Binary file/)).toHaveCount(0)
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
const inlineBounds = (await stage.boundingBox())!
expect(inlineBounds.width).toBeLessThan(1000)
await video.evaluate((element) => {
element.dataset.e2eProbe = "retained"
})
await fullscreen.click()
await expect(exitFullscreen).toHaveAttribute("aria-pressed", "true")
await expect(stage).toHaveAttribute("data-expanded", "")
expect(await stage.evaluate((element) => element.matches(":popover-open"))).toBe(true)
expect(await page.evaluate(() => document.fullscreenElement)).toBeNull()
expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained")
await expect.poll(() => stage.boundingBox()).toEqual({ x: 0, y: 0, width: 1440, height: 900 })
await page.keyboard.press("Escape")
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
await expect(stage).not.toHaveAttribute("data-expanded")
expect(await stage.evaluate((element) => element.matches(":popover-open"))).toBe(false)
expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained")
await expect.poll(() => stage.boundingBox()).toEqual(inlineBounds)
await video.dblclick()
await expect(exitFullscreen).toHaveAttribute("aria-pressed", "true")
await page.mouse.click(8, 8)
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained")
})
test("restores review state and the side-panel tab per session", async ({ page }) => {
const sessions = [
{ id: "ses_review_state_a", title: "Alpha review state" },
@@ -630,7 +941,7 @@ test("restores review state and the side-panel tab per session", async ({ page }
await page.getByRole("option", { name: "Branch changes" }).click()
await page.getByRole("button", { name: "beta.ts" }).click()
await selectedFile("beta.ts")
await page.getByRole("button", { name: "View context usage" }).click()
await page.getByRole("button", { name: "Toggle session context" }).click()
await selectedTab("Context")
await switchSession("Gamma review state")
@@ -664,9 +975,22 @@ test("restores review state and the side-panel tab per session", async ({ page }
await selectedFile("gamma.ts")
await review.press("End")
await selectedTab("README.md")
// A hidden side-panel tab does not claim Close Tab while the side region is collapsed.
await toggle.click()
await expect(panel).toBeHidden()
await page.keyboard.press("Control+w")
await expect(page.locator("[data-titlebar-tab-slot]", { hasText: "Alpha review state" })).toHaveCount(0)
await expectSessionTitle(page, "Beta review state")
await page.keyboard.press("Control+Shift+T")
await expectSessionTitle(page, "Alpha review state")
await toggle.click()
await selectedTab("README.md")
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "README.md", exact: true })).toHaveCount(0)
await expect(review).toHaveAttribute("aria-selected", "true")
await expectSessionTitle(page, "Alpha review state")
})
test("shows and restores last turn changes from the session diff", async ({ page }) => {
@@ -4,7 +4,7 @@ import { PromptInput } from "@opencode/schema/prompt-input"
import { SessionInbox } from "@opencode/schema/session-inbox"
import { SessionMessage } from "@opencode/schema/session-message"
import { Schema } from "effect"
import { provider } from "../utils/app"
import { holdRoute, provider } from "../utils/app"
import type { MockServerConfig } from "../utils/mock-server"
import { openSession } from "../utils/workspace"
@@ -160,6 +160,7 @@ async function openQueue(
mock: ReturnType<typeof createQueueMock>,
followUpBehavior?: "queue" | "steer",
revert?: string,
status: "idle" | "running" = "running",
) {
const model = { id: "queue-model", name: "Queue Model" }
@@ -175,7 +176,7 @@ async function openQueue(
],
provider: provider(model),
pageMessages: () => ({ items: mock.messages }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
sessionStatus: () => ({ [sessionID]: { type: status } }),
inbox: () => [...mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })), ...mock.compactions],
onPrompt: mock.onPrompt,
onCompact: mock.onCompact,
@@ -223,6 +224,73 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
await expect(view.input).toHaveText("")
})
test("a steer shows as pending in the timeline as the composer clears, before its selection commits", async ({
page,
}) => {
// The running execution has delivered input, so a new steer waits behind its work.
const mock = createQueueMock(
[],
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
)
const view = await openQueue(page, mock, "steer")
const commit = await holdRoute(page, (url) => url.pathname === `/api/session/${sessionID}/model`, { method: "POST" })
const prompt = page.locator('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]', {
hasText: "steer while the model commits",
})
await expect(userRow(page, "msg_queue_delivered")).toContainText("First prompt")
await view.input.fill("steer while the model commits")
await view.input.press("Enter")
await commit.arrived
await expect(view.input).toHaveText("")
await expect(prompt).toBeVisible()
await expect(prompt.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true")
// It turns pending at once rather than fading from the delivered color it may have rendered with first.
await expect(prompt.locator('[data-slot="user-message-text"]')).toHaveCSS("transition-duration", "0s")
expect(mock.prompts).toEqual([])
commit.release()
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual(["steer while the model commits"])
await expect(prompt).toHaveCount(1)
await expect(prompt.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true")
})
test("a steer into an idle session leaves the finished turn finished until delivery", async ({ page }) => {
const mock = createQueueMock(
[],
[
{ id: "msg_queue_finished_user", type: "user", text: "Inspect the queue.", time: { created: 1700000000000 } },
{
id: "msg_queue_finished_assistant",
type: "assistant",
agent: "build",
model: { id: "queue-model", providerID: "opencode" },
content: [{ type: "text", text: "The queue is fine." }],
finish: "stop",
time: { created: 1700000000001, completed: 1700000000002 },
},
{ id: "msg_queue_finished_idle", type: "idle", outcome: "succeeded", time: { created: 1700000000003 } },
],
)
const view = await openQueue(page, mock, "steer", undefined, "idle")
const finished = page.getByRole("button", { name: "Copy response" })
await expect(finished).toHaveCount(1)
await view.input.fill("start the next turn")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual(["start the next turn"])
await expect(
page.locator('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]', {
hasText: "start the next turn",
}),
).toBeVisible()
// The admitted steer waits for delivery; the finished turn above it keeps its copy action and duration.
await expect(finished).toHaveCount(1)
})
test("dragging reorders queued prompts", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
mock.rows[0].payload.files = [
@@ -971,10 +1039,23 @@ for (const delivery of ["steer", "queue"] as const) {
})
.toBe(true)
// Working ends the work the model has seen, so the steer it has not seen yet waits below it.
const working = transcript.locator('[data-component="session-working"]')
await expect(tools.or(working).or(pending)).toHaveText([
/^Used\s*2\s*Read, Grep$/,
/Working/,
/U2: Also check the retry path\./,
])
mock.rows.splice(0, 1)
mock.emit("session.inbox.delivered", { sessionID, inboxID })
await expect(thinking).toHaveCount(0)
await expect(pending).toHaveCount(1)
await expect(tools.or(pending).or(working)).toHaveText([
/^Used\s*2\s*Read, Grep$/,
/U2: Also check the retry path\./,
/Working/,
])
if (colors) {
await expect(bubble).toHaveAttribute("data-delivery-marker", "pending")
@@ -83,6 +83,93 @@ test("shows a pending question dock", async ({ page }) => {
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
})
test("replies with predefined choices and the edited custom multiselect answer", async ({ page }) => {
await mockServer(page, {
forms: [
{
id: "frm_question_multiselect",
sessionID,
title: "Questions",
metadata: { kind: "question" },
fields: [
{
key: "q0",
type: "multiselect",
title: "Include",
description: "Which supporting surfaces should remain visible?",
options: [
{ value: "files", label: "Changed files" },
{ value: "terminal", label: "Terminal" },
],
custom: true,
},
],
},
],
})
await page.goto(sessionHref(sessionID))
await expectSessionTitle(page, title)
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
const custom = question.getByRole("checkbox", { name: "Type your own answer…", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
const cancellations: string[] = []
page.on("request", (request) => {
if (
request.method() === "DELETE" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_multiselect`
)
cancellations.push(request.url())
})
await expect(files).not.toBeChecked()
await files.click()
await input.fill("Original custom answer")
await expect(custom).toBeChecked()
await expect(files).toBeChecked()
await terminal.click()
await expect(terminal).toBeChecked()
await expect(custom).toBeChecked()
await expect(files).toBeChecked()
await custom.click()
await expect(custom).not.toBeChecked()
await expect(input).toHaveValue("Original custom answer")
await expect(files).toBeChecked()
await expect(terminal).toBeChecked()
await input.focus()
await expect(custom).not.toBeChecked()
await expect(files).toBeChecked()
await expect(terminal).toBeChecked()
await custom.click()
await expect(custom).toBeChecked()
await expect(input).toHaveValue("Original custom answer")
await expect(files).toBeChecked()
await expect(terminal).toBeChecked()
await input.fill("Updated custom answer")
await input.press("Escape")
await expect(input).not.toBeFocused()
await expect(question.getByRole("button", { name: "Submit", exact: true })).toBeEnabled()
await expect(input).toHaveValue("Updated custom answer")
await expect(custom).toBeChecked()
await expect(files).toBeChecked()
await expect(terminal).toBeChecked()
expect(cancellations).toEqual([])
const reply = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_multiselect/reply`,
)
await question.getByRole("button", { name: "Submit", exact: true }).press("Control+Enter")
expect((await reply).postDataJSON()).toEqual({ answer: { q0: ["files", "terminal", "Updated custom answer"] } })
expect(cancellations).toEqual([])
})
test("shows a pending permission dock", async ({ page }) => {
const replies: { sessionID: string; permissionID: string; body: unknown }[] = []
await mockServer(page, {
+494 -1
View File
@@ -1,7 +1,7 @@
import { expect, test, type Page } from "@playwright/test"
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server"
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"
const directory = "C:/Projects/settings-demo"
@@ -248,6 +248,41 @@ test("project settings open as a nested view that keeps its route", async ({ pag
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
})
// The desktop app reopens its last settings URL on launch, so a crash on this page would repeat on every restart.
test("a project whose folder is gone opens its settings with a notice", async ({ page }) => {
await mockWorkspace(page, {
name: "Settings demo",
directory,
provider: NO_PROVIDER,
sessions: [],
seed: { tabs: [] },
})
await mockMissingLocation(page, directory)
const plugins = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await page.goto(
`/settings?server=${encodeURIComponent(SERVER)}&project=${encodeURIComponent(directory)}&tab=extensions`,
)
expect((await plugins).status()).toBe(404)
const settings = page.getByTestId("settings-screen")
const notice = settings.getByRole("status").filter({ hasText: "Project folder not found" })
await expect(notice).toContainText(directory)
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(
settings.getByRole("tabpanel", { name: "Plugins", exact: true }).getByText("No plugins yet", { exact: true }),
).toBeVisible()
await settings.getByRole("tab", { name: "General", exact: true }).click()
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toBeVisible()
await expect(notice).toContainText(directory)
})
for (const field of [
{
name: "color",
@@ -750,6 +785,464 @@ for (const row of [
})
}
const consoleIntegrations = [
{
id: "opencode",
name: "OpenCode Console",
methods: [
{ id: "device", type: "oauth", label: "OpenCode Console account" },
{ type: "key", label: "API key (service account)" },
],
connections: [],
},
{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] },
]
for (const row of [
{ provider: "OpenCode Console", connect: "Connect OpenCode Console", signIn: "Connecting to OpenCode Console" },
{ provider: "OpenCode Go", connect: "Connect OpenCode Go", signIn: "Connecting to OpenCode Go" },
]) {
test(`providers: ${row.provider} signs in from the Console intro`, async ({ page, context }) => {
const url = "https://auth.example.test/device?user_code=ABCD-EFGH"
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
const { settings } = await open(page, { integrations: consoleIntegrations, onIntegrationOAuth: () => ({ url }) })
// Registered after the mock server so it sees the sign-in request first.
const start = await holdRoute(page, (url) => url.pathname === "/api/integration/opencode/connect/oauth", {
method: "POST",
})
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
await settings
.locator(".settings-provider-row", { hasText: row.provider })
.getByRole("button", { name: "Connect", exact: true })
.click()
const intro = page.getByRole("dialog", { name: row.connect, exact: true })
const container = page.locator('[data-slot="dialog-container"]')
await expect(
intro.getByText("Sign in with your OpenCode Console account to use the available models."),
).toBeVisible()
await expect(intro.getByRole("button", { name: "Use API key", exact: true })).toBeVisible()
expect((await container.boundingBox())?.height).toBe(512)
const popup = page.waitForEvent("popup")
await intro.getByRole("button", { name: "Continue in the browser", exact: true }).click()
await start.arrived
await expect(intro.getByRole("button", { name: "Opening browser…", exact: true })).toBeDisabled()
expect((await container.boundingBox())?.height).toBe(512)
start.release()
await expect(await popup).toHaveURL(url)
const signIn = page.getByRole("dialog", { name: row.signIn, exact: true })
await expect(signIn.getByRole("group", { name: "Device code: ABCD-EFGH", exact: true })).toBeVisible()
await expect(signIn.getByRole("button", { name: "Copy sign-in link", exact: true })).toBeVisible()
await expect(signIn.getByRole("button", { name: "Use API key", exact: true })).toHaveCount(0)
await expect.poll(async () => (await container.boundingBox())?.height).toBeLessThan(512)
})
}
test("providers: the connect dialog lists connected providers and manages their accounts", async ({ page }) => {
const accounts = [
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
{ type: "credential", id: "cred_personal", label: "Personal", method: "key" },
]
const state = { active: "cred_work" }
const activated: string[] = []
const { settings } = await open(page, {
integrations: () => [
{
id: "openai",
name: "OpenAI",
methods: [{ type: "key", label: "API key" }],
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
},
{ id: "anthropic", name: "Anthropic", methods: [{ type: "key", label: "API key" }], connections: [] },
],
onCredentialActivate: (credentialID) => {
activated.push(credentialID)
state.active = credentialID
},
onCredentialUpdate: (credentialID, body) => {
const account = accounts.find((item) => item.id === credentialID)
if (account && body.label) account.label = body.label
},
})
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
await settings.getByRole("button", { name: "Show more providers", exact: true }).click()
const dialog = page.getByRole("dialog")
const openai = dialog.locator('[data-provider-id="openai"]')
const count = openai.locator('[data-component="tag"]')
await expect(count).toHaveText("2")
await expect(count).toHaveAttribute("aria-label", "2 accounts")
await expect(dialog.locator('[data-provider-id="anthropic"]').locator('[data-component="tag"]')).toHaveCount(0)
await openai.click()
await expect(page.getByRole("dialog", { name: "OpenAI", exact: true })).toBeVisible()
const list = dialog.getByRole("radiogroup", { name: "Connected accounts", exact: true })
await expect(list.getByRole("radio", { name: "Work", exact: true })).toHaveAttribute("aria-checked", "true")
// Rows keep their order when the active account changes.
await expect(list.getByRole("radio")).toHaveText(["Personal", "Work"])
await list.getByRole("radio", { name: "Personal", exact: true }).click()
await expect(list.getByRole("radio", { name: "Personal", exact: true })).toHaveAttribute("aria-checked", "true")
await expect(list.getByRole("radio")).toHaveText(["Personal", "Work"])
expect(activated).toEqual(["cred_personal"])
await list.getByRole("button", { name: "Personal options", exact: true }).click()
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const name = list.getByRole("textbox", { name: "Account name", exact: true })
await expect(name).toBeFocused()
await expect(name).toHaveValue("Personal")
await name.fill("Home")
await name.press("Enter")
await expect(list.getByRole("radio")).toHaveText(["Home", "Work"])
await expect(list.getByRole("radio", { name: "Home", exact: true })).toHaveAttribute("aria-checked", "true")
await list.getByRole("button", { name: "Home options", exact: true }).click()
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
await name.fill("Ignored")
await name.press("Escape")
await expect(list.getByRole("radio")).toHaveText(["Home", "Work"])
await expect(page.getByRole("dialog", { name: "OpenAI", exact: true })).toBeVisible()
await list.getByRole("button", { name: "Work options", exact: true }).click()
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
await expect(dialog.getByText("Remove Work?", { exact: true })).toBeVisible()
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/api/credential/cred_work",
)
await dialog.getByRole("button", { name: "Remove", exact: true }).click()
await removed
await dialog.getByRole("button", { name: "Add another account", exact: true }).click()
await expect(dialog.getByPlaceholder("API key")).toBeVisible()
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
await expect(list).toBeVisible()
})
test("providers: adding a Go account uses a Go key, and a lone sign-in starts once", async ({ page, context }) => {
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
const attempts: string[] = []
const { settings } = await open(page, {
integrations: [
...consoleIntegrations.filter((integration) => integration.id !== "opencode-go"),
{
id: "opencode-go",
name: "OpenCode Go",
methods: [{ type: "key" }],
connections: [{ type: "credential", id: "cred_go", label: "Go key", method: "key" }],
},
{
id: "github-copilot",
name: "GitHub Copilot",
methods: [{ id: "device", type: "oauth", label: "GitHub" }],
connections: [{ type: "credential", id: "cred_gh", label: "Octocat", method: "oauth" }],
},
],
onIntegrationOAuth: (input) => {
attempts.push(input.integrationID)
return { url: "https://auth.example.test/device?user_code=GH12-3456" }
},
})
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
await settings.getByRole("button", { name: "Show more providers", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.locator('[data-provider-id="opencode-go"]').click()
await dialog.getByRole("button", { name: "Add another account", exact: true }).click()
await expect(dialog.getByPlaceholder("API key")).toBeVisible()
expect(attempts).toEqual([])
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
await expect(dialog.getByRole("radio", { name: "Go key", exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
await dialog.locator('[data-provider-id="github-copilot"]').click()
const popup = page.waitForEvent("popup")
await dialog.getByRole("button", { name: "Add another account", exact: true }).click()
await popup
await expect.poll(() => attempts).toEqual(["github-copilot"])
expect(attempts).toEqual(["github-copilot"])
})
test("providers: the account options rename and remove the active account", async ({ page }) => {
const account = { type: "credential", id: "cred_work", label: "Work", method: "key" }
const other = { type: "credential", id: "cred_personal", label: "Personal", method: "key" }
const removed: string[] = []
const { settings } = await open(page, {
provider: { all: [{ id: "openai", name: "OpenAI", models: {} }], connected: ["openai"], default: {} },
integrations: () => [
{
id: "openai",
name: "OpenAI",
methods: [{ type: "key", label: "API key" }],
connections: [account, other].filter((item) => !removed.includes(item.id)),
},
],
onCredentialUpdate: (credentialID, body) => {
const target = [account, other].find((item) => item.id === credentialID)
if (target && body.label) target.label = body.label
},
})
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "DELETE" && path.startsWith("/api/credential/")) removed.push(path.split("/").at(-1)!)
})
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
const trigger = settings.getByRole("button", { name: "Manage OpenAI accounts", exact: true })
await expect(trigger).toHaveText("Work")
await trigger.click()
await expect(page.getByRole("menuitemradio", { name: "Work", exact: true })).toHaveAttribute("aria-checked", "true")
await expect(page.getByRole("menuitem", { name: "Add account", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
const options = settings.getByRole("button", { name: "Work options", exact: true })
await expect(options).toHaveCSS("height", "28px")
await options.click()
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const name = settings.getByRole("textbox", { name: "Account name", exact: true })
await expect(name).toBeFocused()
await expect(name).toHaveValue("Work")
await name.fill("Team")
await name.press("Enter")
await expect(trigger).toHaveText("Team")
expect(other.label).toBe("Personal")
await settings.getByRole("button", { name: "Team options", exact: true }).click()
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
await name.fill("Ignored")
await name.press("Escape")
await expect(trigger).toHaveText("Team")
await settings.getByRole("button", { name: "Team options", exact: true }).click()
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
await name.fill("Shared")
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
await expect(trigger).toHaveText("Shared")
await settings.getByRole("button", { name: "Shared options", exact: true }).click()
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
await expect.poll(() => removed).toEqual(["cred_work"])
})
test("providers: switching to a Zen API key does not wait for a Console workspace", async ({ page }) => {
const accounts = [
{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" },
{ type: "credential", id: "cred_zen", label: "Zen key", method: "key" },
]
const state = { active: "cred_console" }
const { settings } = await open(page, {
provider: () =>
state.active === "cred_console"
? {
all: [
{ id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode", models: {} },
{ id: "console-google", integrationID: "opencode", name: "Anomaly / Google", models: {} },
],
connected: ["opencode", "console-google"],
default: {},
}
: {
all: [
{
id: "opencode",
name: "OpenCode Zen",
models: { paid: { id: "paid", name: "Paid", cost: { input: 3 } } },
},
],
connected: ["opencode"],
default: {},
},
integrations: () => [
{
id: "opencode",
name: "OpenCode Console",
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
},
],
onCredentialActivate: (credentialID) => {
state.active = credentialID
},
})
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
const trigger = settings.getByRole("button", { name: "Manage OpenCode Console accounts", exact: true })
await expect(trigger).toHaveText("Anomaly")
await trigger.click()
const catalogReads: string[] = []
page.on("request", (request) => {
if (request.method() === "GET" && new URL(request.url()).pathname === "/api/provider")
catalogReads.push(request.url())
})
await page.getByRole("menuitemradio", { name: "Zen key", exact: true }).click()
await expect(page.getByText("OpenCode Console account switched")).toBeVisible()
// One refresh after the switch; a Console workspace poll would keep reading the catalog.
expect(catalogReads).toHaveLength(1)
})
test("providers: connecting OpenCode Go keeps the Console row in place", async ({ page, context }) => {
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
// After the sign-in the server reloads the workspace, briefly serving only the free catalog.
const state = { authorized: false, pendingReads: 0 }
const workspace = {
all: [
{ id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode", models: {} },
{ id: "console-google", integrationID: "opencode", name: "Anomaly / Google", models: {} },
],
connected: ["opencode", "console-google"],
default: {},
}
const catalog = () => {
if (state.pendingReads > 0) {
state.pendingReads--
return { all: [{ id: "opencode", name: "OpenCode", models: {} }], connected: ["opencode"], default: {} }
}
if (!state.authorized) return workspace
return {
all: [...workspace.all, { id: "opencode-go", integrationID: "opencode-go", name: "OpenCode Go", models: {} }],
connected: [...workspace.connected, "opencode-go"],
default: {},
}
}
const { settings } = await open(page, {
provider: catalog,
integrations: () => [
{
id: "opencode",
name: "OpenCode Console",
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
connections: [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }],
},
{
id: "opencode-go",
name: "OpenCode Go",
methods: [{ type: "key" }],
connections: state.authorized ? [{ type: "credential", id: "cred_go", label: "Anomaly", method: "oauth" }] : [],
},
],
onIntegrationOAuth: () => ({ url: "https://auth.example.test/device?user_code=GOGO-1234" }),
onIntegrationOAuthStatus: () => {
if (!state.authorized) {
state.authorized = true
state.pendingReads = 3
}
return "complete"
},
})
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
const connected = settings.locator('[data-component="connected-providers-section"]')
await expect(connected.getByText("OpenCode Console", { exact: true })).toBeVisible()
await connected.evaluate((element) => {
new MutationObserver(() => {
if (!element.textContent?.includes("OpenCode Console")) element.dataset.flashed = ""
}).observe(element, { childList: true, subtree: true, characterData: true })
})
await settings
.locator(".settings-provider-row", { hasText: "OpenCode Go" })
.getByRole("button", { name: "Connect", exact: true })
.click()
const popup = page.waitForEvent("popup")
await page.getByRole("button", { name: "Continue in the browser", exact: true }).click()
await popup
await expect(page.getByText("OpenCode Go connected", { exact: true })).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(connected.getByText("OpenCode Go", { exact: true })).toBeVisible()
await expect(connected.getByText("OpenCode Console", { exact: true })).toBeVisible()
await expect(connected).not.toHaveAttribute("data-flashed")
})
test("providers: switching Console accounts keeps the list until the new workspace loads", async ({ page }) => {
const accounts = [
{ type: "credential", id: "cred_clara", label: "Clara", method: "oauth" },
{ type: "credential", id: "cred_anomaly", label: "Anomaly", method: "oauth" },
]
// The server loads the new workspace's providers a few catalog reads after the switch.
const state = { active: "cred_clara", pendingReads: 0 }
const workspace = () => accounts.find((account) => account.id === state.active)!.label
const catalog = () => {
if (state.pendingReads > 0) {
state.pendingReads--
return {
all: [{ id: "opencode", name: "OpenCode", models: { free: { id: "free", name: "Free", cost: { input: 0 } } } }],
connected: ["opencode"],
default: {},
}
}
return {
all: [
{ id: "opencode", integrationID: "opencode", name: `${workspace()} / OpenCode`, models: {} },
{ id: "console-google", integrationID: "opencode", name: `${workspace()} / Google`, models: {} },
],
connected: ["opencode", "console-google"],
default: {},
}
}
const { settings } = await open(page, {
provider: catalog,
integrations: () => [
{
id: "opencode",
name: "OpenCode Console",
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
},
],
onCredentialActivate: (credentialID) => {
state.active = credentialID
state.pendingReads = 2
},
})
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
const connected = settings.locator('[data-component="connected-providers-section"]')
const trigger = connected.getByRole("button", { name: "Manage OpenCode Console accounts", exact: true })
await expect(trigger).toHaveText("Clara")
await connected.evaluate((element) => {
new MutationObserver(() => {
if (element.textContent?.includes("No connected providers")) element.dataset.flashed = ""
}).observe(element, { childList: true, subtree: true, characterData: true })
})
await trigger.click()
await page.getByRole("menuitemradio", { name: "Anomaly", exact: true }).click()
await expect(page.getByText("OpenCode Console account switched")).toBeVisible()
await expect(trigger).toHaveText("Anomaly")
await expect(connected).not.toHaveAttribute("data-flashed")
})
test("the add server dialog keeps focus above fullscreen settings", async ({ page }) => {
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
const { settings } = await open(page)
+391 -32
View File
@@ -56,6 +56,71 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
await expectPath(page, sessionHref(b.id))
})
test("inactive session attention dots share the close control without marking the avatar or active tab", async ({
page,
}) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b],
sessionPermissions: {
[b.id]: [
{ id: "permission-tab-b", sessionID: b.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
],
},
})
await page.goto(sessionHref(a.id))
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
const dot = tabB.locator('[data-slot="tab-unread-dot"]')
const close = tabB.getByRole("button", { name: "Close tab", exact: true })
await expect(tabA.locator('[data-slot="tab-title"]')).toHaveText(a.title)
await expect(tabB.locator('[data-slot="tab-title"]')).toHaveText(b.title)
await page.mouse.move(0, 0)
await expect(dot).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
await expect(close).toHaveCSS("opacity", "0")
await expect(tabB.locator('[data-slot="project-avatar-unread-dot"]')).toHaveCount(0)
await expect
.poll(() =>
dot.evaluate((element) => {
const dot = element.getBoundingClientRect()
const slot = element.parentElement!.getBoundingClientRect()
return (
Math.abs(dot.x + dot.width / 2 - slot.x - slot.width / 2) +
Math.abs(dot.y + dot.height / 2 - slot.y - slot.height / 2)
)
}),
)
.toBeLessThan(1)
await tabB.hover()
await expect(dot).toBeHidden()
await expect(close).toHaveCSS("opacity", "1")
await page.mouse.move(0, 0)
await tabB.locator("[data-titlebar-tab-link]").focus()
await page.keyboard.press("Tab")
await expect(close).toBeFocused()
await expect(dot).toBeHidden()
await expect(close).toHaveCSS("opacity", "1")
await close.blur()
await expect(dot).toBeVisible()
await tabB.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(b.id))
await expect(tabB).toHaveAttribute("data-active", "true")
await expect(dot).toHaveCount(0)
await tabA.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(a.id))
await page.mouse.move(0, 0)
await expect(dot).toBeVisible()
await tabB.hover()
await close.click()
await expect(tabB).toHaveCount(0)
await expectPath(page, sessionHref(a.id))
})
test("a tab does not reopen its title editor while a rename is saving", async ({ page }) => {
await mockWorkspace(page, { name: "Tabs", sessions: [a, b] })
await page.goto(sessionHref(a.id))
@@ -97,44 +162,139 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab
await expectPath(page, sessionHref(c.id))
})
test("option arrows move to the previous or next tab, and with shift to the next unread one", async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
sessionPermissions: {
[c.id]: [
{ id: "permission-tab-c", sessionID: c.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
],
},
})
await page.goto(sessionHref(a.id))
await page.mouse.move(0, 0)
await expect(
page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"]) [data-slot="tab-unread-dot"]`),
).toBeVisible()
await page.keyboard.press("Alt+ArrowDown")
await expectPath(page, sessionHref(b.id))
await page.keyboard.press("Alt+ArrowUp")
await expectPath(page, sessionHref(a.id))
await page.keyboard.press("Shift+Alt+ArrowDown")
await expectPath(page, sessionHref(c.id))
})
for (const row of [
{ tabLayout: "horizontal", width: 360 },
{ tabLayout: "vertical", width: 390 },
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
]) {
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
await page.setViewportSize({ width: row.width, height: 720 })
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
test.describe(`mobile drawer (${row.tabLayout})`, () => {
test.use({ hasTouch: row.hasTouch })
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
await page.setViewportSize({ width: row.width, height: 720 })
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
sessionPermissions: {
[b.id]: [
{
id: "permission-mobile-b",
sessionID: b.id,
action: "shell",
resources: ["git status"],
metadata: {},
save: [],
},
],
},
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
})
await page.goto(sessionHref(a.id))
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), row.direction)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-unread-dot"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("width", "20px")
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("height", "20px")
await expect
.poll(() =>
tabB.evaluate((element) => {
const dot = element.querySelector('[data-slot="tab-unread-slot"]')!.getBoundingClientRect()
const close = element.querySelector('[data-slot="tab-close"]')!.getBoundingClientRect()
return dot.right <= close.left || dot.left >= close.right
}),
)
.toBe(true)
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
await expect(tabB.getByRole("button", { name: "Close tab", exact: true })).toHaveCSS("opacity", "1")
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
await expectPath(page, sessionHref(b.id))
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
if (row.tabLayout !== "vertical") return
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
})
await page.goto(sessionHref(a.id))
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
await expectPath(page, sessionHref(b.id))
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
if (row.tabLayout !== "vertical") return
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
})
}
test("icon-only inactive tabs retain their avatar and attention badge instead of showing a close overlay", async ({
page,
}) => {
await mockWorkspace(page, {
name: "CompactTabs",
sessions: [
a,
b,
...Array.from({ length: 30 }, (_, index) => ({ id: `ses_compact_${index}`, title: `Compact ${index}` })),
],
sessionPermissions: {
[b.id]: [
{
id: "permission-compact-b",
sessionID: b.id,
action: "shell",
resources: ["git status"],
metadata: {},
save: [],
},
],
},
})
await page.goto(sessionHref(a.id))
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabB.locator('[data-slot="tab-title"]')).toBeHidden()
await expect(tabB.locator('[data-component="project-avatar-v2"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("width", "6px")
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("height", "6px")
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.hover()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(b.id))
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCount(0)
})
test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 480 })
const directory = "C:/OpenCode/Tabs"
@@ -202,6 +362,141 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
await expectPath(page, sessionHref(b.id))
})
test("vertical tab gaps hover and select the nearest row", async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(sessionHref(a.id))
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const rows = sidebar.locator("[data-titlebar-tab]")
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title])
const upper = await tabB.boundingBox()
const lower = await tabC.boundingBox()
if (!upper || !lower) throw new Error("vertical tabs have no bounding boxes")
expect(lower.y - upper.y - upper.height).toBe(4)
const middle = upper.y + upper.height + 2
// The two halves belong to their nearest row; an exact tie belongs to the next row.
for (const [offset, id] of [
[-1, b.id],
[0, c.id],
] as const) {
await page.mouse.move(upper.x + upper.width / 2, middle + offset)
await expect(sidebar.locator("[data-titlebar-tab]:hover [data-titlebar-tab-link]")).toHaveAttribute(
"href",
sessionHref(id),
)
}
await page.mouse.click(upper.x + upper.width / 2, middle + 1)
await expectPath(page, sessionHref(c.id))
await page.mouse.move(upper.x + upper.width / 2, middle - 1)
await page.mouse.down()
await page.mouse.move(upper.x + upper.width / 2, lower.y + lower.height - 2, { steps: 8 })
await page.mouse.up()
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title, b.title])
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title])
await expectPath(page, sessionHref(c.id))
const list = await sidebar.locator("[data-titlebar-tab-list]").boundingBox()
if (!list) throw new Error("vertical tab list has no bounding box")
for (const [x, y] of [
[list.x + list.width / 2, list.y - 1],
[list.x + list.width / 2, list.y + list.height + 1],
[list.x - 1, list.y + list.height / 2],
[list.x + list.width + 1, list.y + list.height / 2],
]) {
await page.mouse.move(x, y)
await expect(sidebar.locator("[data-titlebar-tab]:hover")).toHaveCount(0)
}
})
test("tab preview opens cold, switches immediately between tabs, and closes outside the strip", async ({ page }) => {
const d = { id: "ses_tab_d", title: "Library" }
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c, d],
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(sessionHref(a.id))
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const rows = sidebar.locator("[data-titlebar-tab]")
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
const preview = page.locator('[data-component="session-tab-popover"]')
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title, d.title])
await page.clock.install()
await page.clock.pauseAt(new Date())
await tabB.hover()
await page.clock.runFor(700)
await expect(preview).toHaveCount(0)
await page.clock.runFor(50)
await expect(preview).toContainText(b.title)
await page.clock.runFor(10_000)
await expect(preview).toContainText(b.title)
const box = await tabB.boundingBox()
if (!box) throw new Error("vertical tab has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 1)
await page.clock.runFor(1000)
await expect(preview).toContainText(b.title)
await tabB.getByRole("button", { name: "Close tab", exact: true }).hover()
await page.clock.runFor(1000)
await expect(preview).toContainText(b.title)
await tabC.hover({ position: { x: box.width - 1, y: box.height / 2 } })
await page.clock.runFor(20)
await expect(preview).toHaveCount(1)
await expect(preview).toContainText(c.title)
await expect(preview).toHaveCSS("pointer-events", "none")
await rows.filter({ has: page.locator(`a[href="${sessionHref(d.id)}"]`) }).hover()
await page.clock.runFor(20)
await expect(preview).toHaveCount(1)
await expect(preview).toContainText(d.title)
const card = await preview.boundingBox()
if (!card) throw new Error("tab preview has no bounding box")
// The card is informational: entering its painted area leaves the tab strip.
await page.mouse.move(card.x + card.width / 2, card.y + card.height / 2)
await page.clock.runFor(20)
await expect(preview).toHaveCount(0)
await tabB.hover()
await page.clock.runFor(800)
await expect(preview).toContainText(b.title)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 20, { steps: 8 })
await page.clock.runFor(1000)
await expect(preview).toHaveCount(0)
await page.mouse.up()
// Removing an open tab must not leave the shared preview state permanently warm.
await tabB.hover()
await page.clock.runFor(800)
await expect(preview).toContainText(b.title)
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
await page.mouse.move(900, 500)
await page.clock.runFor(600)
await tabC.hover()
await page.clock.runFor(700)
await expect(preview).toHaveCount(0)
await page.clock.runFor(50)
await expect(preview).toContainText(c.title)
})
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
const sessionA = session({ id: "ses_server_a", directory: "C:/server-a", title: "Server A session" })
const sessionB = session({ id: "ses_server_b", directory: "/home/server-b", title: "Server B session" })
@@ -533,6 +828,70 @@ test("each session tab shows its own file tab after a switch to a session in ano
}
})
test("tab shortcuts cycle side-panel tabs when the side panel is focused with multiple tabs", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const directory = "C:/OpenCode/SidePanelTabs"
const alpha = { id: "ses_side_tabs_alpha", title: "Side tabs alpha" }
const beta = { id: "ses_side_tabs_beta", title: "Side tabs beta" }
await openSession(page, {
name: "SidePanelTabs",
sessions: [alpha, beta],
fileList: (path) => (path ? [] : ["README.md"].map((file) => fileNode(directory, file))),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
seed: { panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } } },
})
const panel = page.locator("#review-panel")
const reviewTab = panel.getByRole("tab", { name: "Review", exact: true })
const fileTab = panel.getByRole("tab", { name: "README.md", exact: true })
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await panel.getByRole("button", { name: "Open file" }).click()
await panel.getByRole("button", { name: "README.md", exact: true }).click()
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
// Focus outside the side panel still cycles session tabs even when multiple side-panel tabs are open.
await composer.click()
await page.keyboard.press("Control+Tab")
await expectPath(page, sessionHref(beta.id))
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
await expectPath(page, sessionHref(alpha.id))
await expect(fileTab).toHaveAttribute("aria-selected", "true")
// Focus inside the side panel cycles the drawn side-panel tabs and keeps focus inside the side panel.
await panel.getByText("contents:README.md", { exact: true }).click()
await page.keyboard.press("Control+Tab")
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("Control+Tab")
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("Control+Shift+Tab")
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("ControlOrMeta+Alt+ArrowRight")
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
// Once only one side-panel tab remains, the shortcut returns to cycling session tabs.
await page.keyboard.press("Control+Tab")
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ControlOrMeta+w")
await expect(fileTab).toHaveCount(0)
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await reviewTab.click()
await page.keyboard.press("Control+Tab")
await expectPath(page, sessionHref(beta.id))
})
// Windows has no native menu bar: the titlebar menu owns Paste, which only the desktop edit action can perform.
test("the Windows titlebar menu pastes through the desktop edit action", async ({ page }) => {
await mockWorkspace(page, { name: "WindowsMenu", sessions: [] })
@@ -151,6 +151,23 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
})
test("focuses the terminal opened from the command palette", async ({ page }) => {
const { editor } = await openSession(page, workspace)
const terminal = page.locator('[data-component="terminal"]')
await editor.click()
await page.keyboard.press("ControlOrMeta+Shift+P")
const palette = page.getByRole("dialog")
const search = palette.getByRole("textbox")
await expect(search).toBeFocused()
await search.fill("Toggle terminal")
await expect(palette.getByRole("option", { name: /^Toggle terminal/ })).toHaveAttribute("aria-selected", "true")
await search.press("Enter")
await expect(palette).toHaveCount(0)
await expect(terminal.locator("textarea")).toHaveCount(1)
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
})
test("closing the last restored terminal does not create a hidden replacement", async ({ page }) => {
const cached = { id: "pty_cached", title: "Terminal 1" }
@@ -3,6 +3,7 @@ import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
historyMessages,
partUpdated,
renderedPartID,
sessionID,
@@ -12,6 +13,7 @@ import {
toolPart,
userMessage,
userText,
validateTimelineMessages,
waitForVisualSettle,
} from "../utils/timeline"
import {
@@ -459,12 +461,22 @@ for (const outline of [
for (const failed of [false, true]) {
test(`keeps a submitted prompt in place until Working shows${failed ? " after an error" : ""}`, async ({ page }) => {
await setupTimeline(page, {
seedHistory: true,
messages: [
userMessage(),
...(failed
? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }]
: []),
sessionMessages: [
...validateTimelineMessages([
...historyMessages(18),
userMessage(),
...(failed
? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }]
: []),
]),
// The previous execution ended at an idle marker, so the submitted prompt starts a turn instead of
// waiting as a steer behind that one.
{
id: "msg_1002_timeline_idle",
type: "idle",
outcome: failed ? "failed" : "succeeded",
time: { created: 1700000003000 },
},
],
})
const working = page.locator('[data-component="session-working"]')
@@ -494,7 +506,7 @@ for (const failed of [false, true]) {
// Records the prompt row in every frame from submission until Working has shown.
const observation = await page.evaluateHandle((text) => {
const frames: { prompt?: number; working: boolean }[] = []
const frames: { prompt: number | undefined; working: boolean }[] = []
let frame = 0
const sample = () => {
@@ -503,7 +515,7 @@ for (const failed of [false, true]) {
)
frames.push({
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
prompt: prompt?.getBoundingClientRect().y,
working: !!document.querySelector('[data-component="session-working"]'),
})
frame = requestAnimationFrame(sample)
+13
View File
@@ -125,6 +125,19 @@ const Group = HttpApiGroup.make("mock")
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.post("credentialActivate", "/api/credential/:credentialID/activate", {
params: { credentialID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.patch("credentialUpdate", "/api/credential/:credentialID", {
params: { credentialID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
params: { credentialID: Schema.String },
+57 -11
View File
@@ -44,7 +44,11 @@ export type MockFileContent =
export interface MockServerConfig {
server?: string
provider: Resolvable<MockProviderCatalog>
integrations?: unknown[]
integrations?: Resolvable<unknown[]>
// Runs on POST /api/credential/:id/activate; the fixture swaps its integrations and catalog to the new account.
onCredentialActivate?: (credentialID: string) => void
// Runs on PATCH /api/credential/:id with the request body; the fixture applies the new label.
onCredentialUpdate?: (credentialID: string, body: { label?: string }) => void
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
// Terminal shells the settings offer (`/api/config/shell`).
shells?: unknown[]
@@ -103,6 +107,8 @@ export interface MockServerConfig {
// Starts an OAuth attempt (POST .../connect/oauth) and returns its authorization URL; the attempt then stays pending.
// Without it, OAuth connects answer 501 MockUnsupported.
onIntegrationOAuth?: (input: { integrationID: string; directory: string; body: unknown }) => { url: string }
// Answers status polls for an OAuth attempt; without it every attempt stays pending.
onIntegrationOAuthStatus?: (attemptID: string) => "pending" | "complete"
plugins?: Resolvable<unknown[]>
skills?: Resolvable<unknown[]>
// Replaces the `/api/worktree` inventory, which defaults to the directory plus project sandboxes.
@@ -470,6 +476,21 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return { server, pty: transport.pty, push }
}
// Answers every Location-scoped request for `directory` as the server does once that folder is deleted.
export function mockMissingLocation(page: Page, directory: string) {
return page.route(
(url) => url.pathname.startsWith("/api/") && url.searchParams.get("location[directory]") === directory,
(route) =>
route.fulfill({
status: 404,
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
headers: { "access-control-allow-origin": "*" },
}),
)
}
// Mocks several servers on one page. Each origin gets its own handler and its own SSE transport for events.
export async function mockServers(page: Page, servers: Record<string, Omit<MockServerConfig, "server" | "events">>) {
return Object.fromEntries(
@@ -758,9 +779,15 @@ function mockHandlers(
Effect.gen(function* () {
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
const content = Predicate.isString(value) ? value : (value?.content ?? "")
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
if (Predicate.isString(value)) return HttpServerResponse.uint8Array(new TextEncoder().encode(value))
// Like the server, stream base64 content as the bytes it encodes.
return HttpServerResponse.uint8Array(
value?.encoding === "base64"
? Buffer.from(value.content, "base64")
: new TextEncoder().encode(value?.content ?? ""),
)
}),
)
// Raw, so a hook's answer can replace the status and body.
@@ -843,19 +870,20 @@ function mockHandlers(
model: () => Effect.succeed({ location: location(config), data: currentModels(resolve(config.provider)) }),
modelDefault: () =>
Effect.succeed({ location: location(config), data: currentDefaultModel(resolve(config.provider)) }),
integrationList: () => Effect.succeed({ location: location(config), data: config.integrations ?? [] }),
integrationList: () =>
Effect.sync(() => ({ location: location(config), data: resolve(config.integrations ?? []) })),
integrationGet: (ctx) =>
Effect.succeed({
Effect.sync(() => ({
location: location(config),
data: config.integrations
?.filter(Predicate.isObject)
data: resolve(config.integrations ?? [])
.filter(Predicate.isObject)
.find((integration) => integration.id === ctx.params.integrationID) ?? {
id: ctx.params.integrationID,
name: ctx.params.integrationID,
methods: [{ type: "key", label: "API key" }],
connections: [],
},
}),
})),
integrationConnect: (ctx) =>
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
Effect.andThen(noContent),
@@ -892,9 +920,18 @@ function mockHandlers(
return Effect.succeed({
location: location(config, requestDirectory(config, ctx.request)),
data: { status: "pending", time: { created, expires: created + 600_000 } },
data: {
status: config.onIntegrationOAuthStatus?.(ctx.params.attemptID) ?? "pending",
time: { created, expires: created + 600_000 },
},
})
}),
credentialActivate: (ctx) =>
Effect.sync(() => config.onCredentialActivate?.(ctx.params.credentialID)).pipe(Effect.andThen(noContent)),
credentialUpdate: (ctx) =>
Effect.sync(() => config.onCredentialUpdate?.(ctx.params.credentialID, ctx.payload)).pipe(
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: (ctx) =>
Effect.sync(() => ({
@@ -1398,6 +1435,8 @@ function currentProviders(catalog: MockProviderCatalog) {
.map((provider) => ({
id: provider.id,
name: provider.name,
canonical: provider.canonical,
integrationID: provider.integrationID,
package: provider.id,
activation: connected.has(provider.id) ? "enabled" : "auto",
}))
@@ -1471,7 +1510,7 @@ export function currentSession(session: MockSession, fallbackDirectory?: string)
parentID: session.parentID,
projectID: session.projectID ?? "project",
agent: session.agent ?? "build",
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
model: session.model,
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...decodeSessionOutcome(session),
@@ -1529,7 +1568,14 @@ const decodePromptPayload = Schema.decodeUnknownOption(
}),
)
const decodeProvider = Schema.decodeUnknownOption(Schema.Struct({ id: Schema.String, name: Schema.String }))
const decodeProvider = Schema.decodeUnknownOption(
Schema.Struct({
id: Schema.String,
name: Schema.String,
canonical: lenient(Schema.String),
integrationID: lenient(Schema.String),
}),
)
const decodeModelProvider = Schema.decodeUnknownOption(
Schema.Struct({ id: Schema.String, models: Schema.Record(Schema.String, Schema.Unknown) }),
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.26",
"version": "2.0.29",
"description": "",
"type": "module",
"exports": {
@@ -19,9 +19,9 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:unit": "bun test --conditions=solid --only-failures --isolate --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --isolate --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --isolate --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:built": "PLAYWRIGHT_BUILD=1 playwright test",
"test:e2e:local": "playwright test",
+3 -2
View File
@@ -3,6 +3,7 @@ import type { SessionMessageUser } from "@opencode/client/promise"
import type { Accessor } from "solid-js"
import type { ModelSelection } from "@/providers/models/selection"
import type { ServerSDK } from "@/runtime/server/client"
import type { SessionMessageHandoff } from "@/session/handoff"
import type { ComposerStateTarget } from "./submission-state"
import type { createComposerSubmission } from "./submission-state"
@@ -51,8 +52,8 @@ export type ComposerQueue = {
export type ComposerSession = {
id: string
directory: string
handoff?: {
set: (message: SessionMessageUser) => void
handoff: {
set: (handoff: SessionMessageHandoff) => void
clear: (messageID: string) => void
}
api: {
+2 -23
View File
@@ -3,7 +3,6 @@ import { useLanguage } from "@/runtime/i18n/language"
import { DialogSelectModel } from "@/providers/models/select-dialog"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "./editor/dom"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionOwnership } from "@/session/session-ownership"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -35,28 +34,8 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
}
const chooseModel = () => {
const owner = sessionOwnership.capture()
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
const selection = window.getSelection()
const cursor =
editor && selection?.rangeCount && editor.contains(selection.anchorNode) ? getCursorPosition(editor) : null
const restoreComposer = () => {
// Kobalte restores focus during its teardown effect; defer past it so the
// composer keeps focus and the caret returns to where the user left it.
requestAnimationFrame(() => {
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
if (!editor) return
editor.focus()
if (cursor !== null) setCursorPosition(editor, cursor)
})
}
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
sessionOwnership.capture().run(() => {
void dialog.show(() => <DialogSelectModel model={model} />)
})
}
+2 -15
View File
@@ -1,19 +1,6 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import type { LineComment } from "./comments"
let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
const mod = await import("./comments")
createCommentSessionForTest = mod.createCommentSessionForTest
})
import { createCommentSessionForTest, type LineComment } from "./comments"
function line(file: string, id: string, time: number): LineComment {
return {
+68 -12
View File
@@ -1,8 +1,14 @@
import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode/client/promise"
import type { SessionMessageHandoff } from "@/session/handoff"
import { Skill } from "@opencode/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import type {
ActiveComposerAdapter,
ComposerControls,
ComposerDelivery,
ComposerSession,
NewSessionComposerAdapter,
} from "./adapter"
import { createMemoryComposerState, type Prompt } from "./state"
import { createComposerSubmit } from "./submit"
import type { ComposerStateTarget } from "./submission-state"
@@ -75,12 +81,14 @@ function submitInput(
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,
delivery: ComposerDelivery = "steer",
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
clientCommand,
delivery: () => delivery,
editor: () => undefined,
queueScroll() {},
addToHistory: (prompt) =>
@@ -110,7 +118,7 @@ function session(input: {
return {
id: "session-1",
directory: "C:/repo",
handoff: input.handoff,
handoff: input.handoff ?? { set() {}, clear() {} },
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
@@ -140,8 +148,11 @@ function session(input: {
location: { command: { list: () => [] } },
session: {
setStatus: (_sessionID, status) => input.statuses?.push(status),
// Like the data layer: admit locally, prepare, then send.
prompt: async (value) => {
input.calls.push("prompt")
input.calls.push("admit")
await value.prepare?.()
input.calls.push("send")
await input.prompt?.(value)
// The admitted inbox item, as the server returns it.
@@ -192,16 +203,21 @@ describe("Composer submission", () => {
})
test.each([
{ current: { agent: "plan", model: { id: "old", providerID: "old" } }, calls: ["switch-agent", "switch-model"] },
// The prompt admits before the selection commits, so a steer shows as pending while it does.
{
current: { agent: "plan", model: { id: "old", providerID: "old" } },
calls: ["admit", "switch-agent", "switch-model", "send"],
},
// The model still commits: cached session state may lag behind an earlier switch.
{
current: { agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } },
calls: ["switch-model"],
calls: ["admit", "switch-model", "send"],
},
// A prompt commits a staged revert, which would delete the model switch made after its boundary.
// A prompt commits a staged revert, which would delete the model switch made after its boundary. The revert
// settles before admission, whose row it would also delete.
{
current: { agent: "plan", model: { id: "old", providerID: "old" }, revert: { messageID: "msg_reverted" } },
calls: ["switch-agent", "revert-commit", "switch-model"],
calls: ["switch-agent", "revert-commit", "switch-model", "admit", "send"],
},
])("applies the selection before sending one captured value: $calls", async (row) => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
@@ -213,7 +229,7 @@ describe("Composer submission", () => {
await submitInput(active(state, target)).submit(new Event("submit"))
const request = await admitted.promise
expect(calls).toEqual([...row.calls, "prompt"])
expect(calls).toEqual([...row.calls])
expect(request.delivery).toBe("steer")
expect(request.text).toBe("ship it")
expect(request.id).toMatch(/^msg_/)
@@ -229,6 +245,42 @@ describe("Composer submission", () => {
expect(state.context.items()).toEqual([])
})
test.each(["steer", "queue"] as const)(
"shows a %s in the session as the composer clears, before its selection commits",
async (delivery) => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
const committed = Promise.withResolvers<void>()
const handoffs: SessionMessageHandoff[] = []
const statuses: ("idle" | "running")[] = []
const target = session({
calls: [],
statuses,
handoff: { set: (handoff) => handoffs.push(handoff), clear() {} },
switchModel: async () => committed.promise,
})
await submitInput(active(state, target), undefined, "normal", undefined, undefined, undefined, delivery).submit(
new Event("submit"),
)
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(statuses).toEqual(["running"])
// A queued prompt shows in the queue once admitted, not in the transcript.
expect(handoffs).toMatchObject(
delivery === "steer"
? [
{
message: { type: "user", text: "ship it" },
selection: { agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } },
},
]
: [],
)
committed.resolve()
},
)
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
@@ -345,12 +397,13 @@ describe("Composer submission", () => {
state.set(prompt)
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
const attempts: string[] = []
const calls: string[] = []
const statuses: ("idle" | "running")[] = []
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const target = session({
calls: [],
calls,
statuses,
prompt: async (value) => {
attempts.push(value.id ?? "")
@@ -374,6 +427,9 @@ describe("Composer submission", () => {
expect(attempts).toHaveLength(4)
expect(new Set(attempts).size).toBe(1)
// A retried admission reuses its submission's selection commit.
const submitted = ["admit", "switch-agent", "switch-model", "send", "admit", "send"]
expect(calls).toEqual([...submitted, ...submitted])
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toEqual(prompt)
expect(state.context.items()).toMatchObject([
@@ -461,7 +517,7 @@ describe("Composer submission", () => {
]
draft.set(prompt)
const handedOff = Promise.withResolvers<SessionMessageUser>()
const handedOff = Promise.withResolvers<SessionMessageHandoff>()
const admitted = Promise.withResolvers<void>()
const cleanup = Promise.withResolvers<void>()
@@ -481,7 +537,7 @@ describe("Composer submission", () => {
await submitted
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(await handedOff.promise).toMatchObject({
expect((await handedOff.promise).message).toMatchObject({
type: "user",
text: "",
files: [
+29 -14
View File
@@ -146,16 +146,25 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const restore = () => restoreSubmission(input, submission, value, comments)
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
// The timeline shows a steer, and any switches it makes, as the composer clears; a queued prompt shows in
// the queue once admitted.
if (value.delivery === "steer")
session.handoff.set({
message: handoffMessage(value),
selection: {
agent: value.selection.agent,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
},
})
const optimisticBusy = !input.adapter.working()
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
}).then(
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -485,7 +494,6 @@ async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
@@ -500,8 +508,16 @@ async function sendPrompt(
if (session.current()?.revert) await session.api.revert.commit({ sessionID: session.id })
}
if (value.delivery === "steer") await applySelection(session, value.selection, track, settle)
else await settle()
const commit = () => (value.delivery === "steer" ? applySelection(session, value.selection, track, settle) : settle())
// The data layer admits the prompt locally before it commits the selection, so a steer shows as pending while the
// selection commits. A staged revert settles before admission instead: its committed echo removes every local row
// from its boundary on. Only an idle session stages one, and a prompt into it starts work rather than showing as
// pending.
const staged = !!session.current()?.revert
if (staged) await commit()
// A retried admission reuses the first commit rather than switching again.
let committed: Promise<void> | undefined
const admission = {
id: value.id,
@@ -523,11 +539,10 @@ async function sendPrompt(
agent: value.selection.agent,
model: selectionModel(value.selection),
},
prepare: staged ? undefined : () => (committed ??= commit()),
}
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
onAdmit()
await sending
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
}
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
@@ -559,7 +574,7 @@ function failSubmission(
) {
if (messageID && session.admitted(messageID)) return
if (messageID) session.handoff?.clear(messageID)
if (messageID) session.handoff.clear(messageID)
rollback?.()
restore()
input.notify.failed(kind, error)
@@ -149,16 +149,14 @@ export function createHomeProjectsController(home: HomeController) {
project: project.worktree,
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
hasUnread: (conn: ServerConnection.Any, project: LocalProject) => {
const notification = global.ensureServerCtx(conn).notification
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
return directories(project).some((directory) => notification.project.hasUnread(directory))
},
clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => {
const notification = global.ensureServerCtx(conn).notification
directories(project)
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
directories(project).forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (authenticate(conn, (next) => choose(next))) return
+1 -1
View File
@@ -20,7 +20,7 @@ export function HomeProjects(props: {
projectsForServer={props.projects.server.projects}
collapsed={props.projects.server.collapsed}
canRevealProject={props.projects.project.canReveal}
unseenCount={props.projects.project.unseenCount}
hasUnread={props.projects.project.hasUnread}
onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose}
onFocusServer={props.projects.server.focus}
+4 -4
View File
@@ -53,7 +53,7 @@ export type HomeProjectsViewProps = {
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean
canRevealProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
hasUnread: (server: ServerConnection.Any, project: LocalProject) => boolean
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
onFocusServer: (server: ServerConnection.Any) => void
@@ -571,7 +571,7 @@ function HomeProjectSlot(
selected={
props.selection.server === ServerConnection.key(props.server) && props.selection.directory === props.worktree
}
unseen={props.unseenCount(props.server, project())}
unread={props.hasUnread(props.server, project())}
/>
)
}
@@ -649,7 +649,7 @@ function HomeProjectRow(
index: number
serverSelected: boolean
selected: boolean
unseen: number
unread: boolean
},
) {
const platform = usePlatform()
@@ -782,7 +782,7 @@ function HomeProjectRow(
</Menu.Item>
</Show>
<Menu.Item
disabled={props.unseen === 0}
disabled={!props.unread}
onSelect={() => props.onClearNotifications(props.server, props.project)}
>
{props.language.t("sidebar.project.clearNotifications")}
@@ -437,6 +437,8 @@ export function HomeSessionStatusController(props: {
() => props.server,
() => props.record.session.id,
() => true,
// Home rows can come from the fetched index before the session is loaded.
() => props.record.session,
)
return props.render({
@@ -1,6 +1,5 @@
import { Predicate } from "effect"
import { base64Encode } from "@opencode/util/encode"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Session } from "@opencode/schema/session"
import { startTransition } from "solid-js"
import type { NewSessionComposerAdapter } from "@/composer/adapter"
@@ -10,13 +9,13 @@ import { createComposerProjectControls } from "./project/controller"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
import { useData, useServer } from "@/runtime/server/current"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useWorkspaceLocation } from "@/workspaces/location"
import { createWorktree } from "@/workspaces/create"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
import { createSessionMessageHandoff } from "@/session/handoff"
import type { DraftMcpControls } from "./mcp"
export function createNewSessionComposerAdapter(props: {
@@ -161,7 +160,7 @@ export function createNewSessionComposerAdapter(props: {
session: {
id: created.id,
directory: sessionDirectory,
handoff: createMessageHandoff(sessionKey, created.id, serverSDK.event),
handoff: createSessionMessageHandoff(sessionKey, created.id, serverSDK.event),
api: {
command: (input) => afterCreation(() => serverSDK.api.session.command(input)),
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
@@ -196,28 +195,6 @@ export function createNewSessionComposerAdapter(props: {
}
}
function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["event"]) {
let unsubscribe: VoidFunction | undefined
return {
set(message: SessionMessageUser) {
unsubscribe?.()
setSessionMessageHandoff(key, message)
unsubscribe = event.on("session.inbox.enqueued", (item) => {
if (item.data.sessionID !== sessionID || item.data.inboxID !== message.id) return
unsubscribe?.()
unsubscribe = undefined
clearSessionMessageHandoff(key, message.id)
})
},
clear(messageID: string) {
unsubscribe?.()
unsubscribe = undefined
clearSessionMessageHandoff(key, messageID)
},
}
}
async function resolveSessionDirectory(input: {
projectDirectory: string
worktree: string
+21 -10
View File
@@ -1,5 +1,5 @@
import { Navigate, useSearchParams } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { createMemo, ErrorBoundary, Show, type ParentProps } from "solid-js"
import { CommentsProvider } from "@/composer/comments"
import { FileProvider } from "@/workspaces/files/model"
import { useGlobal } from "@/runtime/server/runtime"
@@ -12,6 +12,7 @@ import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import NewSession from "@/new-session/screen"
import { IncompatibleServerPanel } from "@/session/incompatible-server-panel"
import { ErrorPage } from "@/shell/errors/error"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
export function DraftRoute() {
@@ -67,15 +68,25 @@ function ResolvedDraftContent(props: { draft: DraftTab }) {
</SessionRouteFrame>
}
>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
<ErrorBoundary
fallback={(error) => (
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<ErrorPage error={error} />
</SessionPanelFrame>
</SessionRouteFrame>
)}
>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
</ErrorBoundary>
</Show>
)
}
@@ -62,8 +62,8 @@ export function createProviderConnectionController(options: {
keyProvider?: () => string
directory: () => string | undefined
onComplete: () => void
/** Picks the method to start without asking when the integration exposes several. */
autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined
/** Skips auto-selecting a lone method; the caller starts it. */
manual?: () => boolean
/** Runs after the catalogs refresh; returning false keeps the dialog on a retryable error. */
prepare?: (active: () => boolean) => Promise<boolean>
pollInterval?: number
@@ -148,12 +148,9 @@ export function createProviderConnectionController(options: {
)
const autoIndex = createMemo(() => {
if (integration.loading) return undefined
const values = methods()
if (integration.loading || options.manual?.()) return undefined
if (values.length === 1) return 0
return options.autoSelect?.(values)
return methods().length === 1 ? 0 : undefined
})
type Action =
+267 -84
View File
@@ -4,6 +4,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { List } from "@opencode/ui/list"
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
import { Loader } from "@opencode/ui/loader"
import { Spinner } from "@opencode/ui/spinner"
import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
@@ -23,7 +24,6 @@ import {
} from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
@@ -43,7 +43,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema } from "effect"
import {
CONSOLE_INTEGRATION,
CONSOLE_PROVIDERS,
consoleIntegration,
createProviderConnectionController,
@@ -53,6 +52,11 @@ import {
import { ConsoleAuthorization } from "./console"
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
import { authServerName, RemoteAuthNotice } from "./remote"
import { providerAccounts, type ProviderAccount } from "@/settings/providers/accounts"
import { AccountNameInput } from "@/settings/providers/account-name"
import { useProviderAccountActions } from "@/settings/providers/account-actions"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import "./models.css"
const CUSTOM_ID = "_custom"
@@ -89,10 +93,12 @@ export const DialogConnectProvider: Component<{
modelProvider?: { id: string; name: string }
authorization: boolean
chatgptWelcome: boolean
picked: boolean
}>({
completed: false,
authorization: false,
chatgptWelcome: false,
picked: false,
})
const language = useLanguage()
@@ -112,6 +118,7 @@ export const DialogConnectProvider: Component<{
const select = (provider?: string) => {
back.current = reset
setState("picked", true)
controller.select(provider)
}
@@ -128,6 +135,7 @@ export const DialogConnectProvider: Component<{
{(provider) => (
<ProviderConnection
provider={provider}
accounts={state.picked}
directory={props.directory}
defaultLocation={props.defaultLocation}
onBack={reset}
@@ -137,8 +145,7 @@ export const DialogConnectProvider: Component<{
onConnected={(methodID) => {
props.onConnected?.(provider)
if (provider === "openai" && methodID === "chatgpt-token-sharing")
setState("chatgptWelcome", true)
if (provider === "openai" && methodID === "chatgpt-token-sharing") setState("chatgptWelcome", true)
}}
onFirstConnection={(provider) => setState("modelProvider", provider)}
onAuthorization={(authorization) => setState("authorization", authorization)}
@@ -177,7 +184,8 @@ export const DialogConnectProvider: Component<{
}}
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
classList={{
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": consoleSelected() && !state.modelProvider,
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3":
consoleSelected() && !state.modelProvider,
"[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider,
}}
>
@@ -232,29 +240,11 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
// Only a stored credential hides a provider: environment and config connections can still be
// replaced by a sign-in. OpenCode Zen stays until a Console account (not a key) is connected.
const consoleAccount = createMemo(() =>
integrations
.list()
.find((integration) => integration.id === CONSOLE_INTEGRATION)
?.connections.some((connection) => connection.type === "credential" && connection.method === "oauth"),
)
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [
custom(),
...integrations
.list()
.filter((integration) =>
integration.id === CONSOLE_INTEGRATION
? !consoleAccount()
: !integration.connections.some((connection) => connection.type === "credential"),
),
]
const values = [custom(), ...integrations.list()]
if (!query) return values
@@ -280,6 +270,10 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
)
const rows = createMemo(() => [...popular(), ...other()])
const accountCount = (provider: { id: string }) =>
providerAccounts(integrations.list().find((integration) => integration.id === provider.id)).length
let picker: HTMLDivElement | undefined
let search: HTMLInputElement | undefined
@@ -356,7 +350,18 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
>
<ProviderModelIcon provider={provider} class="shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
<Show when={CONSOLE_PROVIDERS.has(provider.id)}>
<Show when={accountCount(provider) > 0}>
<Badge
variant="subtle"
class="shrink-0"
aria-label={language.plural("dialog.provider.accounts.count", accountCount(provider), {
count: accountCount(provider),
})}
>
{accountCount(provider)}
</Badge>
</Show>
<Show when={CONSOLE_PROVIDERS.has(provider.id) && accountCount(provider) === 0}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
@@ -409,6 +414,7 @@ function ProviderConnection(props: {
onConnected?: (methodID?: string) => void
onFirstConnection: (provider: { id: string; name: string }) => void
onAuthorization: (authorization: boolean) => void
accounts?: boolean
}) {
const dialog = useDialog()
const params = useParams()
@@ -424,6 +430,7 @@ function ProviderConnection(props: {
const location = () => (initialDirectory ? { directory: initialDirectory } : undefined)
const providers = useProviders(directory)
const integrations = useIntegrations(directory)
const accountActions = useProviderAccountActions(directory)
const integrationID = consoleIntegration(props.provider)
const isConsole = CONSOLE_PROVIDERS.has(props.provider)
const remote = isConsole && authServerName(sdk.server) !== undefined
@@ -437,7 +444,15 @@ function ProviderConnection(props: {
catalogPending: boolean
selectedModel: string
collapsed: Record<string, boolean>
adding: boolean
direct: boolean
confirming?: string
switching?: string
activating?: string
renaming?: string
}>({
adding: false,
direct: false,
copied: false,
copyFailed: false,
models: false,
@@ -448,18 +463,23 @@ function ProviderConnection(props: {
collapsed: {},
})
// Sorted by name so rows stay put when the server moves the active account first.
const serverAccounts = () =>
providerAccounts(integrations.list().find((integration) => integration.id === props.provider))
const accounts = () => serverAccounts().toSorted((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
const activeAccount = () => state.activating ?? serverAccounts()[0]?.id
const showAccounts = () => !!props.accounts && !state.adding && accounts().length > 0
const controller = createProviderConnectionController({
provider: () => integrationID,
// A Go service-account key still belongs to the `opencode-go` integration (zen/go/v1),
// exactly as before; only the sign-in is shared with the Console.
keyProvider: () => props.provider,
directory,
autoSelect: (methods) => {
if (!isConsole) return undefined
const index = methods.findIndex((method) => method.type === "oauth")
return index === -1 ? undefined : index
},
manual: () => isConsole || (!!props.accounts && accounts().length > 0),
prepare: isConsole ? prepareConsoleCatalog : undefined,
pollInterval: isConsole ? 500 : undefined,
onComplete: () => {
@@ -467,9 +487,7 @@ function ProviderConnection(props: {
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
// The picker only lists the newest model per family by default, which hides most of
// what a new connection just unlocked. Show everything the connected integration offers.
global.models.show(
connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })),
)
global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })))
if (state.catalogPending) {
setState("noModels", true)
@@ -593,7 +611,9 @@ function ProviderConnection(props: {
})
createEffect(() => {
const current = controller.auth.state()
props.onAuthorization(controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"))
props.onAuthorization(
controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"),
)
})
const provider = createMemo(() => ({
@@ -646,13 +666,17 @@ function ProviderConnection(props: {
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
// The Console device flow owns the dialog from the first frame until the catalogs are loaded.
const consoleSignIn = () =>
isConsole &&
!state.noModels &&
controller.currentMethod()?.type !== "key" &&
controller.currentMethod()?.type === "oauth" &&
controller.auth.state() !== "error" &&
(controller.busy() || controller.authorization()?.mode === "auto")
controller.authorization()?.mode === "auto"
const consoleStarting = () => controller.currentMethod()?.type === "oauth" && controller.auth.state() === "pending"
const consoleIntro = () =>
isConsole && !controller.loading() && (controller.methodIndex() === undefined || consoleStarting())
function AuthFormView() {
const defaults = providerFormDefaults(controller.currentMethod()?.form)
@@ -889,15 +913,19 @@ function ProviderConnection(props: {
}
function goBack() {
// The API key path for the Console is an escape hatch below the sign-in flow, so
// "back" returns to the sign-in rather than leaving the provider.
if (isConsole && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
void controller.auth.select(oauthIndex())
if (
props.accounts &&
state.adding &&
accounts().length > 0 &&
(state.direct || controller.methodIndex() === undefined)
) {
controller.auth.reset()
setState({ adding: false, direct: false })
return
}
if (!isConsole && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
if ((isConsole || controller.methods().length > 1) && controller.methodIndex() !== undefined) {
controller.auth.reset()
return
@@ -952,7 +980,7 @@ function ProviderConnection(props: {
function ErrorRow() {
return (
<div class="flex flex-col items-start gap-3">
<div class="flex flex-col items-start" classList={{ "gap-3": !isConsole, "gap-4": isConsole }}>
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
<span role="alert">
@@ -961,8 +989,8 @@ function ProviderConnection(props: {
: language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
</span>
</div>
<Button variant="neutral" onClick={() => void controller.auth.retry()}>
{language.t("common.retry")}
<Button variant={isConsole ? undefined : "neutral"} onClick={() => void controller.auth.retry()}>
{language.t(isConsole ? "provider.connect.console.retry" : "common.retry")}
</Button>
</div>
)
@@ -1001,21 +1029,15 @@ function ProviderConnection(props: {
}
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={isConsole}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div>
{language.t("provider.connect.console.apiKey.description")}{" "}
<ExternalLink
href="https://opencode.ai/console"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.console.apiKey.link")}
</ExternalLink>
</div>
</Show>
<div
class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
classList={{ "pb-1": isConsole }}
>
<p>
{isConsole
? language.t("provider.connect.console.serviceKeyDescription")
: language.t("provider.connect.apiKey.description", { provider: provider().name })}
</p>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
@@ -1040,7 +1062,12 @@ function ProviderConnection(props: {
</div>
)}
</Show>
<Button type="submit" variant="contrast" data-action="provider-connect-submit">
<Button
type="submit"
variant="contrast"
class={isConsole ? "!px-3" : undefined}
data-action="provider-connect-submit"
>
{language.t("common.continue")}
</Button>
</form>
@@ -1143,22 +1170,170 @@ function ProviderConnection(props: {
)
}
// Deliberately quiet: most people should never need a key, so this stays small and at the bottom.
function ConsoleApiKeySwitch() {
const addAccount = () => {
setState({ adding: true, direct: false, confirming: undefined })
// Go signs in through the Console, which would add a Console account, so Go adds a key instead.
const index =
props.provider === "opencode-go" ? keyIndex() : !isConsole && controller.methods().length === 1 ? 0 : -1
if (index === -1) return
setState("direct", true)
void controller.auth.select(index)
}
const activateAccount = async (account: ProviderAccount) => {
if (activeAccount() === account.id || state.switching) return
setState({ switching: account.id, activating: account.id, confirming: undefined })
await accountActions.activate({ integrationID: props.provider, provider: provider().name, account })
setState({ switching: undefined, activating: undefined })
}
const removeAccount = async (account: ProviderAccount) => {
setState({ switching: account.id, confirming: undefined })
await accountActions.remove({
integrationID: props.provider,
provider: provider().name,
account,
final: accounts().length === 1,
})
setState("switching", undefined)
}
function AccountsView() {
return (
<div data-component="console-service-account" class="flex h-7 items-center gap-1 px-3 pt-5 text-[13px]">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
<Button
variant="ghost-muted"
data-action="provider-connect-api-key"
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.useApiKey")}
<div data-component="provider-accounts" class="flex flex-col gap-3">
<section class="flex flex-col">
<div class="px-3 pb-2 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted">
{language.t("dialog.provider.accounts.connected")}
</div>
<div role="radiogroup" aria-label={language.t("dialog.provider.accounts.connected")} class="flex flex-col">
<For each={accounts()}>
{(account) => (
<Show
when={state.confirming !== account.id}
fallback={
<div class="flex min-h-9 items-center gap-2 rounded-md px-3 text-[13px] leading-5 tracking-[-0.04px]">
<span role="alert" class="min-w-0 flex-1 truncate font-[440] text-v2-text-text-base">
{language.t("dialog.provider.accounts.removeConfirm", { account: account.label })}
</span>
<Button variant="ghost-muted" onClick={() => setState("confirming", undefined)}>
{language.t("common.cancel")}
</Button>
<Button variant="neutral" onClick={() => void removeAccount(account)}>
{language.t("dialog.provider.accounts.remove")}
</Button>
</div>
}
>
<div class="group relative flex min-h-9 items-center rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
<Show
when={state.renaming !== account.id}
fallback={
<div class="flex min-h-9 min-w-0 flex-1 items-center px-3">
<AccountNameInput
value={account.label}
class="-ms-1 max-w-full text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
onSave={(label) => accountActions.rename(account, label)}
onClose={() => setState("renaming", undefined)}
/>
</div>
}
>
<button
type="button"
role="radio"
aria-checked={activeAccount() === account.id}
class="flex min-h-9 min-w-0 flex-1 items-center gap-2 px-3 text-left text-[13px] leading-5 tracking-[-0.04px] focus-visible:outline-none"
disabled={state.switching !== undefined}
onClick={() => void activateAccount(account)}
>
<span class="min-w-0 truncate font-[440] text-v2-text-text-base">{account.label}</span>
<Show when={activeAccount() === account.id}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</Show>
<div class="flex shrink-0 items-center pe-1.5">
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<Icon name="outline-dots" />}
disabled={state.switching !== undefined}
aria-label={language.t("dialog.provider.accounts.options", { account: account.label })}
/>
{/* The modal dialog aria-hides anything added outside it, this portal included, a frame
after it opens. Kobalte leaves a node marked as a top layer visible. */}
<Menu.Portal
ref={(el) => {
el.dataset.reactAriaTopLayer = "true"
}}
>
<Menu.Content onCloseAutoFocus={(event) => state.renaming && event.preventDefault()}>
<Menu.Item onSelect={() => setState({ renaming: account.id, confirming: undefined })}>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => setState({ confirming: account.id, renaming: undefined })}>
{language.t("dialog.provider.accounts.remove")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</div>
</Show>
)}
</For>
</div>
</section>
<Button class="-mt-1 ms-2.5 self-start !px-3" disabled={state.switching !== undefined} onClick={addAccount}>
{language.t("dialog.provider.accounts.add")}
</Button>
</div>
)
}
function ConsoleIntro() {
return (
<div class="flex flex-col items-start gap-5 px-3">
<p class="text-[13px] leading-5 text-v2-text-text-muted">{language.t("provider.connect.console.intro")}</p>
<Button
size="large"
class="!px-3"
variant="contrast"
disabled={oauthIndex() === -1 || consoleStarting()}
aria-busy={consoleStarting()}
onClick={() => void controller.auth.select(oauthIndex())}
>
<Show when={consoleStarting()}>
<span class="absolute inset-0 flex items-center justify-center gap-1.5">
<Loader />
<span>{language.t("provider.connect.console.openingBrowser")}</span>
</span>
</Show>
<span aria-hidden={consoleStarting() ? "true" : undefined} classList={{ "opacity-0": consoleStarting() }}>
{language.t("provider.connect.console.continue")}
</span>
</Button>
<Show when={keyIndex() !== -1}>
<div data-component="console-service-account" class="flex h-7 items-center gap-1 text-[13px]">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
<Button
variant="ghost-muted"
data-action="provider-connect-api-key"
disabled={consoleStarting()}
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.useApiKey")}
</Button>
</div>
</Show>
</div>
)
}
function ConsoleNoModels() {
return (
<div role="status" class="flex flex-col items-start gap-5 px-3 text-[13px] leading-5 text-v2-text-text-muted">
@@ -1352,7 +1527,17 @@ function ProviderConnection(props: {
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
<DialogTitle>
<Switch>
<Match when={consoleSignIn()}>{language.t("provider.connect.console.title")}</Match>
<Match when={showAccounts()}>{provider().name}</Match>
<Match when={isConsole && controller.auth.state() === "error"}>
{language.t("provider.connect.opencode.errorTitle")}
</Match>
<Match when={consoleSignIn()}>
{language.t(
props.provider === "opencode-go"
? "provider.connect.console.titleGo"
: "provider.connect.console.title",
)}
</Match>
<Match
when={
props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")
@@ -1360,6 +1545,9 @@ function ProviderConnection(props: {
>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={props.provider === "opencode"}>
{language.t("provider.connect.title", { provider: language.t("provider.connect.opencode.name") })}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</DialogTitle>
@@ -1390,6 +1578,12 @@ function ProviderConnection(props: {
/>
</div>
</Match>
<Match when={showAccounts() && controller.methodIndex() === undefined}>
<AccountsView />
</Match>
<Match when={consoleIntro()}>
<ConsoleIntro />
</Match>
<Match when={controller.busy()}>
<div class="px-3">
<StatusRow>{language.t("provider.connect.status.inProgress")}</StatusRow>
@@ -1416,17 +1610,6 @@ function ProviderConnection(props: {
<OAuthAutoView />
</Match>
</Switch>
<Show
when={
isConsole &&
!controller.loading() &&
!state.noModels &&
controller.currentMethod()?.type !== "key" &&
keyIndex() !== -1
}
>
<ConsoleApiKeySwitch />
</Show>
</div>
</div>
</Show>
@@ -56,9 +56,7 @@
}
@media (hover: hover) {
[data-component="first-provider-models"]
.provider-model-groups--dialog
.provider-model-group-trigger:not(:disabled):hover {
[data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group-header:hover {
background-color: var(--v2-background-bg-layer-03);
}
}
@@ -58,6 +58,7 @@ function SelectorStory(props: { plan: boolean }) {
{models.find((model) => model.id === current())?.name}
</Button>
)}
all={() => models}
models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))}
groups={(items) => [{ category: "openai", items }]}
current={`openai:${current()}`}
@@ -30,6 +30,7 @@ import {
ProviderModelIcon,
ProviderModelSections,
} from "@/providers/models/provider-group"
import { consoleProviderName } from "@/providers/catalog/console"
import "@/settings/settings.css"
import "./select-dialog.css"
@@ -299,6 +300,7 @@ export function ModelSelectorPopover(props: {
fallback={
<ModelSelectorPopoverView
trigger={props.trigger}
all={controller.all}
models={controller.models}
groups={controller.groups}
current={controller.current()}
@@ -494,6 +496,7 @@ function createModelSelectorController(input: {
export function ModelSelectorPopoverView(props: {
trigger: ModelSelectorTrigger
all: () => ModelItem[]
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: string | undefined
@@ -512,6 +515,21 @@ export function ModelSelectorPopoverView(props: {
const groups = createMemo(() => props.groups(models()))
const keys = () => [...groups().flatMap((group) => group.items.map(modelKey)), manageKey]
const managed = createMemo(() => consoleModelGroup(props.all()))
const managedIDs = createMemo(() => new Set(managed()?.providers.map((provider) => provider.id)))
const canonical = (provider: ModelItem["provider"]) => provider.canonical ?? provider.id.replace(/^console-/, "")
// The workspace badge only disambiguates a managed provider that is also connected directly.
const direct = createMemo(
() =>
new Set(
props
.all()
.filter((item) => !managedIDs().has(item.provider.id))
.map((item) => canonical(item.provider)),
),
)
const initialActive = () => {
const selected = props.current
const options = keys()
@@ -677,8 +695,26 @@ export function ModelSelectorPopoverView(props: {
<For each={groups()}>
{(group) => (
<Menu.Group>
<Menu.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
<Menu.GroupLabel
class="gap-1.5 pe-3"
style={{ "padding-inline-start": group.items[0].provider.id === "opencode" ? "10px" : "12px" }}
>
<ProviderModelIcon provider={group.items[0].provider} class="shrink-0" />
<Show
when={managedIDs().has(group.category) ? managed() : undefined}
fallback={<span class="min-w-0 truncate">{group.items[0].provider.name}</span>}
>
{(workspace) => (
<>
<span class="min-w-0 truncate">
{consoleProviderName(workspace(), group.items[0].provider.name)}
</span>
<Show when={direct().has(canonical(group.items[0].provider))}>
<Badge class="shrink-0">{workspace().workspace}</Badge>
</Show>
</>
)}
</Show>
</Menu.GroupLabel>
<Menu.RadioGroup value={props.current}>
<For each={group.items}>
@@ -269,19 +269,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const current = createMemo(() => {
if (!configuredModel.ready()) return
const item = firstModel(
() => scope()?.model,
() => {
const session = durable()
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const model = session?.model
return model && { providerID: model.providerID, modelID: model.id }
},
() => agent.current()?.model,
fallback,
)
// Catalog availability must not replace a user's choice with a default on the next submission.
const item =
scope()?.model ??
(session?.model && (!session.agent || session.agent === agent.current()?.name)
? { providerID: session.model.providerID, modelID: session.model.id }
: undefined) ??
firstModel(() => agent.current()?.model, fallback)
if (!item) return
@@ -1,4 +1,4 @@
import { For, Show, createMemo, onCleanup, type JSX } from "solid-js"
import { For, Show, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createEventListener } from "@solid-primitives/event-listener"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
@@ -75,17 +75,30 @@ export function SideRegion(props: {
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
)
let root: HTMLElement | undefined
let tabList: HTMLDivElement | undefined
let selectionEvent: Event | undefined
createEffect(() => {
if (open()) return
const active = document.activeElement
if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return
active.blur()
})
return (
<Show when={isDesktop() && !!params.id}>
<aside
ref={root}
id="review-panel"
tabIndex={-1}
aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)] outline-none"
classList={{
"h-full shrink-0": !props.stacked,
"h-full min-h-0": props.stacked,
+35 -5
View File
@@ -42,6 +42,10 @@ export const dict = {
"command.palette": "Command palette",
"command.session.new": "New session",
"command.session.previous": "Previous session",
"command.session.next": "Next session",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.file.open": "Open file",
"command.tab.close": "Close tab",
"command.tab.reopenClosed": "Reopen closed tab",
@@ -50,6 +54,7 @@ export const dict = {
"command.input.focus": "Focus input",
"command.fileTree.toggle": "Toggle file tree",
"command.review.toggle": "Toggle review",
"command.sidebar.toggle": "Toggle sidebar",
"command.message.previous": "Previous message",
"command.message.previous.description": "Go to the previous user message",
"command.message.next": "Next message",
@@ -68,6 +73,7 @@ export const dict = {
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Auto-accept permissions",
"command.permissions.autoaccept.disable": "Stop auto-accepting permissions",
"command.permissions.autoaccept.project": "Auto-accept permissions for this project",
"command.session.undo": "Undo",
"command.session.undo.description": "Undo the last message",
"command.session.redo": "Redo",
@@ -117,11 +123,24 @@ export const dict = {
"dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
"dialog.provider.viewAll": "Show more providers",
"dialog.provider.accounts.count.one": "{{count}} account",
"dialog.provider.accounts.count.other": "{{count}} accounts",
"dialog.provider.accounts.add": "Add another account",
"dialog.provider.accounts.connected": "Connected accounts",
"dialog.provider.accounts.removeConfirm": "Remove {{account}}?",
"dialog.provider.accounts.remove": "Remove",
"dialog.provider.accounts.options": "{{account}} options",
"provider.connect.title": "Connect {{provider}}",
"provider.connect.opencode.name": "OpenCode Console",
"provider.connect.opencode.freeName": "OpenCode Free",
"provider.connect.opencode.errorTitle": "Connect to OpenCode",
"provider.connect.console.title": "Connecting to OpenCode Console",
"provider.connect.console.titleGo": "Connecting to OpenCode Go",
"provider.connect.console.intro": "Sign in with your OpenCode Console account to use the available models.",
"provider.connect.console.continue": "Continue in the browser",
"provider.connect.console.retry": "Try again",
"provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.",
"provider.connect.console.instructions":
"Continue in your browser. Confirm the code shown there matches the one below.",
"provider.connect.console.deviceCode": "Device code",
@@ -190,9 +209,6 @@ export const dict = {
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
"provider.connect.oauth.openBrowser": "Open browser",
"provider.connect.oauth.expired": "Authorization expired",
"provider.connect.console.apiKey.description":
"Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.",
"provider.connect.console.apiKey.link": "Open the Console",
"provider.connect.toast.connected.title": "{{provider}} connected",
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
@@ -397,6 +413,9 @@ export const dict = {
"toast.permissions.autoaccept.on.title": "Auto-accepting permissions",
"toast.permissions.autoaccept.on.description": "Permission requests will be automatically approved",
"toast.permissions.autoaccept.project.title": "Auto-accepting permissions for this project",
"toast.permissions.autoaccept.project.description":
"Permission requests in this project will be automatically approved",
"toast.permissions.autoaccept.off.title": "Stopped auto-accepting permissions",
"toast.permissions.autoaccept.off.description": "Permission requests will require approval",
@@ -549,6 +568,7 @@ export const dict = {
"session.messages.jumpToLatest": "Jump to latest",
"session.question.progress": "{{current}} of {{total}} questions",
"session.question.title": "Question",
"session.websearch.title": "Third-party web search",
"session.websearch.description": "Select the search provider agents use to search the web",
"session.websearch.provider": "Search provider",
@@ -720,6 +740,11 @@ export const dict = {
"settings.extensions.manageConfig": "Manage in opencode.json",
"settings.extensions.addSkills": "How to add skills",
"project.settings.title": "Edit project",
"project.settings.autoAccept.description": "Approve this project's permission requests without asking",
"project.settings.autoAccept.default.on": "Use default (On)",
"project.settings.autoAccept.default.off": "Use default (Off)",
"project.settings.autoAccept.on": "On",
"project.settings.autoAccept.off": "Off",
"project.settings.name.title": "Project name",
"project.settings.name.description": "The name shown for this project throughout OpenCode",
"project.settings.icon.description": "Recommended: 128×128px. Click or drag to upload an image.",
@@ -727,6 +752,8 @@ export const dict = {
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
"project.settings.worktree.startup.hint.new": "Use $OPENCODE_WORKTREE_PATH for the new worktree.",
"project.settings.folderMissing.title": "Project folder not found",
"project.settings.folderMissing.description": "Restore the folder or close this project.",
"project.settings.extensions.description": "View extensions available to this project",
"project.settings.extensions.tab.lsps": "LSPs",
"project.settings.extensions.added": "Added to this project",
@@ -770,6 +797,8 @@ export const dict = {
"settings.timeline.description.text-only": "Hide all activity.",
"settings.timeline.description.custom": "Uses advanced settings.",
"settings.timeline.custom": "Custom",
"settings.timeline.row.turnDiffs.title": "Changed files",
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.group": "Group",
@@ -906,8 +935,7 @@ export const dict = {
"settings.providers.account.manage": "Manage {{provider}} accounts",
"settings.providers.account.group": "Accounts",
"settings.providers.account.add": "Add account",
"settings.providers.account.remove": "Remove account…",
"settings.providers.account.active": "Active",
"settings.providers.account.name": "Account name",
"settings.providers.account.switched.title": "{{provider}} account switched",
"settings.providers.account.switched.description": "Now using {{account}}.",
"settings.providers.account.removed.title": "{{account}} removed",
@@ -997,6 +1025,8 @@ export const dict = {
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
"settings.workspaces.delete.blocked.linked":
"Linked sessions will remain, but their working directory will no longer exist.",
"settings.permissions.autoAccept.default.description":
"Approve permission requests without asking. Projects can override this in their settings.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
Loaded 100 of 1123 files, more files were not shown because too many files have changed in this diff. Show more