Compare commits

...
Author SHA1 Message Date
Luke Parker b2cecc6350 fix(desktop): add sidecar credentials from the main process (#47588) 2026-09-06 07:18:12 +00:00
Luke Parker 63a1074c6c fix(app): keep slow git reads from filling the request queue (#47564) 2026-09-06 17:02:03 +10:00
Luke Parker 31ee07e3ae fix(app): pace directory re-sync after reconnect (#47565) 2026-09-06 17:01:24 +10:00
Aarav Sareen 370b9965d3 feat(app): cmd+F to search session with highlighting 2026-09-06 16:47:20 +10:00
Dax Raad 8ea99ef9ad fix(tui): dismiss update notification only on skip 2026-09-06 02:43:21 -04:00
Aarav Sareen e64b2bc137 feat(app): add horizontal file sidebar scrolling + right click menu 2026-09-06 16:35:14 +10:00
opencode-agent[bot]andrekram1-node cf1923c238 fix(core): disable default GPT and Claude search filtering (#47586)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-06 01:22:48 -05:00
Luke Parker bfcb388dd7 feat(app): pulse the status dot while the event stream reconnects (#47574) 2026-09-06 15:13:38 +10:00
usrnk1 ec46439ef5 feat(desktop): simplify move to background action 2026-09-06 14:45:16 +10:00
Luke Parker 1be3b32a47 fix(client): detect stalled event streams and resync on foreground (#47571) 2026-09-06 04:43:50 +00:00
Aiden Cline 2823b886d7 feat(core): add independent GPT and Claude tool optimization (#47559) 2026-09-05 23:43:23 -05:00
Luke Parker 99651b2d50 fix(app): refresh queued inputs when the connection returns (#47573) 2026-09-06 04:42:27 +00:00
usrnk1 33ef66746b feat(desktop): add third-party web search consent 2026-09-06 14:38:55 +10:00
Luke Parker cf212a4235 fix(app): time out requests the server never answers (#47572) 2026-09-06 14:28:43 +10:00
David Hill 768e3d45a2 fix(ui): lighten dark contrast icon buttons 2026-09-06 14:01:47 +10:00
Luke Parker 0674ddf043 fix(client): coalesce catalog refetches from event bursts (#47561) 2026-09-06 03:48:49 +00:00
David Hill 41b66bc560 fix(app): scroll overflowing composer controls 2026-09-06 13:47:42 +10:00
opencode-agent[bot] 49a5baa059 chore: update nix node_modules hashes 2026-09-06 03:39:31 +00:00
Luke Parker 1cf576ab55 fix(desktop): keep server CORS headers so preflights cache (#47560) 2026-09-06 03:26:12 +00:00
David Hill f7d0f69e6c feat(app): add subtle new-session logo shimmer 2026-09-06 13:22:15 +10:00
Aiden Cline a66e6cd55e feat(core): discover Bedrock credentials in the provider plugin (#47548) 2026-09-05 22:20:48 -05:00
David Hill 93b3725b14 fix(app): move project icon setting to experimental 2026-09-06 13:07:37 +10:00
David Hill 371e344ba6 fix(app): widen new-session prompt beyond logo 2026-09-06 13:07:33 +10:00
David Hill 6559dcb01b fix(tui): list OpenCode Go before Zen 2026-09-06 13:07:29 +10:00
David Hill 35dbdfe287 fix(app): simplify vertical tabs footer 2026-09-06 13:07:24 +10:00
opencode-agent[bot] cd504dc66a chore: update nix node_modules hashes 2026-09-06 00:05:05 +00:00
Aiden Cline c4f2926ffb feat(ai): resolve Bedrock credentials through the AWS default chain (#47436) 2026-09-05 18:49:05 -05:00
opencode-agent[bot]andrekram1-node aa48433fa0 fix(core): send session ID with Copilot requests (#47213)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-05 18:42:46 -05:00
Kit Langton 8da7526616 fix(core): reload local plugin helpers without restarting
Reuse the shared dependency-aware plugin loader for Core hot reloads. Scope watcher setup callbacks and subscriptions, recover missing external helpers, and preserve explicit local entrypoint invalidation without reloading package dependencies. Add regression coverage and portable RPC fixtures.
2026-09-05 17:00:41 -04:00
opencode-agent[bot]andrekram1-node 7a4ad68af6 fix(mcp): retry initialization without codemode on HTTP 400 (#47507)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-05 15:49:25 +00:00
opencode-agent[bot]andnexxeln 2960c61f9c fix(tui): preserve parent scroll position across subagents (#47464)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-05 15:19:16 +05:30
opencode-agent[bot]andnexxeln 7ad705225e fix(tui): avoid disconnection error on terminal exit (#47272)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-05 14:35:40 +05:30
opencode-agent[bot]andHona 5045b3ce18 fix(ui): keep file accordion borders visible when scaled (#47461)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-05 08:47:54 +00:00
Aiden Cline 4306c07b34 feat(core): update GPT prompts and remove legacy Anthropic prompt (#47447) 2026-09-05 01:39:18 -05:00
opencode-agent[bot] 7de1e86b5d chore: update nix node_modules hashes 2026-09-05 06:38:20 +00:00
opencode-agent[bot]andrekram1-node a6fd1f8b22 chore: bump Bun to 1.4.2 (#47446)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-05 01:21:42 -05:00
Dax Raad 52685d4517 fix(tui): simplify home update notices 2026-09-05 02:17:09 -04:00
Luke Parker 30d1049942 fix(app): load worktree inventory on demand and cap concurrent server requests (#47441) 2026-09-05 05:25:43 +00:00
Stefan Avramandslickstef11 21adcb4969 fix(console): route enterprise forms to Chatwoot (#47437)
Co-authored-by: slickstef11 <stefanavram22@gmail.com>
2026-09-05 00:05:40 -04:00
Kit Langton c72b535dee fix(tui): reload local plugin dependency graphs (#47388)
Reload statically discovered local plugin dependencies through native module caches while preserving shared package identities and best-effort last-good registrations.
2026-09-04 23:41:02 -04:00
opencode-agent[bot]andjlongster 8d1a9799f4 fix(tui): remove completion notice links (#47426)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-04 22:46:28 -04:00
David Hill 86ba09c6e0 fix(app): increase vertical tabs minimum width (#47424) 2026-09-05 12:10:12 +10:00
opencode-agent[bot]andHona 41cb354c3e fix(app): preserve vertical navigation label and icon widths (#47418)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-05 11:30:12 +10:00
James Long 23f3f8b6ca fix(tui): reference update command (#47415) 2026-09-04 20:43:44 -04:00
Aiden Cline baab05727d fix(core): compare Codex GPT versions by major and minor (#47404) 2026-09-04 19:22:12 -05:00
Aiden Cline 541937d124 fix(core): clarify how to call search in Code Mode guidance (#47360) 2026-09-04 18:50:47 -05:00
David Hill 0da55f1cef fix(app): list OpenCode Go before Zen (#47386) 2026-09-05 09:38:59 +10:00
opencode-agent[bot] ac76382211 chore: update nix node_modules hashes 2026-09-04 23:23:36 +00:00
James Long c30285c148 feat(worktree): support configurable plugin strategies (#47358) 2026-09-04 19:07:40 -04:00
opencode-agent[bot] b274224af1 fix(app): keep pending worktree composer styling consistent (#47377) 2026-09-04 22:59:21 +00:00
opencode-agent[bot] 7a050a19a1 fix(app): apply worktree message color during preparation (#47379) 2026-09-04 22:53:43 +00:00
Luke Parker 16601775f1 fix(app): align desktop worktree location with TUI (#47370) 2026-09-04 22:12:15 +00:00
James Long 0991e8b5a5 fix(tui): clarify update notifications (#47372) 2026-09-04 18:03:12 -04:00
James Long 6d791dfe67 feat(tui): add plugin-owned session panels (#47150) 2026-09-04 17:37:45 -04:00
Dax f268956c75 fix(tui): preview always-allow patterns without extra confirmation (#47369) 2026-09-04 21:34:58 +00:00
James Long 3290a39667 feat(tui): add manual update dialog (#47364) 2026-09-04 17:11:35 -04:00
James Long 51d69b26a0 refactor(tui): support reactive theme contexts (#47148) 2026-09-04 16:42:07 -04:00
James Long 8ff1ef1a62 fix(tui): anchor diff file menus to the screen (#47147) 2026-09-04 16:23:16 -04:00
James Long 90cd910f52 feat(tui): add shared interactivity context (#47144) 2026-09-04 16:07:11 -04:00
Dax Raad c3342ca812 chore: empty commit 2026-09-04 14:23:12 -04:00
Kit Langton 7ca047b2b9 fix(core): prioritize compaction over pending steers (#47340) 2026-09-04 13:23:55 -04:00
opencode-agent[bot]andnexxeln b78c2ea7b4 fix(tui): undo pending prompts and active sessions (#47343)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-04 22:52:03 +05:30
James Long 0e143437c8 feat(cli): restore automatic update policy (#47161) 2026-09-04 12:58:30 -04:00
opencode-agent[bot]andrekram1-node bff58fc387 feat(tui): show retry countdown (#47145)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-04 11:24:04 -05:00
Shoubhit Dash a26a978051 fix(ai): tolerate out-of-band responses notifications (#47316) 2026-09-04 21:36:29 +05:30
opencode-agent[bot]andnexxeln 218a0dde97 fix(tui): clear stale error styling on resumed subagents (#47295)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-04 21:29:27 +05:30
usrnk1 bd54dc508f feat(desktop): add dropzone to new sessions (#47303) 2026-09-04 17:19:05 +02:00
Shoubhit Dash 0b3c45c948 feat(ai): add streamed compaction triggers (#47082) 2026-09-04 14:00:02 +00:00
Shoubhit Dash 689a354a43 fix(ai): preserve websocket replay baselines (#47291) 2026-09-04 13:46:00 +00:00
Shoubhit Dash acb462a173 fix(tui): restore agent and model selection parity (#47260) 2026-09-04 18:16:04 +05:30
opencode-agent[bot] e26cbb935e chore: update nix node_modules hashes 2026-09-04 12:21:18 +00:00
Luke Parker fa6dd9df8f chore: upgrade Bun to 1.4.1 and re-enable bytecode (#47271) 2026-09-04 22:01:12 +10:00
opencode-agent[bot]andBrendonovich b52b79d28f fix(app): align file tool disclosures with Patch (#47269)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 19:47:57 +08:00
Aarav Sareen 632b65854c fix(app): adjust sidebar and settings spacing (#47263) 2026-09-04 09:50:30 +00:00
opencode-agent[bot]andBrendonovich ed877cfebc fix(app): expose skills in slash commands (#47247)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 17:40:39 +08:00
opencode-agent[bot]andBrendonovich 3a08d95907 fix(app): preserve thinking trigger visibility invariant (#47256)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 09:34:18 +00:00
b09a74591c fix(app): omit notices from Used summary (#47245)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-04 09:13:00 +00:00
Brendan Allan e8481973ce refactor(app): make session composer ownership explicit (#47254) 2026-09-04 09:07:47 +00:00
usrnk1 211cd73f1a feat(desktop): update composer dropzone (#42312) 2026-09-04 16:45:42 +08:00
opencode-agent[bot]andBrendonovich 2375e81bd6 fix(app): match optimistic session heading to tab label (#47244)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-04 08:07:24 +00:00
365 changed files with 14808 additions and 3911 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.0
bun-version: 1.4.2
- name: Setup git committer
id: committer
@@ -113,7 +113,7 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: bun-v1.4.0
BUN_COMPILE_RELEASE: bun-v1.4.2
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
+1 -1
View File
@@ -183,7 +183,7 @@ const table = sqliteTable("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.
- 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 delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. 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.
- 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.
+12 -22
View File
@@ -33,6 +33,7 @@
"name": "@opencode-ai/ai",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -338,7 +339,6 @@
"version": "1.18.4",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cohere": "3.0.27",
@@ -350,7 +350,6 @@
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
@@ -695,6 +694,8 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -1065,6 +1066,7 @@
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.2",
"effect": "catalog:",
"solid-js": "catalog:",
},
@@ -1102,7 +1104,7 @@
"@tanstack/solid-virtual": "3.13.37",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13",
"@types/bun": "1.4.0",
"@types/cross-spawn": "6.0.6",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
@@ -1186,7 +1188,7 @@
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -3032,7 +3034,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
@@ -3484,7 +3486,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -5864,6 +5866,8 @@
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
@@ -5872,8 +5876,6 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -5920,9 +5922,9 @@
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6340,10 +6342,6 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
@@ -6386,8 +6384,6 @@
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@@ -6922,8 +6918,6 @@
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
@@ -7250,10 +7244,6 @@
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"ai/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+2
View File
@@ -235,6 +235,7 @@ const bucketNew = 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")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
@@ -263,6 +264,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ENTERPRISE_SALES_INBOX_EMAIL,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
"x86_64-linux": "sha256-bWKV3fV8Yc+3ILaNRChIYajsRcvEG2OkydsD2KMUXCM=",
"aarch64-linux": "sha256-zIavB09LW+BU3GOq0PO7Lqfc3MVchkiOUf3trnAjhRM=",
"aarch64-darwin": "sha256-Jlvz7QsXsb8GS9Ty4TmUIfGOawiueZWWEYmyEPms3dc=",
"x86_64-darwin": "sha256-TzTDtcDtj1c3+eP2WZAHOfkszxIS06J6QUwlnscLaUM="
}
}
+4 -3
View File
@@ -5,9 +5,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",
"packageManager": "bun@1.4.2",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
@@ -43,7 +43,7 @@
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/bun": "1.4.0",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
@@ -159,6 +159,7 @@
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "1.4.2",
"effect": "catalog:",
"solid-js": "catalog:"
},
+38 -1
View File
@@ -247,6 +247,8 @@ it does not repair or truncate them.
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
`testLayer`.
@@ -259,7 +261,7 @@ This is different from prompt caching, server-side history storage, or truncatio
### Explicit compaction
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response. This mechanism does not accept a WebSocket executor.
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
@@ -279,6 +281,41 @@ Generation-only body overlays such as `stream` and `store` are not sent to the c
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
### Streamed checkpoint compaction
OpenAI Responses also exposes a separate, explicitly selected mechanism:
```ts
const result =
yield *
LLMClient.compact(request, {
mechanism: "trigger",
webSocket, // Optional: without it, the request uses HTTP/SSE.
})
result.checkpoint // Successful encrypted CompactionPart.
result.responseID
result.usage
```
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
The result is **not a replacement window**. The caller selects retained history, combines it with `result.checkpoint`, and durably installs it before continuing. The operation does not choose a retention budget, prune messages, or modify the original request.
The supplied WebSocket executor can reuse a compatible append baseline for the compaction request. On completion the protocol supplies no continuation checkpoint, clearing the old baseline so the next generation sends the newly installed window in full. Validation occurs before transport completion is acknowledged. There is no operation-level retry or fallback to `/responses/compact`; existing safe transport fallback may use SSE, with full history and no connection-local response ID.
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
```ts
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
}
```
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
### Advanced: in-band compaction
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
+1
View File
@@ -28,6 +28,7 @@
"typescript": "catalog:"
},
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"@opencode-ai/schema": "workspace:*",
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
responseID = created
return { type: "frame", frame }
}
// Keepalives carry no response state and may arrive before response.created.
if (event.type === "keepalive") return { type: "frame", frame }
// Keepalives and provider notifications carry no response state and may precede response.created.
if (!event.type.startsWith("response.")) return { type: "frame", frame }
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
@@ -42,6 +42,7 @@ const canonical = (value: unknown): string => {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
return `{${Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort()
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
.join(",")}}`
@@ -57,7 +58,12 @@ const comparable = (value: unknown) => {
if (value.type === "message" && value.role === "assistant")
return {
role: "assistant",
content: value.content,
// Annotations and logprobs describe the response, not the text replayed in model input.
content: Array.isArray(value.content)
? value.content.map((part) =>
ProviderShared.isRecord(part) && part.type === "output_text" ? { type: part.type, text: part.text } : part,
)
: value.content,
...(value.phase === undefined ? {} : { phase: value.phase }),
}
if (value.type === "function_call")
@@ -121,7 +127,7 @@ const rejected = (
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
let output: unknown[] = []
let output: OpenResponses.StreamItem[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
@@ -149,6 +155,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
}
if (observation.type !== "completed") return observation
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
if (
Array.isArray(request.input) &&
request.input.some((item) => ProviderShared.isRecord(item) && item.type === "compaction_trigger")
)
return observation
const responseID = event.response?.id
if (!responseID || responseID.trim().length === 0) return observation
return {
@@ -159,7 +171,14 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
version: VERSION,
responseID,
request,
output: event.response?.output ? [...event.response.output] : output.slice(),
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
output: event.response?.output
? event.response.output.map((item) =>
item.type === "reasoning" && item.id !== undefined
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
: item,
)
: output.slice(),
} satisfies CheckpointValue,
},
}
+7 -3
View File
@@ -923,7 +923,7 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
const outputItemID = (state: ParserState, event: Event) =>
const outputItemID = (state: Pick<ParserState, "outputItems">, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
@@ -935,7 +935,11 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
const resolveItem = (
state: Pick<ParserState, "outputItems">,
item: StreamItem,
index: number | undefined,
): OutputItem => ({
...item,
id:
item.id ??
@@ -945,7 +949,7 @@ const resolveItem = (state: ParserState, item: StreamItem, index: number | undef
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
export const normalize = (state: Pick<ParserState, "outputItems">, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
+44 -2
View File
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import type { LLMRequest, JsonSchema, ToolDefinition, ToolEntry } from "../schema/index.js"
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
@@ -13,6 +13,7 @@ import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -114,6 +115,18 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
/** Request control, never conversation content. */
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
),
})
const adapter = {
id: ADAPTER,
name: NAME,
@@ -191,6 +204,35 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
})
})
const checkpointBody = {
schema: CheckpointBody,
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
const overlay = request.http?.body
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
if (
overlay?.input !== undefined ||
overlay?.previous_response_id !== undefined ||
overlay?.conversation !== undefined
)
return yield* ProviderShared.invalidRequest(
"Trigger compaction requires complete canonical history, not an input or continuation override",
)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
...mergeJsonRecords(native, overlay),
input: [...native.input, { type: "compaction_trigger" }],
stream: true,
store: false,
parallel_tool_calls: true,
tool_choice: undefined,
context_management: undefined,
text: undefined,
max_output_tokens: undefined,
max_tool_calls: undefined,
})
}),
}
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) {
@@ -268,7 +310,7 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: ResponsesCompaction.make(adapter),
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
@@ -2,13 +2,12 @@ import { AwsV4Signer } from "aws4fetch"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth, type AuthInput } from "../../route/auth.js"
import { AIError, AuthenticationError } from "../../schema/index.js"
import { ProviderShared } from "../shared.js"
/**
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
* which provider facades configure as route auth instead of SigV4. STS-vended
* credentials should be refreshed by the consumer (rebuild the model) before
* they expire; the route does not refresh.
* which provider facades configure as route auth instead of SigV4.
*/
export interface Credentials {
readonly region: string
@@ -17,6 +16,44 @@ export interface Credentials {
readonly sessionToken?: string
}
/** Static credentials or an effect resolved before every request. */
export type CredentialSource = Credentials | Effect.Effect<Credentials, AIError>
export interface DefaultChainOptions {
readonly region: string
/** Shared config profile passed to the AWS default chain. */
readonly profile?: string
}
/**
* Resolve credentials through the AWS default provider chain: environment
* variables, shared config and SSO caches, web identity tokens, process
* credentials, and container or instance metadata. A fresh chain runs on every
* request so credentials rotated on disk without an expiration (for example
* shared-config keys rewritten by a corporate SSO tool) are always re-read;
* the SDK's own memoization would otherwise pin them for the process lifetime.
*/
export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Credentials, AIError> =>
Effect.tryPromise({
try: async () => {
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
return {
region: options.region,
accessKeyId: identity.accessKeyId,
secretAccessKey: identity.secretAccessKey,
...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }),
}
},
catch: (error) =>
new AIError({
reason: new AuthenticationError({
message: `AWS default credential chain failed: ${ProviderShared.errorText(error)}`,
cause: error,
}),
}),
})
const signRequest = (input: {
readonly url: string
readonly body: string
@@ -48,16 +85,17 @@ const signRequest = (input: {
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (
credentials: Credentials | undefined,
source: CredentialSource | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
Auth.custom((input: AuthInput) => {
return Effect.gen(function* () {
if (!credentials) {
if (!source) {
return yield* ProviderShared.invalidRequest(
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
)
}
const credentials = Effect.isEffect(source) ? yield* source : source
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({
url: input.url,
@@ -74,4 +112,35 @@ export const sigV4 = (
/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
export const auth = sigV4(undefined)
export const resolveRegion = (input: {
readonly region?: string
readonly credentials?: { readonly region: string }
}) =>
input.region ?? input.credentials?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
export interface ResolveAuthInput {
readonly apiKey?: string
readonly credentials?: Credentials
readonly profile?: string
}
export interface ResolveAuthOptions {
readonly service?: string
readonly name?: string
/** `sigv4` ignores an ambient `AWS_BEARER_TOKEN_BEDROCK`; `bearer` is validated by the caller. */
readonly mode?: "bearer" | "sigv4"
}
/**
* Bearer wins over SigV4 and explicit static credentials win over the default
* chain, matching the AWS SDK's own precedence for `AWS_BEARER_TOKEN_BEDROCK`.
* The region is applied to the SigV4 scope so it always matches the endpoint host.
*/
export const resolveAuth = (input: ResolveAuthInput, region: string, options: ResolveAuthOptions = {}) => {
const apiKey = options.mode === "sigv4" ? undefined : (input.apiKey ?? process.env.AWS_BEARER_TOKEN_BEDROCK)
if (apiKey !== undefined) return Auth.bearer(apiKey)
if (input.credentials !== undefined) return sigV4({ ...input.credentials, region }, options)
return sigV4(defaultChain({ region, profile: input.profile }), options)
}
export * as BedrockAuth from "./bedrock-auth.js"
@@ -0,0 +1,122 @@
import { Effect, Schema, Stream } from "effect"
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
import { Protocol } from "../../route/protocol.js"
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
import { OpenResponses } from "../open-responses.js"
import { ProviderShared } from "../shared.js"
interface State {
readonly parser: Pick<OpenResponses.ParserState, "id" | "provider" | "outputItems">
readonly checkpoints: Readonly<Record<string, CompactionCheckpointResponse["checkpoint"]>>
readonly responseID?: string
}
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
state: State,
input: OpenResponses.Event,
) {
const event = OpenResponses.normalize(state.parser, input)
const item = event.item
if (!item) return state
const parser =
event.output_index === undefined || state.parser.outputItems[event.output_index] === item.id
? state.parser
: { ...state.parser, outputItems: { ...state.parser.outputItems, [event.output_index]: item.id } }
const next = parser === state.parser ? state : { ...state, parser }
if (event.type === "response.output_item.added" || item.type !== "compaction") return next
if (
event.output_index !== undefined &&
Object.entries(state.parser.outputItems).some(
([index, id]) => id === item.id && Number(index) !== event.output_index,
)
)
return yield* ProviderShared.eventError(parser.id, "Compaction checkpoint appeared in multiple output slots")
if (!item.encrypted_content)
return yield* ProviderShared.eventError(parser.id, "Compaction output is missing its encrypted content")
const previous = state.checkpoints[item.id]
if (previous && previous.encrypted !== item.encrypted_content)
return yield* ProviderShared.eventError(parser.id, "Compaction output changed after completion")
if (previous) return next
return {
...next,
checkpoints: {
...state.checkpoints,
[item.id]: { type: "compaction", provider: parser.provider, id: item.id, encrypted: item.encrypted_content },
},
} satisfies State
})
/** Collect a trigger response before acknowledging transport completion. No generation output escapes. */
export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
Effect.fn("ResponsesCheckpoint.execute")(function* (request, executor, options) {
const source = request.model.route
let result: CompactionCheckpointResponse | undefined
// Route registries erase the frame type. The codec validates that boundary before parsing.
const event: Schema.Codec<OpenResponses.Event, unknown> = OpenResponses.protocol.stream.event
const protocol = Protocol.make({
id: source.protocol,
body,
stream: {
event,
initial: (request: LLMRequest): State => ({
parser: { id: source.id, provider: request.model.provider, outputItems: {} },
checkpoints: {},
}),
terminal: OpenResponses.terminal,
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
if (event.type === "error" || event.type === "response.failed")
return yield* OpenResponses.providerFailure(event, "Compaction request failed")
if (event.type === "response.incomplete")
return yield* ProviderShared.eventError(source.id, "Compaction response was incomplete")
if (event.type === "response.output_item.added" || event.type === "response.output_item.done")
return [yield* onOutputItem(state, event), []] as const
if (event.type !== "response.completed") return [state, []] as const
const responseID = event.response?.id
if (!responseID?.trim())
return yield* ProviderShared.eventError(source.id, "Compaction response is missing its response ID")
if (event.response?.status !== undefined && event.response.status !== "completed")
return yield* ProviderShared.eventError(source.id, "Compaction response did not complete successfully")
let next = state
for (const [index, item] of (event.response?.output ?? []).entries()) {
next = yield* onOutputItem(next, { type: "response.output_item.done", output_index: index, item })
}
const checkpoints = Object.values(next.checkpoints)
const checkpoint = checkpoints[0]
if (checkpoints.length !== 1 || !checkpoint)
return yield* ProviderShared.eventError(
source.id,
"Compaction response must contain exactly one checkpoint",
)
result = new CompactionCheckpointResponse({
checkpoint,
responseID,
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
})
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
}),
},
})
const route = Route.make({
id: source.id,
provider: source.provider,
providerMetadataKey: source.providerMetadataKey,
protocol,
endpoint: source.endpoint,
auth: source.auth,
transport: source.transport,
})
const native = yield* body.from(request)
// The body builder already applied and validated overlays. Do not let transport reapply them.
const preparedRequest = LLMRequest.update(request, {
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
})
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
return result
})
export * as ResponsesCheckpoint from "./responses-checkpoint.js"
+2 -2
View File
@@ -1,5 +1,5 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactOperation } from "./route/client.js"
import type { CompactionOperations } from "./route/client.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@@ -10,7 +10,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
}
@@ -1,4 +1,3 @@
import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
@@ -10,9 +9,15 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
readonly apiKey?: string
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
readonly credentials?: Credentials
/** Shared config profile for the default credential chain. */
readonly profile?: string
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
@@ -22,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly profile?: string
readonly region?: string
readonly topP?: number
readonly providerOptions?: OpenAIProviderOptionsInput
@@ -47,23 +53,35 @@ const chatRoute = OpenAIChat.route.with({
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
const region = BedrockAuth.resolveRegion(input)
return route.with({
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
auth:
input.apiKey === undefined
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
: Auth.bearer(input.apiKey),
auth: BedrockAuth.resolveAuth(input, region, {
service: "bedrock-mantle",
name: "Bedrock Mantle",
mode: input.auth,
}),
})
}
const defaults = (input: Config) => {
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
const {
apiKey: _,
auth: _auth,
baseURL: _baseURL,
credentials: _credentials,
profile: _profile,
region: _region,
...rest
} = input
return rest
}
export const configure = (input: Config = {}) => {
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
@@ -87,29 +105,26 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
const fromSettings = (settings: Settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
profile: settings.profile,
providerOptions: settings.providerOptions,
region: settings.region,
}
}
})
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
) => fromSettings(settings).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
) => fromSettings(settings).responses(modelID)
export const model = responsesModel
+20 -13
View File
@@ -1,17 +1,23 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
/** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */
readonly apiKey?: string
/** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */
readonly auth?: "bearer" | "sigv4"
readonly headers?: Record<string, string>
/** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */
readonly credentials?: BedrockCredentials
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
/** Shared config profile for the default credential chain. */
readonly profile?: string
/** AWS region. Falls back to `credentials.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then `us-east-1`. */
readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
readonly baseURL?: string
@@ -22,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: BedrockCredentials
readonly profile?: string
readonly region?: string
readonly topP?: number
}
@@ -30,14 +37,17 @@ export const routes = [BedrockConverse.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
const configuredRoute = (input: Config) => {
const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
const resolvedRegion = BedrockAuth.resolveRegion(input)
return BedrockConverse.route.with({
...rest,
provider: id,
providerMetadataKey: "bedrock",
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }),
})
}
@@ -51,18 +61,15 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
return configure({
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
profile: settings.profile,
region: settings.region,
}).model(modelID)
}
+4 -3
View File
@@ -1,7 +1,7 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -39,6 +39,7 @@ export type Settings = ProviderPackage.Settings &
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
const responsesRoute = OpenAIResponses.route.with({
compact: { endpoint: OpenAIResponses.route.compact.endpoint },
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
@@ -102,7 +103,7 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
@@ -168,7 +169,7 @@ const config = (settings: Settings): Config => {
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
CompactOperation
typeof responsesRoute.compact
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
+7 -6
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -73,7 +73,7 @@ const defaults = (input: Config) => {
return rest
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
@@ -132,10 +132,11 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
typeof OpenAIResponses.route.compact
>["model"] = (modelID, settings) => {
return configure(config(settings)).responses(modelID)
}
+7 -6
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
@@ -32,7 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: XAIResponses.compact,
compact: { endpoint: XAIResponses.compact },
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
@@ -103,10 +103,11 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<
Settings,
XAIProviderOptionsInput,
typeof responsesRoute.compact
>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+114 -39
View File
@@ -14,6 +14,7 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
CompactionCheckpointResponse,
AIErrorReason,
GenerationOptions,
HttpOptions,
@@ -38,7 +39,7 @@ export interface RouteBody<Body> {
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
readonly compact: Compact
readonly id: string
@@ -53,7 +54,15 @@ export interface Route<
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
): Route<Body, Prepared, Next>
(
patch: Omit<RoutePatch<Body, Prepared>, "compact"> & { readonly compact?: undefined },
): Route<Body, Prepared, Compact>
(patch: RoutePatch<Body, Prepared>): Route<Body, Prepared>
}
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options, Compact>
@@ -74,7 +83,7 @@ export interface Route<
// Normal call sites use `OpenAIChat.route`; callers only need body types
// when preparing a request with a protocol-specific type assertion.
// oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
export type AnyRoute<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Route<
any,
any,
Compact
@@ -101,6 +110,7 @@ export interface RouteDefaultsInput {
}
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly compact?: CompactionOperations
readonly id?: string
readonly provider?: string | ProviderID
readonly providerMetadataKey?: string
@@ -111,7 +121,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
route: AnyRoute<Compact>,
mapped: RouteMappedLanguageModelInput,
) => {
@@ -162,10 +172,7 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
readonly compact: (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly compact: CompactMethod
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -189,12 +196,64 @@ export type CompactOperation = (
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
export type CompactionRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, CompactOperation>
export type TriggerCompactOperation = (
request: LLMRequest,
executor: RequestExecutor.Interface,
options: TriggerCompactOptions,
) => Effect.Effect<CompactionCheckpointResponse, AIError>
/** Protocol capabilities, not deployment/model eligibility. */
export interface CompactionOperations {
readonly endpoint?: CompactOperation
readonly trigger?: TriggerCompactOperation
}
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
request.model.route.compact !== undefined
export interface EndpointCompactOptions extends Pick<StreamOptions, "http"> {
readonly mechanism?: "endpoint"
readonly webSocket?: never
}
export interface TriggerCompactOptions extends StreamOptions {
readonly mechanism: "trigger"
}
// Keep the required route shape explicit: the schema class's self type erases its model parameter in assignability.
export type CompactionRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, { readonly endpoint: CompactOperation }>
}
export type CheckpointRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, { readonly trigger: TriggerCompactOperation }>
}
export interface CompactMethod<R = never> {
(request: CheckpointRequest, options: TriggerCompactOptions): Effect.Effect<CompactionCheckpointResponse, AIError, R>
(request: CompactionRequest, options?: EndpointCompactOptions): Effect.Effect<CompactionResponse, AIError, R>
}
export function canCompact(
request: LLMRequest,
options?: { readonly mechanism?: "endpoint" },
): request is CompactionRequest
export function canCompact(
request: LLMRequest,
options: { readonly mechanism: "trigger" },
): request is CheckpointRequest
export function canCompact(request: LLMRequest, options?: { readonly mechanism?: string }) {
if (options?.mechanism === "trigger") return request.model.route.compact?.trigger !== undefined
if (options?.mechanism !== undefined && options.mechanism !== "endpoint") return false
return request.model.route.compact?.endpoint !== undefined
}
const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefined) => {
if (mechanism !== undefined && mechanism !== "endpoint" && mechanism !== "trigger")
return ProviderShared.invalidRequest(`Unknown compaction mechanism: ${mechanism}`)
return ProviderShared.unsupportedOperation({
operation: mechanism === "trigger" ? "compact.trigger" : "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support ${mechanism === "trigger" ? "trigger" : "explicit"} compaction`,
})
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -216,7 +275,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
}
export interface MakeInput<Body, Frame, Event, State> {
readonly compact?: CompactOperation
readonly compact?: CompactionOperations
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -238,7 +297,7 @@ export interface MakeInput<Body, Frame, Event, State> {
}
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
readonly compact?: CompactOperation
readonly compact?: CompactionOperations
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -326,9 +385,10 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
...routeInput,
compact: "compact" in patch ? compact : routeInput.compact,
id: id ?? routeInput.id,
provider: provider ?? routeInput.provider,
providerMetadataKey:
@@ -343,7 +403,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@@ -440,12 +500,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
}
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, Prepared, CompactOperation>
export function make<Body, Frame, Event, State>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
export function make<Body, Prepared, Frame, Event, State, Compact extends CompactionOperations>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: Compact },
): Route<Body, Prepared, Compact>
export function make<Body, Frame, Event, State, Compact extends CompactionOperations>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: Compact },
): Route<Body, HttpTransport.HttpPrepared<Frame>, Compact>
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
@@ -561,14 +621,23 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
})
}
export const compact = (
export function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
export function compact(
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
): Effect.Effect<CompactionResponse, AIError, Service> =>
Effect.gen(function* () {
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError, Service>
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.gen(function* () {
const client = yield* Service
return yield* client.compact(request, options)
if (options?.mechanism === "trigger" && canCompact(request, options)) return yield* client.compact(request, options)
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
return yield* client.compact(request, options)
return yield* unsupportedCompaction(request, options?.mechanism)
})
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
@@ -582,21 +651,27 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const stream = streamRequestWith({ http: executor })
function compact(
request: CompactionRequest,
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError>
function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError>
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
if (options?.mechanism === "trigger" && canCompact(request, options))
return request.model.route.compact.trigger(prepareRequest(request), executor, options)
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
return request.model.route.compact.endpoint(prepareRequest(request), executor, options)
return unsupportedCompaction(request, options?.mechanism)
})
}
return Service.of({
stream,
generate: generateWith(stream),
compact: (request, options) =>
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.unsupportedOperation({
operation: "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
})
return operation(prepareRequest(request), executor, options)
}),
compact,
})
}),
)
+6
View File
@@ -9,6 +9,12 @@ export type {
Interface as LLMClientShape,
Service as LLMClientService,
StreamOptions,
CompactMethod,
CompactionOperations,
CompactionRequest,
CheckpointRequest,
EndpointCompactOptions,
TriggerCompactOptions,
} from "./client.js"
export * from "./executor.js"
export { Auth } from "./auth.js"
+15
View File
@@ -97,6 +97,21 @@ export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.Co
usage: Schema.optional(Usage),
}) {}
/** A checkpoint only; retained history and replacement-window construction belong to the caller. */
export class CompactionCheckpointResponse extends Schema.Class<CompactionCheckpointResponse>(
"LLM.CompactionCheckpointResponse",
)({
checkpoint: CompactionPart.pipe(
Schema.refine(
(part): part is CompactionPart & { readonly encrypted: string; readonly text?: never } =>
part.encrypted !== undefined && part.encrypted.length > 0,
{ message: "A checkpoint response requires encrypted compaction content" },
),
),
responseID: Schema.String.check(Schema.isPattern(/\S/)),
usage: Schema.optional(Usage),
}) {}
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
index: Schema.Number,
+8 -8
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute, CompactOperation } from "../route/client.js"
import type { AnyRoute, CompactionOperations } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -175,7 +175,7 @@ export namespace LanguageModelCompatibility {
export class LanguageModel<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
@@ -194,7 +194,7 @@ export class LanguageModel<
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
>(input: LanguageModel.Input<Compact>) {
return new LanguageModel<Options, Compact>({
id: ModelID.make(input.id),
@@ -206,7 +206,7 @@ export class LanguageModel<
})
}
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static input<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
return {
@@ -218,11 +218,11 @@ export class LanguageModel<
}
}
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
model: LanguageModel<Options, Compact>,
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
): LanguageModel<Options, Compact>
@@ -241,7 +241,7 @@ export class LanguageModel<
}
export namespace LanguageModel {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
export type ConstructorInput<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute<Compact>
@@ -249,7 +249,7 @@ export namespace LanguageModel {
readonly compatibility?: LanguageModelCompatibility
}
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
export type Input<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
+39 -11
View File
@@ -1,10 +1,17 @@
export * as TestLLM from "./testing.js"
import { LLMClient } from "./route/client.js"
import {
LLMClient,
type CompactionRequest,
type CheckpointRequest,
type EndpointCompactOptions,
type TriggerCompactOptions,
} from "./route/client.js"
import {
LLMEvent,
LLMResponse,
CompactionResponse,
CompactionCheckpointResponse,
type FinishReasonDetails,
type AIError,
type LLMRequest,
@@ -13,7 +20,11 @@ import {
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
export type Response =
| readonly LLMEvent[]
| Stream.Stream<LLMEvent, AIError>
| CompactionResponse
| CompactionCheckpointResponse
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -132,21 +143,38 @@ const make = (options: LayerOptions) =>
Stream.unwrap(
take(request).pipe(
Effect.map((response) => {
if (response instanceof CompactionResponse)
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
return Stream.die("TestLLM generation requires an event response")
return Stream.isStream(response) ? response : Stream.fromIterable(response)
}),
),
)
const test = Test.of({
compact: (request) =>
take(request).pipe(
Effect.flatMap((response) =>
response instanceof CompactionResponse
function compact(
request: CompactionRequest,
options?: EndpointCompactOptions,
): Effect.Effect<CompactionResponse, AIError>
function compact(
request: CheckpointRequest,
options: TriggerCompactOptions,
): Effect.Effect<CompactionCheckpointResponse, AIError>
function compact(
request: LLMRequest,
options?: EndpointCompactOptions | TriggerCompactOptions,
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
return take(request).pipe(
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
if (options?.mechanism === "trigger")
return response instanceof CompactionCheckpointResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse"),
),
),
: Effect.die("TestLLM trigger compaction requires a CompactionCheckpointResponse")
return response instanceof CompactionResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse")
}),
)
}
const test = Test.of({
compact,
stream,
generate: (request) =>
stream(request).pipe(
+30
View File
@@ -0,0 +1,30 @@
import { Effect } from "effect"
/**
* Run an effect with `process.env` overrides, restoring the previous values
* afterwards. Use for code that reads `process.env` directly, such as AWS SDK
* credential providers, where Effect `ConfigProvider` layers do not apply.
* `undefined` removes a variable for the duration of the effect.
*/
export const withProcessEnv =
(env: Record<string, string | undefined>) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(env).map((name) => [name, process.env[name]]))
apply(env)
return previous
}),
() => effect,
(previous) => Effect.sync(() => apply(previous)),
)
const apply = (env: Record<string, string | undefined>) => {
for (const [name, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[name]
continue
}
process.env[name] = value
}
}
@@ -0,0 +1,129 @@
import { Effect } from "effect"
import {
CompactionCheckpointResponse,
CompactionResponse,
LanguageModel,
LLM,
LLMClient,
LLMRequest,
} from "../../src/index.js"
import {
Anthropic,
Azure,
AmazonBedrock,
AmazonBedrockMantle,
OpenAI,
OpenAICompatibleResponses,
XAI,
} from "../../src/providers.js"
import type { WebSocketChannelExecutor } from "../../src/route.js"
import type { RoutePatch } from "../../src/route/client.js"
import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js"
import type { Prepared } from "../../src/protocols/open-responses-channel.js"
declare const webSocket: WebSocketChannelExecutor
const model = OpenAI.configure().responses("fixture")
const request = LLM.request({ model, prompt: "hello" })
LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse))
LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe(
Effect.map((result) => {
result satisfies CompactionCheckpointResponse
result.checkpoint.encrypted satisfies string
result.responseID satisfies string
// @ts-expect-error A trigger does not return replacement history.
result.replacement
}),
)
// @ts-expect-error Endpoint compaction does not accept a WebSocket executor.
LLMClient.compact(request, { mechanism: "endpoint", webSocket })
// @ts-expect-error Omitting mechanism selects the HTTP endpoint.
LLMClient.compact(request, { webSocket })
// @ts-expect-error Unknown mechanisms do not have a permissive fallback overload.
LLMClient.compact(request, { mechanism: "other" })
for (const selected of [
model,
OpenAI.model("fixture", {}),
model.route.with({ headers: { fixture: "test" } }).model({ id: "fixture" }),
LanguageModel.make(LanguageModel.input(model)),
LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }),
]) {
LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" })
}
LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" })
LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" })
const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture")
const xai = XAI.configure().responses("fixture")
// @ts-expect-error Azure must not inherit OpenAI's trigger operation.
LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" })
LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), {
// @ts-expect-error Azure's package entrypoint must preserve its narrower capability.
mechanism: "trigger",
})
// @ts-expect-error xAI endpoint support does not imply trigger support.
LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" })
// @ts-expect-error xAI's package entrypoint must preserve its narrower capability.
LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" })
const unsupported = {
bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
openai: LLM.request({ model: OpenAI.configure().chat("fixture") }),
azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }),
xai: LLM.request({ model: XAI.configure().chat("fixture") }),
compatible: LLM.request({
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
}),
}
// @ts-expect-error Bedrock does not expose trigger compaction.
LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" })
// @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol.
LLMClient.compact(unsupported.mantle, { mechanism: "trigger" })
// @ts-expect-error Anthropic does not expose trigger compaction.
LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" })
// @ts-expect-error OpenAI Chat does not expose trigger compaction.
LLMClient.compact(unsupported.openai, { mechanism: "trigger" })
// @ts-expect-error Azure Chat does not expose trigger compaction.
LLMClient.compact(unsupported.azure, { mechanism: "trigger" })
// @ts-expect-error xAI Chat does not expose trigger compaction.
LLMClient.compact(unsupported.xai, { mechanism: "trigger" })
// @ts-expect-error Generic protocol compatibility does not grant trigger support.
LLMClient.compact(unsupported.compatible, { mechanism: "trigger" })
// @ts-expect-error Changing the model replaces its capability.
LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" })
// @ts-expect-error Changing the route replaces its capability.
LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" })
LLMClient.compact(
LLM.request({
model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }),
}),
// @ts-expect-error Replacing route operations does not retain the old trigger capability.
{ mechanism: "trigger" },
)
declare const dynamic: LLMRequest
declare const patch: Partial<LLMRequest.Input>
declare const routePatch: RoutePatch<OpenAIResponsesBody, Prepared>
// @ts-expect-error A dynamic operation override cannot preserve trigger support.
LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), {
mechanism: "trigger",
})
// @ts-expect-error Explicitly removing operations removes trigger support.
LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), {
mechanism: "trigger",
})
// @ts-expect-error Dynamic models must be narrowed.
LLMClient.compact(dynamic, { mechanism: "trigger" })
if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) {
LLMClient.compact(dynamic, { mechanism: "trigger" })
LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" }))
}
if (LLMClient.canCompact(dynamic)) {
LLMClient.compact(dynamic)
// @ts-expect-error Endpoint narrowing does not grant trigger support.
LLMClient.compact(dynamic, { mechanism: "trigger" })
}
// @ts-expect-error A dynamic model override cannot preserve trigger support.
LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" })
@@ -2,9 +2,11 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect, Encoding, Ref, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
CacheHint,
GenerationOptions,
type LanguageModel,
LLM,
LLMEvent,
LLMRequest,
@@ -18,7 +20,8 @@ import { compileRequest } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers.js"
import * as BedrockConverse from "../../src/protocols/bedrock-converse.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { withProcessEnv } from "../lib/env.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import {
eventSummary,
expectWeatherToolLoop,
@@ -95,6 +98,41 @@ const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
)
// Clears every input the AWS default chain reads so tests do not pick up the
// developer's profiles, SSO cache, or instance metadata.
const noAmbientAWS = {
AWS_ACCESS_KEY_ID: undefined,
AWS_SECRET_ACCESS_KEY: undefined,
AWS_SESSION_TOKEN: undefined,
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_PROFILE: undefined,
AWS_REGION: undefined,
AWS_DEFAULT_REGION: undefined,
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
AWS_EC2_METADATA_DISABLED: "true",
}
const captureHeaders = (target: LanguageModel) =>
Effect.gen(function* () {
const seen: Array<Headers> = []
yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: target })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push(request.headers)
return input.respond(eventStreamBody(["messageStop", { stopReason: "end_turn" }]), {
headers: { "content-type": "application/vnd.amazon.eventstream" },
})
}),
),
),
)
return seen[0]!
})
const model = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
@@ -1311,35 +1349,137 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects requests with no auth path", () =>
it.effect("fails with an authentication error when the default chain cannot resolve credentials", () =>
Effect.gen(function* () {
const unsignedModel = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
profile: "opencode-test-missing-profile",
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
Effect.flip,
)
expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
}),
expect(error.reason._tag).toBe("Authentication")
expect(error.message).toContain("AWS default credential chain failed")
}).pipe(withProcessEnv(noAmbientAWS)),
)
it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
it.effect("signs with static credentials using the configured region for the SigV4 scope", () =>
Effect.gen(function* () {
const signed = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
region: "eu-west-1",
credentials: {
region: "us-east-1",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
const headers = yield* captureHeaders(signed)
expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed)
}),
expect(headers.get("authorization")).toContain("Credential=AKIAIOSFODNN7EXAMPLE/")
expect(headers.get("authorization")).toContain("/eu-west-1/bedrock/aws4_request")
expect(headers.get("x-amz-security-token")).toBeNull()
}).pipe(withProcessEnv(noAmbientAWS)),
)
it.effect("resolves SigV4 credentials through the AWS default chain on every request", () =>
Effect.gen(function* () {
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const headers = yield* captureHeaders(chained)
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/us-west-2/bedrock/aws4_request")
expect(headers.get("x-amz-security-token")).toBe("chain-session-token")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
AWS_SESSION_TOKEN: "chain-session-token",
}),
),
)
it.effect("re-resolves rotated chain credentials on the next request without rebuilding the model", () =>
Effect.gen(function* () {
const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const first = yield* captureHeaders(chained)
const second = yield* captureHeaders(chained).pipe(
withProcessEnv({ AWS_ACCESS_KEY_ID: "AKIAROTATEDEXAMPLE", AWS_SECRET_ACCESS_KEY: "rotated-secret" }),
)
expect(first.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(second.get("authorization")).toContain("Credential=AKIAROTATEDEXAMPLE/")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
}),
),
)
it.effect("prefers AWS_BEARER_TOKEN_BEDROCK over the credential chain", () =>
Effect.gen(function* () {
const bearer = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const headers = yield* captureHeaders(bearer)
expect(headers.get("authorization")).toBe("Bearer env-bearer-token")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
}),
),
)
it.effect("auth: sigv4 ignores an ambient bearer token from configure and settings", () =>
Effect.gen(function* () {
const configured = AmazonBedrock.configure({ auth: "sigv4", baseURL: "https://bedrock-runtime.test" }).model(
"anthropic.claude-3-5-sonnet-20240620-v1:0",
)
const fromSettings = AmazonBedrock.model("anthropic.claude-3-5-sonnet-20240620-v1:0", {
auth: "sigv4",
baseURL: "https://bedrock-runtime.test",
})
for (const target of [configured, fromSettings]) {
const headers = yield* captureHeaders(target)
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
}
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
}).pipe(
withProcessEnv({
...noAmbientAWS,
AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token",
AWS_REGION: "ap-southeast-2",
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
}),
),
)
it.effect("derives the endpoint region from AWS_REGION then AWS_DEFAULT_REGION", () =>
Effect.gen(function* () {
const fromRegion = AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
expect(fromRegion.route.endpoint.baseURL).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com")
const fromDefault = yield* Effect.sync(() =>
AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0"),
).pipe(withProcessEnv({ AWS_REGION: undefined }))
expect(fromDefault.route.endpoint.baseURL).toBe("https://bedrock-runtime.us-gov-west-1.amazonaws.com")
}).pipe(withProcessEnv({ ...noAmbientAWS, AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: "us-gov-west-1" })),
)
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
@@ -7,6 +7,7 @@ import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
import { recordedTests } from "../recorded-test.js"
@@ -82,6 +83,36 @@ describe("Amazon Bedrock Mantle provider", () => {
}),
)
it.effect("signs with the Mantle service using default-chain credentials", () =>
Effect.gen(function* () {
const seen: Array<string | undefined> = []
const model = AmazonBedrockMantle.configure({ region: "us-west-1" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push(request.headers.get("authorization") ?? undefined)
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.flip,
)
expect(seen[0]).toContain("Credential=AKIACHAINEXAMPLE/")
expect(seen[0]).toContain("/us-west-1/bedrock-mantle/aws4_request")
}).pipe(
withProcessEnv({
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_PROFILE: undefined,
AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE",
AWS_SECRET_ACCESS_KEY: "chain-secret",
AWS_SESSION_TOKEN: undefined,
}),
),
)
it.effect("supports bearer authentication and custom base URLs", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
@@ -0,0 +1,243 @@
import { expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMClient, LLMRequest } from "../../src/index.js"
import { Azure, OpenAI } from "../../src/providers.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" })
const trigger = { mechanism: "trigger" } as const
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toBe("/v1/responses")
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
expect(request.headers.authorization).toBe("Bearer fixture")
expect(request.headers["chatgpt-account-id"]).toBe("fixture-account")
expect(request.headers["x-codex-beta-features"]).toBe("remote_compaction_v2")
const body = JSON.parse(text)
expect(body).toMatchObject({
model: "fixture",
stream: true,
store: false,
instructions: "Keep the context",
parallel_tool_calls: true,
prompt_cache_key: "session-key",
service_tier: "priority",
reasoning: { effort: "high", summary: "auto" },
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
})
expect(body.tools).toHaveLength(1)
expect(body.tools[0].name).toBe("lookup")
expect(body.tool_choice).toBeUndefined()
expect(body.context_management).toBeUndefined()
expect(body.text).toBeUndefined()
expect(body.max_output_tokens).toBeUndefined()
expect(body.previous_response_id).toBeUndefined()
return respond(
sseEvents({
type: "response.completed",
response: {
id: "resp_1",
output: [checkpoint],
usage: {
input_tokens: 100,
input_tokens_details: { cached_tokens: 40 },
output_tokens: 5,
total_tokens: 105,
},
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
Effect.gen(function* () {
const calls: string[] = []
const input = LLM.request({
model: request.model,
system: "Keep the context",
prompt: "hello",
promptCacheKey: "session-key",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
toolChoice: { type: "tool", name: "lookup" },
generation: { maxTokens: 1 },
providerOptions: {
store: true,
reasoningEffort: "high",
reasoningSummary: "auto",
contextManagement: [{ type: "compaction" }],
},
http: {
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
query: { trace: "request" },
body: {
service_tier: "priority",
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
store: true,
stream: false,
text: { format: { type: "json_object" } },
tool_choice: "required",
},
},
})
const original = LLMRequest.input(input)
const result = yield* LLMClient.compact(input, {
...trigger,
http: (request, next) => {
calls.push("http")
return next(request)
},
})
expect(result.checkpoint).toMatchObject({
type: "compaction",
provider: "openai",
id: "cmp_1",
encrypted: "opaque",
})
expect(result.responseID).toBe("resp_1")
expect(result.usage).toMatchObject({
inputTokens: 100,
outputTokens: 5,
totalTokens: 105,
cacheReadInputTokens: 40,
})
expect(LLMRequest.input(input)).toEqual(original)
expect(calls).toEqual(["http"])
}),
)
const idless = { type: "compaction", encrypted_content: "opaque" }
testEffect(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" },
},
{ type: "response.output_text.delta", delta: "do not expose this" },
{ type: "response.output_item.added", output_index: 1, item: { type: "compaction" } },
{ type: "response.output_item.done", output_index: 1, item: idless },
{ type: "response.output_item.done", output_index: 1, item: idless },
{
type: "response.completed",
response: {
id: "resp_1",
output: [{ type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" }, idless],
},
},
),
),
).effect("correlates an ID-less checkpoint across repeated events and ignores other output", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(request, trigger)
expect(result.checkpoint.encrypted).toBe("opaque")
expect(result.checkpoint.id).toBeString()
expect("message" in result).toBe(false)
}),
)
for (const [name, events] of Object.entries({
missing: [{ type: "response.completed", response: { id: "resp_1", output: [] } }],
multiple: [
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, { ...checkpoint, id: "cmp_2" }] } },
],
duplicateSlots: [{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, checkpoint] } }],
malformed: [
{ type: "response.completed", response: { id: "resp_1", output: [{ type: "compaction", id: "cmp_1" }] } },
],
noResponseID: [{ type: "response.completed", response: { output: [checkpoint] } }],
incomplete: [
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.incomplete", response: { id: "resp_1", incomplete_details: { reason: "max_output_tokens" } } },
],
failed: [
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.failed", response: { id: "resp_1", error: { code: "server_error", message: "failed" } } },
],
})) {
const wire = events.map((event) => ({ ...event, fixture_extra: "preserved" }))
testEffect(
fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }),
).effect(`rejects ${name} checkpoint response and preserves original error context`, () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
expect(error.reason.body).toBe(JSON.stringify(wire.at(-1)))
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } })
}),
)
}
testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: checkpoint }))).effect(
"rejects clean EOF without response.completed",
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
for (const body of [{ input: [] }, { previous_response_id: "stale" }]) {
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
}
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
"endpoint support does not grant trigger support, including for untyped callers",
() =>
Effect.gen(function* () {
const client = yield* LLMClient.Service
const unsupported = LLM.request({ model: Azure.configure({ resourceName: "fixture" }).responses("fixture") })
expect(LLMClient.canCompact(unsupported)).toBe(true)
expect(LLMClient.canCompact(unsupported, trigger)).toBe(false)
// @ts-expect-error Exercise untyped consumers; the service must reject before sending.
const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip)
expect(serviceError.reason._tag).toBe("UnsupportedOperation")
// @ts-expect-error Exercise an unknown mechanism supplied by JavaScript.
const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
testEffect(fixedResponse("must not use HTTP")).effect(
"invalid checkpoint output is rejected before acknowledging channel completion",
() =>
Effect.gen(function* () {
let completed = 0
const operation = LLMClient.compact(request, {
mechanism: "trigger",
webSocket: {
execute: () =>
Effect.succeed({
frames: Stream.make(
JSON.stringify({
type: "response.completed",
response: { id: "resp_1", output: [] },
}),
),
complete: Effect.sync(() => {
completed++
}),
}),
},
})
const error = yield* operation.pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(completed).toBe(0)
}),
)
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("tolerates keepalive frames before response.created", () =>
it.effect("tolerates keepalive and provider notifications before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
@@ -534,6 +534,7 @@ describe("OpenAI Responses route", () => {
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
@@ -657,7 +658,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a promoted steer after the completed assistant output", () =>
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
@@ -671,7 +672,7 @@ describe("OpenAI Responses route", () => {
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello" }],
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
@@ -699,42 +700,37 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues store-false reasoning while retaining the output item ID", () =>
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
const reasoning = {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
}
const first = continuationDriver(request)
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
item: reasoning,
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
ProviderShared.encodeJson({
type: "response.completed",
response: { id: "resp_1", output: [{ ...reasoning, encrypted_content: "terminal-encrypted" }] },
}),
),
)
const next = continuationDriver({
...request,
input: [
...firstInput,
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
input: [...firstInput, reasoning, { role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
const continued = yield* next.create(saved)
@@ -744,6 +740,11 @@ describe("OpenAI Responses route", () => {
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
const edited = yield* continuationDriver({
...request,
input: [...firstInput, { ...reasoning, encrypted_content: "edited" }, { role: "user", content: "Continue" }],
}).create(saved)
expect(edited.mode).toBe("full")
}),
)
+26
View File
@@ -3,6 +3,7 @@ import {
AIError,
CompactionPart,
CompactionResponse,
CompactionCheckpointResponse,
LanguageModel,
LLM,
LLMClient,
@@ -79,6 +80,31 @@ describe("TestLLM legacy client", () => {
})
describe("TestLLM first-class client", () => {
it.effect("returns scripted checkpoints only for trigger compaction", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" })
const checkpoint = new CompactionCheckpointResponse({
checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" },
responseID: "resp_fixture",
})
const endpoint = new CompactionResponse({ replacement: [] })
yield* client.push(checkpoint, endpoint, checkpoint, checkpoint)
const operation = LLMClient.compact(request, { mechanism: "trigger" })
expect(yield* client.requests()).toEqual([])
expect(yield* operation).toBe(checkpoint)
expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM trigger compaction requires a CompactionCheckpointResponse",
)
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM compaction requires a CompactionResponse",
)
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM generation requires an event response",
)
}),
)
it.effect("rejects response fixtures for the wrong operation", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
+1 -1
View File
@@ -5,5 +5,5 @@
"noEmit": true,
"rootDir": "."
},
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
"include": ["test/**/*.types.ts", "test/testing.test.ts", "test/provider/checkpoint.test.ts"]
}
+201 -17
View File
@@ -1,16 +1,139 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
for (const direction of ["ltr", "rtl"]) {
for (const alternate of ["none", "queue", "steer"]) {
story(`scrolls overflowing controls beside fixed ${alternate} actions in ${direction}`, async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--toolbar-overflow", {
args: { alternate },
globals: { direction },
})
const controls = component.locator('[data-slot="composer-controls"]')
const actions = component.locator('[data-slot="composer-actions"]')
const submit = component.locator('[data-action="composer-submit"]')
const add = component.locator('[data-action="composer-attach"]')
const agent = controls.getByRole("button", { name: "Choose agent" })
const variant = controls.getByRole("button", { name: "Choose model variant" })
await expect(controls).toHaveCSS("overflow-x", "auto")
await expect(controls).toHaveCSS("overscroll-behavior-x", "contain")
await expect(controls).toHaveCSS("direction", direction)
await expect(controls).toHaveCSS("padding-inline-start", "0px")
await expect(controls).toHaveCSS("padding-inline-end", "0px")
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
alternate === "none" ? 0 : 1,
)
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
for (const width of [1024, 360]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() => controls.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeGreaterThan(0)
const fixed = await submit.boundingBox()
const fixedAdd = await add.boundingBox()
const viewport = await controls.boundingBox()
const action = await actions.boundingBox()
expect(fixed).not.toBeNull()
expect(viewport).not.toBeNull()
expect(action).not.toBeNull()
expect(fixedAdd).not.toBeNull()
if (!fixed || !viewport || !action || !fixedAdd) return
expect(direction === "ltr" ? fixedAdd.x + fixedAdd.width : viewport.x + viewport.width).toBeCloseTo(
direction === "ltr" ? viewport.x - 4 : fixedAdd.x - 4,
1,
)
expect(direction === "ltr" ? viewport.x + viewport.width : action.x + action.width).toBeCloseTo(
direction === "ltr" ? action.x - 12 : viewport.x - 12,
1,
)
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
await controls.evaluate((element) => {
element.scrollLeft = 0
})
await expect(controls).toHaveAttribute("data-overflow-start", "false")
await expect(controls).toHaveAttribute("data-overflow-end", "true")
await expect(controls).toHaveCSS(
"mask-image",
`linear-gradient(to ${direction === "ltr" ? "right" : "left"}, rgba(0, 0, 0, 0), rgb(0, 0, 0) 0px, rgb(0, 0, 0) calc(100% - 16px), rgba(0, 0, 0, 0))`,
)
const first = await agent.boundingBox()
if (!first) throw new Error("Missing agent control")
expect(
direction === "ltr" ? first.x - viewport.x : viewport.x + viewport.width - first.x - first.width,
).toBeCloseTo(0, 0)
await controls.evaluate((element) => {
element.scrollLeft =
((getComputedStyle(element).direction === "rtl" ? -1 : 1) * (element.scrollWidth - element.clientWidth)) / 2
})
await expect(controls).toHaveAttribute("data-overflow-start", "true")
await expect(controls).toHaveAttribute("data-overflow-end", "true")
const scrolled = (await controls.boundingBox())!
expect(scrolled.x).toBeCloseTo(viewport.x, 1)
expect(scrolled.width).toBeCloseTo(viewport.width, 1)
await controls.hover()
await page.mouse.wheel(direction === "ltr" ? 1000 : -1000, 0)
await expect.poll(() => controls.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
expect(await submit.boundingBox()).toEqual(fixed)
expect(await add.boundingBox()).toEqual(fixedAdd)
// The fade disappears at the endpoint instead of reserving padding.
await variant.focus()
await controls.evaluate((element) => {
element.scrollLeft =
getComputedStyle(element).direction === "rtl" ? -element.scrollWidth : element.scrollWidth
})
await expect(controls).toHaveAttribute("data-overflow-start", "true")
await expect(controls).toHaveAttribute("data-overflow-end", "false")
const last = await variant.boundingBox()
if (!last) throw new Error("Missing variant control")
expect(
Math.abs(direction === "ltr" ? viewport.x + viewport.width - last.x - last.width : last.x - viewport.x),
).toBeLessThan(1)
expect(
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
).toBeLessThan(1)
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(variant).toBeFocused()
await expect(submit).toBeInViewport()
await expect(add).toBeInViewport()
await add.click()
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
await page.keyboard.press("Escape")
expect(await add.boundingBox()).toEqual(fixedAdd)
}
if (alternate !== "none") {
const width = (await controls.boundingBox())!.width
await component.getByRole("textbox", { name: "Prompt", exact: true }).fill("")
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(0)
await expect.poll(async () => (await controls.boundingBox())!.width).toBeGreaterThan(width)
}
})
}
}
story("does not mask or pad controls when they fit", async ({ mount }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const controls = component.locator('[data-slot="composer-controls"]')
await expect(controls).toHaveAttribute("data-overflow-start", "false")
await expect(controls).toHaveAttribute("data-overflow-end", "false")
await expect(controls).toHaveCSS("mask-image", "none")
await expect(controls).toHaveCSS("padding-inline-start", "0px")
await expect(controls).toHaveCSS("padding-inline-end", "0px")
})
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
for (const [theme, background] of [
["light", "rgb(255, 255, 255)"],
["dark", "rgb(36, 36, 36)"],
] as const) {
story(`raises the docked composer only in dark mode (${theme})`, async ({ mount }) => {
const component = await mount("opencode-composer-flow--empty-draft", { globals: { theme } })
await expect(component.locator('[data-component="composer"]')).toHaveCSS("background-color", background)
})
}
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
await component.locator('[data-action="composer-attach"]').click()
@@ -88,27 +211,88 @@ story("renders a draft once and supports editing, caret restoration, and failure
await expect(input).toHaveText("Preserve this draft on failure")
})
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
story("shows the thinking level control while relevant", async ({ mount, page }) => {
story("shows thinking on composer hover or when a non-default variant is selected", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const composer = component.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
const control = composer.getByRole("button", { name: "Choose model variant" })
await component.getByRole("status").click()
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await expect(control).toBeVisible()
await expect(control).toHaveText("balanced")
await expect(control).toHaveCSS("opacity", "1")
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await component.getByRole("status").click()
await expect(control).toHaveText("default")
await expect(control).toHaveCSS("opacity", "0")
await expect(control).toHaveCSS("pointer-events", "none")
await input.hover()
await expect(control).toHaveCSS("opacity", "1")
await expect(control).toHaveCSS("pointer-events", "auto")
await input.click()
await page.mouse.move(0, 0)
await expect(input).toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
await input.hover()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).toHaveAttribute("aria-expanded", "true")
await expect(control).toHaveCSS("opacity", "1")
await expect(high).toBeVisible()
await high.click()
await component.getByRole("status").click()
await expect(control).toHaveText("high")
await expect(control).toHaveCSS("opacity", "1")
await input.focus()
await expect(control).toBeVisible()
await input.blur()
await expect(control).toBeVisible()
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await component.getByRole("status").click()
await expect(control).toHaveCSS("opacity", "0")
})
story("keeps default thinking accessible by keyboard without composer hover", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const control = component.getByRole("button", { name: "Choose model variant" })
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await input.click()
await page.mouse.move(0, 0)
await expect(control).toHaveCSS("opacity", "0")
// Tab through Add, Agent, and Model to the visually hidden thinking trigger.
for (let count = 0; count < 4; count++) await page.keyboard.press("Tab")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Enter")
await expect(control).toHaveAttribute("aria-expanded", "true")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Escape")
await expect(control).toHaveAttribute("aria-expanded", "false")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await page.keyboard.press("End")
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeFocused()
await page.keyboard.press("Enter")
await expect(control).toHaveText("high")
await expect(control).toBeFocused()
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await page.keyboard.press("Enter")
await expect(control).toHaveText("default")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Tab")
await expect(control).not.toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
})
@@ -0,0 +1,86 @@
import { expect, story } from "../../storybook/playwright/story"
for (const theme of ["light", "dark"]) {
for (const direction of ["ltr", "rtl"]) {
story(`shimmers once over the visible wordmark (${theme}, ${direction})`, async ({ mount, page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "no-preference" })
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme, direction } })
const logo = component.locator('[data-component="new-session-wordmark"]')
const reveal = logo.locator('[data-slot="wordmark-reveal"]')
const shimmer = logo.locator(".wordmark-shimmer")
const base = logo.locator("svg").first()
await expect(base).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
await expect(shimmer).toHaveCSS("animation-iteration-count", "1")
await expect(logo.locator("g[mask]")).toHaveCount(0)
await expect(shimmer).toHaveCSS("color", "rgb(255, 255, 255)")
await expect(shimmer).toHaveCSS("mix-blend-mode", "screen")
await expect(base.locator("g[fill]")).toHaveAttribute("fill", "currentColor")
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => {
animation.pause()
animation.currentTime = 0
})
})
await expect(reveal).toHaveCSS("opacity", "1")
await expect(reveal).toHaveCSS("animation-name", "none")
await expect(shimmer).toHaveCSS("mask-position", "100% 0px")
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => (animation.currentTime = 400))
})
await expect(shimmer).toHaveCSS("mask-position", "50% 0px")
await expect(shimmer).toHaveCSS("opacity", theme === "dark" ? "0.0672" : "0.45")
const swept = await logo.screenshot({ path: testInfo.outputPath("wordmark-shimmer.png") })
await logo.evaluate((element) => {
element.getAnimations({ subtree: true }).forEach((animation) => animation.finish())
})
await expect(reveal).toHaveCSS("opacity", "1")
await expect(shimmer).toHaveCSS("opacity", "0")
await expect(shimmer).toHaveCSS("mask-position", "0% 0px")
const settled = await logo.screenshot({ path: testInfo.outputPath("wordmark-settled.png") })
const brightness = await page.evaluate(
async (screenshots) => {
const pixels = await Promise.all(
screenshots.map(async (screenshot) => {
const image = new Image()
image.src = `data:image/png;base64,${screenshot}`
await image.decode()
const canvas = new OffscreenCanvas(image.width, image.height)
const context = canvas.getContext("2d")
if (!context) throw new Error("Cannot read screenshot pixels")
context.drawImage(image, 0, 0)
return context.getImageData(0, 0, image.width, image.height).data
}),
)
return {
min: pixels[0].reduce(
(min, value, index) => (index % 4 === 3 ? min : Math.min(min, value - pixels[1][index])),
0,
),
max: pixels[0].reduce(
(max, value, index) => (index % 4 === 3 ? max : Math.max(max, value - pixels[1][index])),
0,
),
}
},
[swept.toString("base64"), settled.toString("base64")],
)
expect(brightness.min).toBeGreaterThanOrEqual(0)
expect(brightness.max).toBeGreaterThanOrEqual(5)
})
}
story(`shows a static wordmark with reduced motion (${theme})`, async ({ mount, page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
await page.setViewportSize({ width: 360, height: 640 })
const component = await mount("app-new-session-wordmark--reveal", { globals: { theme } })
const logo = component.locator('[data-component="new-session-wordmark"]')
await expect(logo.locator("svg").first()).toHaveCSS("opacity", theme === "dark" ? "0.5" : "0.6")
await expect(logo.locator('[data-slot="wordmark-reveal"]')).toHaveCSS("opacity", "1")
await expect(logo.locator(".wordmark-shimmer")).toHaveCSS("opacity", "0")
expect(await logo.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0)
expect(await logo.evaluate((element) => element.getBoundingClientRect().right <= innerWidth)).toBe(true)
})
}
@@ -0,0 +1,79 @@
import { expect, story } from "../../storybook/playwright/story"
story("enables Any only after confirmation and supports resetting the preview", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
await expect(card.getByRole("button", { name: "Search provider Any", exact: true })).toBeVisible()
await card.getByRole("button", { name: "Enable", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): random")
await expect(card).toHaveCount(0)
await component.getByRole("button", { name: "Reset", exact: true }).click()
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
})
story("declining search is an explicit disabled selection", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await card.getByRole("button", { name: "Dont use search", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): false")
await expect(card).toHaveCount(0)
})
story("sizes provider options to their content", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--web-search-request")
await component.getByRole("button", { name: "Search provider Any", exact: true }).click()
const menu = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(menu).toBeVisible()
const metrics = await menu.evaluate((listbox) => {
const items = Array.from(listbox.querySelectorAll('[data-component="menu-v2-item"]'))
const longest = items
.flatMap((item) => {
const label = item.querySelector('[data-slot="menu-v2-item-content"]')?.getBoundingClientRect()
const check = item.querySelector('[data-slot="menu-v2-item-indicator"]')?.getBoundingClientRect()
return label && check ? [{ label, check }] : []
})
.toSorted((a, b) => b.label.width - a.label.width)[0]
return {
width: listbox.getBoundingClientRect().width,
gap: longest ? longest.check.left - longest.label.right : 0,
}
})
expect(metrics.width).toBeLessThan(160)
expect(metrics.gap).toBe(24)
})
for (const width of [360, 1200]) {
for (const direction of ["ltr", "rtl"]) {
for (const theme of ["light", "dark"]) {
story(`selects and confirms a provider at ${width}px in ${direction} ${theme}`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 900 })
const component = await mount("app-current-session-surface--web-search-request", {
globals: { theme, direction },
})
const card = component.getByRole("region", { name: "Third-party web search" })
const select = card.getByRole("button", { name: /^Search provider/ })
await expect(select).toBeEnabled()
await expect(card).toHaveCSS("direction", direction)
expect(await card.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true)
await select.focus()
await select.press("Enter")
const list = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(list).toHaveCSS("direction", direction)
await list.getByRole("option", { name: "Parallel", exact: true }).click()
await expect(select).toHaveText("Parallel")
await expect(card).toBeVisible()
await expect(component.getByRole("status")).toHaveText("Ready")
await expect(select).toBeFocused()
await select.press("Tab")
await expect(card.getByRole("button", { name: "Dont use search", exact: true })).toBeFocused()
await page.keyboard.press("Tab")
const enable = card.getByRole("button", { name: "Enable", exact: true })
await expect(enable).toBeFocused()
await enable.press("Enter")
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): parallel")
await expect(card).toHaveCount(0)
})
}
}
}
+1
View File
@@ -43,6 +43,7 @@ The suite contains:
- home-session click timing split between content and titlebar-tab paint
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing
- large-session search scan, first-result reveal, and highlight stabilization
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
- retained renderer heap with a large model catalog across repeated session navigation
@@ -0,0 +1,69 @@
import { benchmark, expect } from "../benchmark"
import { buildInitialStreamEvent, setupTimelineBenchmark, textPartID } from "./session-timeline-benchmark.fixture"
import {
collectTimelineSearchMetrics,
installTimelineSearchProbe,
waitForStableTimelineSearch,
} from "./session-timeline-search-probe"
benchmark("searches a large virtualized session and reveals the first result", async ({ page, report }) => {
benchmark.setTimeout(180_000)
const historyTurns = Number(process.env.TIMELINE_SEARCH_HISTORY_TURNS ?? 320)
const completionTimeout = Number(process.env.TIMELINE_SEARCH_COMPLETION_TIMEOUT_MS ?? 60_000)
const query = "Historical prompt"
const targetPartID = "msg_0000_0000_a_user:text:0"
const expectedCounter = `1/${historyTurns}`
const fixture = await setupTimelineBenchmark(page, {
historyTurns,
eventBatch: 1,
})
fixture.transport.enqueue(buildInitialStreamEvent(1))
await expect(fixture.text).toContainText("Implementation plan")
await fixture.scrollToBottom()
await fixture.waitForStableGeometry()
// Chromium reserves the physical shortcut for its native find overlay, so request the same controller path directly.
await page.evaluate(() => document.dispatchEvent(new Event("opencode:timeline-search-open")))
const search = page.locator('[data-component="timeline-search-bar"]')
const field = search.getByRole("searchbox", { name: "Find..." })
const count = search.locator('[data-slot="timeline-search-count"]')
const target = page.locator(`[data-timeline-part-id="${targetPartID}"]`)
await expect(field).toBeVisible()
await expect(field).toBeFocused()
await installTimelineSearchProbe(page, { targetPartID })
await field.fill(query)
await expect(count).toHaveText(expectedCounter)
await expect(target).toBeVisible({ timeout: completionTimeout })
await waitForStableTimelineSearch(page, { counter: expectedCounter, targetPartID, timeout: completionTimeout })
const metrics = await collectTimelineSearchMetrics(page, { counter: expectedCounter, targetPartID })
expect(metrics.summary.handlerDurationMs).toBeDefined()
expect(metrics.summary.firstCountObservedMs).toBeDefined()
expect(metrics.summary.firstTargetVisibleMs).toBeDefined()
expect(metrics.summary.firstActiveHighlightObservedMs).toBeDefined()
expect(metrics.summary.stableResultObservedMs).toBeDefined()
expect(metrics.summary.activeHighlightRanges).toBe(1)
report(metrics, { historyTurns, query, expectedMatches: historyTurns })
// Check navigation and the V2 assistant content IDs outside the measured interval.
await field.press("Enter")
await expect(count).toHaveText(`2/${historyTurns}`)
await field.press("Shift+Enter")
await expect(count).toHaveText(expectedCounter)
await field.fill("Implementation plan")
await expect(count).toHaveText("1/1")
await expect(fixture.text).toBeInViewport()
await expect
.poll(() =>
page.evaluate(() => {
const range = [...(CSS.highlights.get("timeline-search-hit-active") ?? [])][0]
return range?.startContainer.parentElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset
.timelinePartId
}),
)
.toBe(textPartID)
await field.press("Escape")
await expect(search).toBeHidden()
})
@@ -0,0 +1,176 @@
import type { Page } from "@playwright/test"
export type TimelineSearchSample = {
observedAtMs: number
counter: string
targetMounted: boolean
targetVisible: boolean
targetTopPx?: number
activeRanges: number
activePartID?: string
activeVisible: boolean
scrollTopPx: number
}
type TimelineSearchProbe = {
samples: TimelineSearchSample[]
handlerDurationMs?: number
initialScrollTopPx: number
stop: () => void
}
export async function installTimelineSearchProbe(page: Page, input: { targetPartID: string }) {
await page.evaluate(({ targetPartID }) => {
const search = document.querySelector<HTMLElement>('[data-component="timeline-search-bar"]')
const field = search?.querySelector<HTMLInputElement>('[data-slot="text-input-v2-input"]')
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (!search || !field || !root) throw new Error("missing timeline search benchmark nodes")
const samples: TimelineSearchSample[] = []
const initialScrollTopPx = root.scrollTop
let startedAt: number | undefined
let handlerDurationMs: number | undefined
let frame: number | undefined
let running = true
const visibleInRoot = (rect: DOMRect) => {
const viewport = root.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
}
const sample = () => {
if (!running || startedAt === undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
setTimeout(() => {
if (!running || startedAt === undefined) return
const target = root.querySelector<HTMLElement>(`[data-timeline-part-id="${targetPartID}"]`)
const targetRect = target?.getBoundingClientRect()
const highlight = CSS.highlights.get("timeline-search-hit-active")
const ranges = highlight ? [...highlight] : []
const active = ranges.find((range): range is Range => range instanceof Range)
const activeRect = active?.getBoundingClientRect()
const activeElement =
active?.startContainer instanceof Element ? active.startContainer : active?.startContainer.parentElement
samples.push({
observedAtMs: performance.now() - startedAt,
counter:
search.querySelector<HTMLElement>('[data-slot="timeline-search-count"]')?.textContent?.trim() ?? "",
targetMounted: !!target,
targetVisible: !!targetRect && visibleInRoot(targetRect),
targetTopPx: targetRect?.top,
activeRanges: ranges.length,
activePartID: activeElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId,
activeVisible: !!activeRect && visibleInRoot(activeRect),
scrollTopPx: root.scrollTop,
})
sample()
}, 0)
})
}
const onInputCapture = (event: Event) => {
if (event.target !== field || startedAt !== undefined) return
startedAt = performance.now()
sample()
}
const onInput = (event: Event) => {
if (event.target !== field || startedAt === undefined || handlerDurationMs !== undefined) return
handlerDurationMs = performance.now() - startedAt
}
document.addEventListener("input", onInputCapture, { capture: true })
document.addEventListener("input", onInput)
;(window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark = {
samples,
initialScrollTopPx,
get handlerDurationMs() {
return handlerDurationMs
},
stop: () => {
running = false
document.removeEventListener("input", onInputCapture, { capture: true })
document.removeEventListener("input", onInput)
if (frame !== undefined) cancelAnimationFrame(frame)
},
}
}, input)
}
export async function waitForStableTimelineSearch(
page: Page,
input: { counter: string; targetPartID: string; timeout: number },
) {
await page.waitForFunction(
({ counter, targetPartID }) => {
const samples = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
?.samples
if (!samples) return false
return samples.some((_, index) => {
const stable = samples.slice(index, index + 3)
if (stable.length !== 3) return false
return stable.every(
(sample, sampleIndex) =>
sample.counter === counter &&
sample.targetVisible &&
sample.activeRanges === 1 &&
sample.activePartID === targetPartID &&
sample.activeVisible &&
(sampleIndex === 0 ||
(Math.abs(sample.scrollTopPx - stable[sampleIndex - 1]!.scrollTopPx) <= 1 &&
Math.abs((sample.targetTopPx ?? Infinity) - (stable[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
)
})
},
{ counter: input.counter, targetPartID: input.targetPartID },
{ timeout: input.timeout },
)
}
export async function collectTimelineSearchMetrics(page: Page, input: { counter: string; targetPartID: string }) {
const result = await page.evaluate(() => {
const probe = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
if (!probe) throw new Error("missing timeline search benchmark probe")
probe.stop()
return {
samples: probe.samples,
handlerDurationMs: probe.handlerDurationMs,
initialScrollTopPx: probe.initialScrollTopPx,
}
})
const first = (predicate: (sample: TimelineSearchSample) => boolean) => result.samples.find(predicate)?.observedAtMs
const stable = result.samples.findIndex((_, index) => {
const samples = result.samples.slice(index, index + 3)
if (samples.length !== 3) return false
return samples.every(
(sample, sampleIndex) =>
sample.counter === input.counter &&
sample.targetVisible &&
sample.activeRanges === 1 &&
sample.activePartID === input.targetPartID &&
sample.activeVisible &&
(sampleIndex === 0 ||
(Math.abs(sample.scrollTopPx - samples[sampleIndex - 1]!.scrollTopPx) <= 1 &&
Math.abs((sample.targetTopPx ?? Infinity) - (samples[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
)
})
const final = result.samples.at(-1)
return {
summary: {
handlerDurationMs: result.handlerDurationMs,
firstCountObservedMs: first((sample) => sample.counter === input.counter),
firstTargetMountedMs: first((sample) => sample.targetMounted),
firstTargetVisibleMs: first((sample) => sample.targetVisible),
firstActiveHighlightObservedMs: first(
(sample) => sample.activeRanges === 1 && sample.activePartID === input.targetPartID && sample.activeVisible,
),
stableResultObservedMs: stable >= 0 ? result.samples[stable + 2]?.observedAtMs : undefined,
initialScrollTopPx: result.initialScrollTopPx,
finalScrollTopPx: final?.scrollTopPx,
scrollDistancePx: final === undefined ? undefined : Math.abs(result.initialScrollTopPx - final.scrollTopPx),
activeHighlightRanges: final?.activeRanges,
},
samples: result.samples,
}
}
@@ -0,0 +1,195 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/slash-skills"
const projectID = "proj_slash_skills"
const sessionID = "ses_slash_skills"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
async function setup(page: Page, queued = false) {
const prompts: Record<string, unknown>[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "slash-skills",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
projectID,
directory,
title: "Slash skills",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
sessionStatus: queued ? { [sessionID]: { type: "running" } } : {},
inbox: queued
? [
{
id: "inb_slash_skill",
sessionID,
timeCreated: 1700000000000,
type: "user",
payload: { text: "Explain caching" },
delivery: "queue",
},
]
: [],
findFiles: ({ query }) =>
query.includes("cache")
? [
{
name: "cache.ts",
path: "src/cache.ts",
absolute: `${directory}/src/cache.ts`,
type: "file",
ignored: false,
},
]
: [],
onPrompt: ({ body }) => prompts.push(body),
})
await page.route("**/api/skill?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [
{ id: "show-me", slash: true, autoinvoke: false },
{ id: "hidden", slash: false },
{ id: "implicit" },
{ id: "review", slash: true },
{ id: "model", slash: true },
].map((skill) => ({
name: skill.id === "show-me" ? "Show Me" : skill.id,
description: "Explain the current topic visually",
location: `/skills/${skill.id}/SKILL.md`,
content: "Explain visually",
...skill,
})),
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.route("**/api/command?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [{ name: "review", description: "Review changes" }],
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const editor = composer.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable()
return { prompts, composer, editor }
}
for (const selection of ["keyboard", "pointer"]) {
test(`selects and submits a manual-only slash skill with ${selection}`, async ({ page }) => {
const { prompts, editor } = await setup(page)
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toContainText("/show-me")
await page.screenshot({ path: test.info().outputPath("slash-menu.png") })
if (selection === "keyboard") await editor.press("Enter")
if (selection === "pointer") await skill.click()
await expect(editor).toHaveText("/show-me ")
await expect(editor).toBeFocused()
await editor.pressSequentially("explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
await expect(editor).toBeEmpty()
})
}
test("respects slash flags and command precedence without hiding context skills", async ({ page }) => {
const { editor } = await setup(page)
await editor.fill("/")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="custom.review"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="model.choose"]')).toBeVisible()
for (const id of ["hidden", "implicit", "review", "model"]) {
await expect(page.locator(`[data-suggestion-id="skill:${id}"]`)).toHaveCount(0)
}
await editor.press("Escape")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toHaveCount(0)
await expect(editor).toBeFocused()
await editor.fill("@hidden")
const hidden = page.locator('[data-suggestion-id="skill:hidden"]')
await expect(hidden).toContainText("@hidden")
await hidden.click()
await expect(editor).toHaveText("@hidden ")
await expect(editor).toBeFocused()
})
test("preserves structured attachments when adding a slash skill from the command menu", async ({ page }) => {
const { prompts, composer, editor } = await setup(page)
await editor.fill("explain @cache")
const file = page.locator('[data-suggestion-id="file:src/cache.ts"]')
await expect(file).toBeVisible()
await file.click()
await expect(editor).toHaveText("explain @src/cache.ts ")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me explain @src/cache.ts ")
await expect(editor).toBeFocused()
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
files: [{ name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } }],
})
})
for (const select of [true, false]) {
test(`keeps slash skills in queued edits (${select ? "selected" : "typed"})`, async ({ page }) => {
const { prompts, editor } = await setup(page, true)
const row = page.locator('[data-component="session-queue-row"]')
await row.getByText("Explain caching", { exact: true }).click()
await expect(editor).toHaveText("Explain caching")
if (select) {
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me ")
await editor.pressSequentially("explain caching")
}
if (!select) await editor.fill("/show-me explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
delivery: "queue",
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
})
}
@@ -86,7 +86,12 @@ async function mockServers(page: Page, requests: string[]) {
}
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
}
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
if (url.pathname === "/api/location")
return json(route, {
directory: current.directory,
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_dropzone"
const directory = "C:/OpenCode/NewSessionDropzone"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the dropzone and attaches a dropped file", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_dropzone",
worktree: directory,
vcs: "git",
name: "new-session-dropzone",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
const surface = page.locator('[data-component="new-session"]')
const dropzone = page.locator('[data-component="session-dropzone"]')
const transfer = await page.evaluateHandle(() => {
const value = new DataTransfer()
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
return value
})
await expect(dropzone).toHaveCount(0)
await surface.dispatchEvent("dragover", { dataTransfer: transfer })
await expect(dropzone).toHaveAttribute("data-visible", "true")
await expect(dropzone).toContainText("Drop files to add")
await surface.dispatchEvent("drop", { dataTransfer: transfer })
await expect(page.locator('[data-component="composer-attachments"]')).toContainText("dropzone.txt")
await expect(dropzone).toHaveCount(0)
})
@@ -134,7 +134,7 @@ for (const viewport of [
await expect(page).toHaveURL(pending.url)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
await expect(pending.title).toHaveText("New session")
await expect(pending.title).toHaveText("Session")
await expect(editor).toHaveText(followUp)
expect(mock.calls).toEqual(["worktree"])
@@ -238,7 +238,7 @@ for (const direction of ["ltr", "rtl"]) {
!frame.message ||
!frame.spinner ||
frame.draft !== followUp ||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
!["Session", "Created workspace session"].includes(frame.title ?? ""),
),
).toEqual([])
const after = await title.boundingBox()
@@ -490,20 +490,24 @@ async function openDraft(
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === `/api/worktree/${projectID}`) {
if (path === "/api/worktree") {
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
await page.route(`**/api/worktree/${projectID}`, async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
},
)
await page.route("**/api/session", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: Record<string, unknown> = route.request().postDataJSON()
@@ -598,7 +602,7 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
const title = preparing.getByRole("heading", { level: 1 })
await expect(preparing).toBeVisible()
await expect(title).toHaveText("New session")
await expect(title).toHaveText("Session")
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
@@ -7,11 +7,13 @@ const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
const longPath = `frontend/${longFilename}`
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
test("expands Windows paths and horizontally scrolls long filenames", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -44,7 +46,17 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
vcsDiff: [
{
file: longPath,
before: "",
after: "export const added = true\n",
additions: 1,
deletions: 0,
status: "added",
patch: "@@ -0,0 +1 @@\n+export const added = true\n",
},
],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
@@ -55,6 +67,13 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
type: "file" as const,
ignored: false,
},
{
name: longFilename,
path: `frontend\\${longFilename}`,
absolute: `${directory}/${longPath}`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
@@ -75,6 +94,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
},
]
},
findFiles: ({ query }) => (longPath.includes(query) ? [longPath] : []),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
@@ -119,6 +139,93 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const viewport = sidebar.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
const longRow = panel.getByRole("button", { name: longFilename })
await expect(longRow).toBeVisible()
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
expect(
await longRow.evaluate((element) => getComputedStyle(element.querySelector("bdi")!.parentElement!).textOverflow),
).toBe("clip")
expect(await longRow.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(
await viewport.evaluate((element) => element.clientWidth),
)
await expect
.poll(() =>
panel.locator('[data-slot="file-tree-v2-row"]').evaluateAll((rows) => {
const widths = rows.map((row) => row.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThanOrEqual(0.5)
await expect(longRow.locator('[data-slot="file-tree-v2-label"]')).toHaveCSS("margin-inline-end", "12px")
const status = longRow.locator('[data-slot="file-tree-v2-change"]')
await expect(status).toHaveText("A")
const statusBox = await status.boundingBox()
if (!statusBox) throw new Error("File status has no bounding box")
const viewportBox = await viewport.boundingBox()
if (!viewportBox) throw new Error("File tree viewport has no bounding box")
expect(viewportBox.x + viewportBox.width - statusBox.x - statusBox.width).toBeLessThanOrEqual(24)
await viewport.hover()
const horizontalThumb = sidebar.locator('.scroll-view__thumb[data-orientation="horizontal"]')
await expect(horizontalThumb).toHaveCSS("opacity", "1")
await page.mouse.wheel(1_000, 0)
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
await expect(horizontalThumb).toHaveAttribute("data-visible", "true")
await expect
.poll(() =>
status.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.right - element.getBoundingClientRect().right
}),
)
.toBeLessThanOrEqual(24)
const beforeDrag = await viewport.evaluate((element) => Math.abs(element.scrollLeft))
const thumbBox = await horizontalThumb.boundingBox()
if (!thumbBox) throw new Error("Horizontal scrollbar thumb has no bounding box")
await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(thumbBox.x + thumbBox.width / 2 - 40, thumbBox.y + thumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeLessThan(beforeDrag)
const filter = panel.getByRole("combobox", { name: "Filter files" })
await filter.fill(longFilename)
const filteredRow = panel.getByRole("option", { name: longFilename })
await expect(filteredRow).toBeVisible()
const filteredStatus = filteredRow.locator('[data-slot="file-tree-v2-change"]')
await expect(filteredStatus).toHaveText("A")
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
await viewport.evaluate((element) => {
element.setAttribute("dir", "rtl")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await expect
.poll(() =>
filteredStatus.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return element.getBoundingClientRect().left - viewport.left
}),
)
.toBeLessThanOrEqual(24)
const rtlThumbBox = await horizontalThumb.boundingBox()
if (!rtlThumbBox) throw new Error("RTL horizontal scrollbar thumb has no bounding box")
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2 - 40, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => element.scrollLeft)).toBeLessThan(0)
await viewport.evaluate((element) => {
element.removeAttribute("dir")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await filter.fill("")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
test.use({
deviceScaleFactor: 2,
// Emulating DPR alone does not exercise Chromium's native hairline border rounding.
launchOptions: { args: ["--force-device-scale-factor=2"] },
})
for (const width of [1400, 390]) {
test(`patch file borders retain a full CSS pixel at ${width}px on high-density displays`, async ({ page }) => {
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
"prt_border_patch",
"patch",
"completed",
{ patchText: "Update file" },
{
metadata: {
files: [
{
file,
status: "modified",
additions: 1,
deletions: 1,
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
},
],
},
},
),
]),
],
reducedMotion: true,
viewport: { width, height: 900 },
})
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
const patch = page.locator('[data-component="apply-patch-tool"]')
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(trigger).toHaveCSS("height", "32px")
for (const side of ["top", "right", "bottom", "left"]) {
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
}
const box = await trigger.boundingBox()
await trigger.hover()
expect(await trigger.boundingBox()).toEqual(box)
await page.mouse.move(0, 0)
expect(await trigger.boundingBox()).toEqual(box)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
const content = patch.locator('[data-slot="accordion-content"]')
await expect(content).toBeVisible()
for (const side of ["left", "right", "bottom"]) {
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
}
await expect(content).toHaveCSS("border-top-width", "0px")
await trigger.press("Enter")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
}
@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -8,7 +8,7 @@ const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the thinking level control while relevant", async ({ page }) => {
test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -52,30 +52,57 @@ test("shows the thinking level control while relevant", async ({ page }) => {
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
const control = composer.getByRole("button", { name: "Choose model variant" })
await expectAppVisible(composer)
await idleComposer(page)
await expect(control).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toHaveText("default")
await expect(control).toHaveCSS("opacity", "0")
await expect(control).toHaveCSS("pointer-events", "none")
await input.hover()
await expect(control).toHaveCSS("opacity", "1")
await input.click()
await page.mouse.move(0, 0)
await expect(input).toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
await input.hover()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).toHaveAttribute("aria-expanded", "true")
await expect(control).toHaveCSS("opacity", "1")
await expect(high).toBeVisible()
await high.click()
await idleComposer(page)
await input.focus()
await expect(control).toBeVisible()
await idleComposer(page)
await expect(control).toBeVisible()
})
async function idleComposer(page: Page) {
await input.click()
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
}
await expect(control).toHaveText("high")
await expect(control).toHaveCSS("opacity", "1")
await control.click()
await page.getByRole("menuitemradio", { name: "default", exact: true }).click()
await input.click()
await page.mouse.move(0, 0)
await expect(control).toHaveText("default")
await expect(control).toHaveCSS("opacity", "0")
// The single-agent fixture has only Add and Model before the thinking trigger.
await page.keyboard.press("Tab")
await expect(composer.getByRole("button", { name: "Add images and files" })).toBeFocused()
await page.keyboard.press("Tab")
await expect(composer.getByRole("button", { name: "Thinking Model" })).toBeFocused()
await page.keyboard.press("Tab")
await expect(control).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Enter")
await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused()
await expect(control).toHaveCSS("opacity", "1")
await page.keyboard.press("Escape")
await expect(control).toBeFocused()
await page.keyboard.press("Tab")
await expect(control).toHaveCSS("opacity", "0")
})
@@ -396,7 +396,12 @@ async function mockServers(
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
return json(route, { data: [] })
if (url.pathname === "/api/location") return json(route, { directory })
if (url.pathname === "/api/location")
return json(route, {
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
@@ -87,16 +87,24 @@ test("recording a new session shortcut stays in settings until recording finishe
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await inventory.promise
await route.fallback()
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
await inventory.promise
await route.fallback()
},
)
await page.route("**/api/session?*", async (route) => {
if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise
await route.fallback()
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
const requested = page.waitForRequest(
(request) =>
new URL(request.url()).pathname === "/api/worktree" &&
new URL(request.url()).searchParams.get("location[directory]") === directory &&
request.method() === "GET",
)
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
@@ -110,16 +118,57 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const refresh = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await refresh.promise
await route.fallback()
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
await refresh.promise
await route.fallback()
},
)
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
refresh.resolve()
})
test("worktree deletion sends the project location separately from the target", async ({ page }) => {
const removed = new Set<string>()
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: [
{ directory },
...sandboxes.filter((item) => !removed.has(item)).map((directory) => ({ directory, strategy: "git" })),
],
})
}
if (route.request().method() === "DELETE") {
removed.add(route.request().postDataJSON().directory)
return route.fulfill({ status: 204 })
}
return route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
await expect(remove).toBeEnabled()
const deleting = page.waitForRequest(
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "DELETE",
)
await remove.click()
const request = await deleting
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
expect(request.postDataJSON()).toEqual({ directory: sandboxes[0], force: true })
await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0)
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
})
test("extensions opens without waiting for MCPs", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
@@ -225,19 +225,90 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await page.mouse.down()
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", "130px")
await expect(sidebar).toHaveCSS("width", "140px")
await tabB.click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabB).toBeVisible()
})
for (const count of [0, 26]) {
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
page,
}, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, directory, count }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
Array.from({ length: count }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `draft_navigation_${index}`,
})),
),
)
},
{ server, directory: sessionA.directory, count },
)
await page.goto("/")
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveCSS("width", "260px")
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
for (const width of [260, 180, 140]) {
if (width !== 260) {
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
const bounds = await sidebar.boundingBox()
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
await page.mouse.down()
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
await page.mouse.up()
}
await expect(sidebar).toHaveCSS("width", `${width}px`)
await testInfo.attach(`navigation-${count}-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
for (const name of ["Home", "New session"]) {
const button = sidebar.getByRole("button", { name, exact: true })
const label = button.getByText(name, { exact: true })
await expect(label).toBeVisible()
await expect
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
.toBeLessThanOrEqual(1)
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await button.hover()
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await expect
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await page.getByRole("main").hover()
}
}
})
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"settings.v3",
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
@@ -254,23 +325,27 @@ for (const direction of ["ltr", "rtl"]) {
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
await page.goto("/")
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
const status = sidebar.getByRole("button", { name: "Status", exact: true })
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(settings).toHaveText("Settings")
await expect(status).toHaveText("Status")
await expect(settings).toHaveCount(0)
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(settings).toBeInViewport({ ratio: 1 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(settings).toHaveCSS("margin-top", "8px")
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
await expect(status).toBeInViewport({ ratio: 1 })
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
@@ -285,12 +360,12 @@ for (const direction of ["ltr", "rtl"]) {
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinned = await settings.boundingBox()
const pinnedStatus = await status.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await testInfo.attach(`vertical-tabs-settings-${width}`, {
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await testInfo.attach(`vertical-tabs-status-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
@@ -304,17 +379,14 @@ for (const direction of ["ltr", "rtl"]) {
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await expect(settings).toHaveCount(0)
}
await settings.click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await expect(settings).toHaveAttribute("aria-pressed", "true")
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeHidden()
await settings.focus()
await settings.press("Enter")
await expect(page.getByTestId("settings-screen")).toBeVisible()
await status.click()
await expect(status).toHaveAttribute("aria-expanded", "true")
await status.press("Escape")
await expect(status).toHaveAttribute("aria-expanded", "false")
})
}
@@ -359,9 +431,9 @@ for (const profile of [
const hint = button.locator('span[aria-hidden="true"]')
await expect(hint).toHaveText(row.shortcut)
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
await button.hover()
await expect(hint).toHaveCSS("opacity", "1")
await expect(hint).toBeVisible()
await expect
.poll(() =>
hint.evaluate((element) => {
@@ -373,7 +445,7 @@ for (const profile of [
)
.toBeCloseTo(8, 1)
await page.getByRole("main").hover()
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
}
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
@@ -381,10 +453,10 @@ for (const profile of [
await home.focus()
await page.keyboard.press("Tab")
await expect(newSession).toBeFocused()
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
await page.keyboard.press("Shift+Tab")
await expect(home).toBeFocused()
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
})
}
@@ -560,7 +632,12 @@ async function mockServer(page: Page) {
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
)
}
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
if (url.pathname === "/api/location")
return json(route, {
directory: sessionA.directory,
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }])
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
@@ -67,12 +67,12 @@ for (const theme of ["light", "dark"] as const) {
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
}
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.send.hover()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectToken(
@@ -92,12 +92,14 @@ for (const theme of ["light", "dark"] as const) {
const view = await openSession(page, workspace, [{ directory: root }])
await view.input.fill("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
const url = page.url()
const refreshed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
response.request().method() === "GET",
)
view.worktrees.push({ directory: workspace, strategy: "git" })
view.events.push({
@@ -107,14 +109,14 @@ for (const theme of ["light", "dark"] as const) {
data: { projectID },
})
expect((await refreshed).ok()).toBe(true)
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await expect(page).toHaveURL(url)
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await view.input.fill("")
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
view.events.push({
id: "evt_workspace_accent_running",
@@ -125,29 +127,29 @@ for (const theme of ["light", "dark"] as const) {
})
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
await expect(stop).toBeEnabled()
await expectBackground(stop, "contrast")
await expectBackground(stop, "icon-button-contrast")
await view.input.fill("Send a follow-up instead of stopping.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await expect(page).toHaveURL(url)
})
test("new workspace send button stays neutral", async ({ page }) => {
const view = await openSession(page, root, [...inventory], true)
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await view.input.fill("Inspect this fixture workspace.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.send.hover()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "contrast")
await expectBackground(view.send, "icon-button-contrast")
})
})
}
@@ -199,10 +201,13 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
events: () => events.splice(0),
})
// Keep authoritative inventory independent of the raw project's empty sandboxes.
await page.route(`**/api/worktree/${projectID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
})
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
},
)
if (draft)
await page.addInitScript(
({ root, server }) => {
@@ -222,7 +227,9 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
)
const loaded = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
response.request().method() === "GET",
)
await page.goto(
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
+4 -8
View File
@@ -71,27 +71,23 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
success: NoContent,
}),
)
@@ -1,4 +1,4 @@
import { onMount } from "solid-js"
import { onCleanup, onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { ComposerAttachment, ComposerPrompt } from "../types"
@@ -78,6 +78,7 @@ export type ComposerAttachmentConfig = {
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
onDragCancel?: (callback: () => void) => () => void
store?: (file: File) => Promise<{ id: string; url: string }>
}
@@ -90,6 +91,9 @@ export function createComposerAttachments(
setDraggingType: (type: "image" | "@mention" | null) => void
},
) {
const clearDrag = () => {
input.setDraggingType(null)
}
const capture = () => {
const prompt = input.capture()
const editor = input.editor()
@@ -178,7 +182,7 @@ export function createComposerAttachments(
const handleDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
input.setDraggingType(null)
clearDrag()
const plainText = event.dataTransfer?.getData("text/plain")
if (plainText?.startsWith("file:")) {
const path = plainText.slice("file:".length)
@@ -191,6 +195,8 @@ export function createComposerAttachments(
}
onMount(() => {
const cancel = input.onDragCancel?.(clearDrag)
if (cancel) onCleanup(cancel)
makeEventListener(document, "dragover", (event) => {
if (input.isDialogActive()) return
event.preventDefault()
@@ -198,7 +204,10 @@ export function createComposerAttachments(
else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
})
makeEventListener(document, "dragleave", (event) => {
if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
if (!input.isDialogActive() && !event.relatedTarget) clearDrag()
})
makeEventListener(document, "keydown", (event) => {
if (event.key === "Escape") clearDrag()
})
makeEventListener(document, "drop", handleDrop)
})
+38 -5
View File
@@ -55,6 +55,8 @@ function ComposerStory(props: {
label?: string
inspectRequest?: boolean
continueOnStop?: boolean
longLabels?: boolean
alternate?: "queue" | "steer"
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
@@ -66,11 +68,15 @@ function ComposerStory(props: {
activity: props.label ?? "Ready",
variant: STORY_MODEL.variant,
})
const modelOption = createMemo(() => ({
...selectedModel,
name: props.longLabels ? "Claude Sonnet with an unusually long model name" : selectedModel.name,
}))
const modelSelection = {
ready: Object.assign(() => true, { promise: undefined }),
current: () => selectedModel,
recent: () => [selectedModel],
list: () => [selectedModel],
current: () => modelOption(),
recent: () => [modelOption()],
list: () => [modelOption()],
cycle() {},
set() {},
visible: () => true,
@@ -126,7 +132,7 @@ function ComposerStory(props: {
placeholder: () => "Ask anything, / for commands, @ for context...",
agent: {
options: () => [
{ id: "build", label: "build" },
{ id: "build", label: props.longLabels ? "Build agent with an unusually long name" : "build" },
{ id: "review", label: "review" },
],
current: () => "build",
@@ -134,7 +140,8 @@ function ComposerStory(props: {
},
variant: {
options: () => [
{ id: "balanced", label: "balanced" },
{ id: "default", label: "default" },
{ id: "balanced", label: props.longLabels ? "Balanced reasoning with an extended label" : "balanced" },
{ id: "high", label: "high" },
],
current: () => story.variant,
@@ -143,6 +150,17 @@ function ComposerStory(props: {
submit: {
stopping: () => !!props.stopping,
working: () => !!props.working,
queue: props.alternate
? {
count: () => 0,
delivery: () => (props.alternate === "queue" ? "steer" : "queue"),
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
cancelEdit() {},
editFirst: () => false,
}
: undefined,
onSubmit: () => {
const value = draft.prompt.map((part) => ("content" in part ? part.content : `[${part.filename}]`)).join("")
const request = props.inspectRequest
@@ -289,6 +307,21 @@ export const NarrowLayout = {
),
}
export const ToolbarOverflow = {
args: { alternate: "queue" },
argTypes: { alternate: { control: "select", options: ["none", "queue", "steer"] } },
render: (args: { alternate: "none" | "queue" | "steer" }) => (
<div class="w-[min(420px,calc(100vw-80px))]">
<ComposerStory
longLabels
working
prompt={text("Keep all controls reachable when the toolbar overflows")}
alternate={args.alternate === "none" ? undefined : args.alternate}
/>
</div>
),
}
export const DemoFirstClassSkillIDs = {
name: "Demo: First-class skill IDs",
render: () => (
+87
View File
@@ -0,0 +1,87 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { createAnimatedPresence } from "@/runtime/animated-presence"
export function ComposerDropzone(props: {
active: boolean
input?: { image?: boolean; pdf?: boolean }
identity?: () => unknown
}) {
const language = useLanguage()
const [elements, setElements] = createStore<{ dropzone?: HTMLDivElement }>({})
const label = createMemo(() => {
if (!props.input?.image && !props.input?.pdf) return language.t("ui.promptInput.dropFiles")
if (!props.input.pdf) return language.t("ui.promptInput.dropFiles.image")
if (!props.input.image) return language.t("ui.promptInput.dropFiles.pdf")
return language.t("ui.promptInput.dropFiles.imagePdf")
})
const presence = createAnimatedPresence(
() => (props.active ? label() : undefined),
() => elements.dropzone ?? null,
props.identity,
)
return (
<>
<div
data-slot="session-dropzone-blur"
data-visible={props.active}
class="pointer-events-none absolute inset-0 z-[79] rounded-[inherit] opacity-[0.001] backdrop-blur-[1.5px] transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] will-change-[opacity,backdrop-filter] data-[visible=true]:opacity-100 motion-reduce:transition-none"
style={{
"-webkit-mask-image":
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
"-webkit-mask-composite": "source-in",
"mask-image":
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
"mask-composite": "intersect",
}}
/>
<Show when={presence.present()}>
<div
ref={(element) => setElements("dropzone", element)}
data-component="session-dropzone"
data-visible={props.active}
class="pointer-events-none absolute inset-0 z-[80] grid place-items-center overflow-hidden rounded-[inherit] bg-[color-mix(in_srgb,var(--v2-text-text-base)_var(--session-dropzone-wash),transparent)] opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:opacity-0 motion-reduce:transition-none"
>
<div class="absolute inset-0 bg-v2-background-bg-base/25" />
<div
class="absolute inset-y-0 left-1/2 w-full -translate-x-1/2 md:max-w-200 2xl:max-w-[1000px]"
style={{
"-webkit-mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
"mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
}}
>
<div
data-slot="session-dropzone-stripes"
class="absolute inset-0 opacity-60"
style={{
background:
"repeating-linear-gradient(135deg, transparent 0px, transparent 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 24px)",
"-webkit-mask-image":
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
"mask-image":
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
}}
/>
</div>
<div
data-slot="session-dropzone-content"
class="relative flex translate-y-0 flex-col items-center gap-5 opacity-100 transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:translate-y-1 data-[visible=false]:opacity-0 motion-reduce:transition-none"
data-visible={props.active}
>
<div
data-slot="session-dropzone-upload"
class="flex size-10 items-center justify-center rounded-full bg-[var(--session-dropzone-card)] text-v2-icon-icon-muted shadow-[var(--v2-elevation-floating)]"
aria-hidden="true"
>
<Icon name="arrow-up" size="normal" class="text-v2-icon-icon-muted" />
</div>
<div class="text-[15px] font-[530] leading-6 text-v2-text-text-base">{presence.value()}</div>
</div>
</div>
</Show>
</>
)
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerPersistedState } from "../types"
import { createComposerEditorActions } from "./actions"
@@ -97,4 +98,26 @@ describe("Composer store", () => {
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
})
test("prepends a slash skill to an attachment-only draft without flattening it", () => {
const prompt = createPromptStore()
prompt.setPrompt([{ type: "file", path: "one", content: "@one", start: 0, end: 4 }], 4)
prompt.addMention(
{
type: "skill",
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
content: "/show-me",
start: 0,
end: 0,
},
{ start: 0, end: 0 },
)
expect(prompt.state.prompt).toMatchObject([
{ type: "skill", id: "show-me", content: "/show-me", start: 0, end: 8 },
{ type: "text", content: " ", start: 8, end: 9 },
{ type: "file", path: "one", content: "@one", start: 9, end: 13 },
])
expect(prompt.state.cursor).toBe(9)
})
})
+9 -3
View File
@@ -69,12 +69,15 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
clearRetry()
},
addMention(mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart) {
addMention(
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
range?: { start: number; end: number },
) {
const text = store()
.prompt.map((part) => ("content" in part ? part.content : ""))
.join("")
const end = store().cursor ?? text.length
const start = text.slice(0, end).lastIndexOf("@")
const end = range?.end ?? store().cursor ?? text.length
const start = range?.start ?? text.slice(0, end).lastIndexOf("@")
setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention))
setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1)
clearRetry()
@@ -113,6 +116,9 @@ function insertMention(
end: number,
mention: ComposerFilePart | ComposerAgentPart | ComposerSkillPart,
): ComposerPrompt {
if (start === 0 && end === 0) {
return withOffsets([mention, { type: "text", content: " ", start: 0, end: 0 }, ...prompt])
}
let position = 0
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (part.type === "image") return [part]
@@ -2,7 +2,36 @@
content: "\200B";
}
/* Masks only cover hidden content; they do not reserve space in the toolbar. */
[data-slot="composer-controls"][data-overflow-start="true"] {
--mask-start: 16px;
}
[data-slot="composer-controls"][data-overflow-end="true"] {
--mask-end: 16px;
}
[data-slot="composer-controls"]:dir(rtl) {
--mask-direction: to left;
}
[data-slot="composer-controls"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
transparent,
black var(--mask-start, 0px),
black calc(100% - var(--mask-end, 0px)),
transparent
);
}
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
opacity: 0;
pointer-events: none;
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="session-composer-dock"] [data-component="composer"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
+92 -66
View File
@@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -56,6 +57,23 @@ export function ComposerEditor(props: ComposerEditorProps) {
const view = props.controller.view
let editor: HTMLDivElement | undefined
let viewport: HTMLDivElement | undefined
let controlsViewport!: HTMLDivElement
let controlsContent!: HTMLDivElement
const [overflow, setOverflow] = createStore({ start: false, end: false })
const updateOverflow = () => {
const offset = Math.abs(controlsViewport.scrollLeft)
setOverflow({
start: offset > 1,
end: controlsViewport.scrollWidth - controlsViewport.clientWidth - offset > 1,
})
}
onMount(() => {
const observer = new ResizeObserver(updateOverflow)
observer.observe(controlsViewport)
observer.observe(controlsContent)
updateOverflow()
onCleanup(() => observer.disconnect())
})
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
@@ -118,7 +136,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
onSubmit={(event) => {
event.preventDefault()
@@ -129,12 +146,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
onDragLeave={props.controller.onDragLeave}
onDrop={props.controller.onDrop}
>
<Show when={state.drag === "active"}>
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
{i18n.t("ui.promptInput.dropFiles")}
</div>
</Show>
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
@@ -233,7 +244,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
<div class="flex h-11 items-center px-2">
<div
class="flex min-w-0 flex-1 items-center gap-1"
class="flex shrink-0 items-center"
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
@@ -252,63 +263,80 @@ export function ComposerEditor(props: ComposerEditorProps) {
onContext={props.controller.openContext}
onShell={props.controller.openShell}
/>
<Show when={view.agent} keyed>
{(control) => (
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
</div>
<div
ref={controlsViewport}
data-slot="composer-controls"
data-overflow-start={overflow.start}
data-overflow-end={overflow.end}
class="ms-1 me-3 h-full min-w-0 flex-1 overflow-x-auto overscroll-x-contain no-scrollbar"
onScroll={updateOverflow}
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
>
<div ref={controlsContent} class="flex h-full w-max min-w-full items-center gap-1">
<Show when={view.agent} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
/>
</Show>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseAgent")}
keybind={["Mod", "."]}
control={control}
/>
)}
</Show>
</Show>
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
class={control.current() === "default" ? "composer-variant-default" : undefined}
/>
</Show>
)}
</Show>
</Show>
</div>
</div>
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
<div data-slot="composer-actions" class="flex shrink-0 items-center">
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onStop={props.controller.stop}
/>
</div>
</div>
</form>
</div>
@@ -603,12 +631,14 @@ function ComposerEditorConfiguredSelect(props: {
keybind?: string[]
control: ComposerSelectControl
model?: boolean
class?: string
}) {
const current = () => props.control.current()
const providerID = () => props.control.options().find((option) => option.id === current())?.providerID
return (
<ComposerEditorSelect
title={props.title}
class={props.class}
keybind={props.control.keybind?.() ?? props.keybind}
options={props.control.options()}
current={current()}
@@ -812,11 +842,7 @@ export function ComposerEditorSubmitButton(props: {
tabIndex={props.mode === "normal" ? undefined : -1}
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
variant="contrast"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
class="size-7 rounded-md p-[6px] disabled:opacity-50"
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
onClick={(event) => {
event.preventDefault()
@@ -145,7 +145,7 @@ export function createComposerEditor(input: {
return
}
if (command.type === "mention.add") {
if (command.item.mention) draft.addMention(command.item.mention)
if (command.item.mention) draft.addMention(command.item.mention, command.range)
return
}
if (command.type === "popover.filter") {
+35 -8
View File
@@ -21,7 +21,7 @@ import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { createComposerSubmit, withSlashSkill } from "./submit"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -42,9 +42,14 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const interaction = createComposerEditorState(prompt.mode.current())
createEffect(
on(adapter.ready, (ready) => {
if (ready) interaction[1]("mode", prompt.mode.current())
}),
on(
() => (adapter.ready() ? prompt.mode.current() : undefined),
(mode) => {
if (!mode) return
// Project external draft changes without another mode write clearing restored retry metadata.
interaction[1](mode === "shell" ? { mode, popover: { type: "closed" } } : { mode })
},
),
)
const mode = () => interaction[0].mode
const history = createComposerHistory()
@@ -236,22 +241,42 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
type: "builtin" as const,
})),
])
const commands = createMemo<ComposerSuggestion[]>(() =>
slashCommands().map((item) => ({
const slashSkills = createMemo(() =>
skills().filter((skill) => skill.slash === true && !slashCommands().some((item) => item.trigger === skill.id)),
)
const commands = createMemo<ComposerSuggestion[]>(() => [
...slashCommands().map((item) => ({
id: item.id,
kind: "command",
kind: "command" as const,
label: `/${item.trigger}`,
trigger: item.trigger,
title: item.title,
description: item.description,
keybind: command.keybindParts(item.id),
})),
)
...slashSkills().map((skill) => ({
id: `skill:${skill.id}`,
kind: "skill" as const,
label: `/${skill.id}`,
trigger: skill.id,
title: skill.name,
description: skill.description,
mention: {
type: "skill" as const,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content: `/${skill.id}`,
start: 0,
end: 0,
},
})),
])
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
skills: slashSkills,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
@@ -346,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
onDragCancel: platform.onDragCancel,
store: platform.draftStore?.putBlob,
},
view: {
@@ -377,6 +403,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
prompt.set(withSlashSkill(prompt.current(), slashSkills()))
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
+132 -5
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Skill } from "@opencode-ai/schema/skill"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
@@ -31,6 +32,16 @@ const selection = {
},
} satisfies ModelSelection
const slashSkill = Skill.Info.make({
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
description: "Explain visually",
slash: true,
autoinvoke: false,
location: AbsolutePath.make("/skills/show-me/SKILL.md"),
content: "Explain visually",
})
function controls(): ComposerControls {
return {
agents: {
@@ -53,11 +64,13 @@ function submitInput(
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
skills: () => readonly Skill.Info[] | undefined = () => [],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
skills,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -109,6 +122,107 @@ function session(input: {
}
describe("Composer submission", () => {
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
{ type: "text", content: "/show-me explain ", start: 0, end: 17 },
{ type: "file", path: "src/cache.ts", content: "@src/cache.ts", start: 17, end: 30 },
])
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [slashSkill],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe("/show-me explain @src/cache.ts")
expect(request.skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
expect(request.files).toEqual([
{ uri: "file:///C:/repo/src/cache.ts", name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } },
])
})
test.each([
{ text: "/show-me", slash: true, expected: ["show-me"] },
{ text: "/show-me\nexplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me\texplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me", slash: false, expected: [] },
{ text: "/show-me", slash: undefined, expected: [] },
{ text: "/show-me-extra", slash: true, expected: [] },
{ text: "Explain /show-me", slash: true, expected: [] },
])("resolves raw slash skill input $text with slash=$slash", async ({ text, slash, expected }) => {
const state = createMemoryComposerState({ prompt: text }).capture()
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [{ ...slashSkill, slash }],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe(text)
expect(request.skills?.map((skill) => skill.id)).toEqual([...expected])
})
test("captures slash skills before creating a session in a new worktree", async () => {
const state = createMemoryComposerState({ prompt: "/show-me" }).capture()
let skills: readonly Skill.Info[] | undefined = [slashSkill]
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
skills = undefined
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => skills,
).submit(new Event("submit"))
expect((await admitted.promise).skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
})
test("sends one captured value with explicit delivery after selection switches", async () => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
const calls: string[] = []
@@ -357,8 +471,8 @@ describe("Composer submission", () => {
expect(promoted.mode.current()).toBe("shell")
})
test("reuses the message ID when an unacknowledged admission is retried", async () => {
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
test.each(["retry me", "/show-me retry me"])("restores and retries an unacknowledged admission: %s", async (text) => {
const state = createMemoryComposerState({ prompt: text }).capture()
const attempts: string[] = []
const statuses: ("idle" | "running")[] = []
const first = Promise.withResolvers<void>()
@@ -368,6 +482,7 @@ describe("Composer submission", () => {
statuses,
prompt: async (value) => {
attempts.push(value.id ?? "")
expect(value.skills?.map((skill) => skill.id)).toEqual(text.startsWith("/") ? ["show-me"] : [])
throw new Error("network unavailable")
},
})
@@ -386,7 +501,13 @@ describe("Composer submission", () => {
missingSelection() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const submission = submitInput(adapter, notify)
const submission = submitInput(
adapter,
notify,
"normal",
() => [],
() => [slashSkill],
)
await submission.submit(new Event("submit"))
await first.promise
@@ -396,7 +517,7 @@ describe("Composer submission", () => {
expect(attempts).toHaveLength(4)
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
expect(state.current()).toMatchObject([{ type: "text", content: text }])
})
test("forwards structured mentions to custom commands", async () => {
@@ -472,7 +593,13 @@ describe("Composer submission", () => {
},
}
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
await submitInput(
adapter,
undefined,
"normal",
() => catalog,
() => [{ ...slashSkill, id: Skill.ID.make("review") }],
).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
expect(requests).toEqual([
+25 -1
View File
@@ -1,5 +1,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionMessageUser, SkillInfo } from "@opencode-ai/client/promise"
import { Skill } from "@opencode-ai/schema/skill"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
@@ -28,6 +29,7 @@ type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
skills: Accessor<readonly Pick<SkillInfo, "id" | "name" | "slash">[] | undefined>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
@@ -68,6 +70,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
if (value.mode === "normal" && !command) value.prompt = withSlashSkill(value.prompt, input.skills())
try {
const started =
@@ -287,6 +290,27 @@ function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text
return { command, arguments: arguments_.join(" ") }
}
export function withSlashSkill(prompt: Prompt, skills: ReturnType<ComposerSubmitInput["skills"]>): Prompt {
const first = prompt[0]
if (first?.type !== "text") return prompt
const name = /^\/(\S+)(?:\s|$)/.exec(first.content)?.[1]
const skill = skills?.find((item) => item.slash === true && item.id === name)
if (!skill || prompt.some((part) => part.type === "skill" && part.id === skill.id)) return prompt
const content = `/${skill.id}`
return [
{
type: "skill",
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content,
start: 0,
end: content.length,
},
{ ...first, content: first.content.slice(content.length), start: content.length },
...prompt.slice(1),
]
}
async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
@@ -35,7 +35,7 @@ export type ComposerInteractionEvent =
export type ComposerInteractionCommand =
| { type: "draft.setText"; value: string }
| { type: "draft.addText"; value: string }
| { type: "mention.add"; item: ComposerSuggestion }
| { type: "mention.add"; item: ComposerSuggestion; range?: { start: number; end: number } }
| { type: "popover.filter"; popover: "command" | "context"; query: string }
| { type: "suggestion.select"; id: string }
| { type: "focus.editor" }
@@ -178,7 +178,13 @@ function suggestionSelected(
: replaceTrigger(current, "/", `${item.label} `),
})
} else {
commands.push({ type: "mention.add", item })
commands.push({
type: "mention.add",
item,
...(item.kind === "skill" && item.label.startsWith("/")
? { range: { start: 0, end: state.popover.type === "command-menu" ? 0 : current.length } }
: {}),
})
}
commands.push({ type: "focus.editor" })
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands)
+8
View File
@@ -34,6 +34,14 @@ export function createHomeController() {
const conn = list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
createEffect(() => {
const ctx = focusedServerCtx()
const id = selectedProject()?.id
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
if (root) void ctx.sync.worktrees.load(root)
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
@@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./records"
@@ -270,14 +270,7 @@ export function createHomeSessionsController(home: HomeController) {
},
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
const project = home.project
.list()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
)
const project = homeProjectForSession(session, home.project.list())
const conn = home.server.focused()
if (!conn) return
const connKey = ServerConnection.key(conn)
@@ -36,4 +36,26 @@ describe("buildHomeSessionRecords", () => {
expect(records.map((record) => record.session.id)).toEqual(["a"])
})
test("labels a worktree session with its project before that project's inventory has loaded", () => {
const records = buildHomeSessionRecords({
sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")],
projectDirectories: () => undefined,
projects: () => [{ ...opened, name: "Project A" }],
})
expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" })
expect(records[0]?.projectName).toBe("Project A")
})
test("prefers the added project whose directory matches over a sibling entry with the same ID", () => {
const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject
const records = buildHomeSessionRecords({
sessions: () => [session("n", "/repo/a/packages/app", "project-a")],
projectDirectories: () => undefined,
projects: () => [opened, nested],
})
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
})
})
+16 -7
View File
@@ -22,13 +22,7 @@ export function buildHomeSessionRecords(input: {
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort(compareSessionTime)
.map((session) => {
const directory = pathKey(session.location.directory)
const project = input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? {
const project = homeProjectForSession(session, input.projects()) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
@@ -36,3 +30,18 @@ export function buildHomeSessionRecords(input: {
return { session, project, projectName: displayName(project) }
})
}
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
// the session's project ID still identifies its added project.
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
session: SessionInfo,
projects: readonly T[],
) {
const directory = pathKey(session.location.directory)
return (
projects.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projects.find((item) => item.id === session.projectID)
)
}
+72
View File
@@ -165,6 +165,15 @@
}
}
::highlight(timeline-search-hit) {
background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent);
}
::highlight(timeline-search-hit-active) {
background-color: var(--v2-icon-icon-accent);
color: var(--v2-background-bg-deep);
}
[data-component="getting-started"] {
container-type: inline-size;
container-name: getting-started;
@@ -319,6 +328,69 @@
}
}
@keyframes session-dropzone-enter {
from {
opacity: 0;
}
}
@keyframes session-dropzone-content-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.96);
}
}
@keyframes session-dropzone-upload-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.92);
}
}
@keyframes session-dropzone-stripes-drift {
to {
background-position: 33.941px 0;
}
}
[data-component="session-dropzone"][data-visible="true"] {
animation: session-dropzone-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"] {
animation: session-dropzone-content-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"] {
animation: session-dropzone-upload-enter 240ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
animation: session-dropzone-stripes-drift 3s linear infinite;
}
[data-component="session-dropzone"] {
--session-dropzone-wash: 4%;
--session-dropzone-stripe: 7%;
--session-dropzone-card: var(--v2-background-bg-base);
}
[data-color-scheme="dark"] [data-component="session-dropzone"] {
--session-dropzone-wash: 6%;
--session-dropzone-stripe: 14%;
--session-dropzone-card: var(--v2-background-bg-layer-01);
}
@media (prefers-reduced-motion: reduce) {
[data-component="session-dropzone"][data-visible="true"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"],
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
animation: none;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
+2 -2
View File
@@ -1,2 +1,2 @@
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
/** Allows the prompt to extend 80px beyond each side of the 720px wordmark. */
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[880px] px-0"
+7 -2
View File
@@ -1,11 +1,11 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { Show, createMemo, createSignal } from "solid-js"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
import type { ComposerModel } from "@/composer/model"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/new-session/workspace/selector"
import {
@@ -22,6 +22,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -54,9 +55,13 @@ export function NewSessionView(props: {
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<ComposerDropzone
active={props.composer.state.drag === "active"}
input={props.composer.model.selection.current()?.capabilities.input}
/>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
<NewSessionWordmark />
<div class="mt-8 flex flex-col gap-8">
<Composer model={props.composer} />
<Show when={props.project.empty()}>
+43
View File
@@ -0,0 +1,43 @@
[data-component="new-session-wordmark"] {
--wordmark-shimmer-opacity: 0.45;
.wordmark-shimmer {
/* Blend above the base logo's opacity so the sweep only adds light in either theme. */
color: white;
mix-blend-mode: screen;
opacity: 0;
mask-image: linear-gradient(to right, transparent 40%, black 50%, transparent 60%);
mask-size: 300% 100%;
mask-repeat: no-repeat;
animation: new-session-wordmark-shimmer 800ms linear both;
}
}
[data-color-scheme="dark"] [data-component="new-session-wordmark"] {
/* Preserve the previous dark highlight strength: 0.16 × 0.7 × 0.6. */
--wordmark-shimmer-opacity: 0.0672;
}
/* Oversizing the mask moves its highlight physically left to right, including in RTL. */
@keyframes new-session-wordmark-shimmer {
0% {
mask-position: 100% 0;
opacity: 0;
}
20%,
80% {
opacity: var(--wordmark-shimmer-opacity);
}
100% {
mask-position: 0% 0;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="new-session-wordmark"] {
.wordmark-shimmer {
animation: none;
}
}
}
@@ -0,0 +1,11 @@
import { NewSessionWordmark } from "./wordmark"
export default {
title: "App/New Session/Wordmark",
id: "app-new-session-wordmark",
component: NewSessionWordmark,
}
export const Reveal = {
render: () => <NewSessionWordmark />,
}
+17
View File
@@ -0,0 +1,17 @@
import { Wordmark } from "@opencode-ai/ui/wordmark"
import "./wordmark.css"
export function NewSessionWordmark() {
return (
<div
data-component="new-session-wordmark"
aria-hidden="true"
class="pointer-events-none mx-auto w-full max-w-[720px] text-v2-background-bg-inverse"
>
<div data-slot="wordmark-reveal" class="relative">
<Wordmark fade={false} class="block h-auto w-full opacity-60 [[data-color-scheme=dark]_&]:opacity-50" />
<Wordmark fade={false} muted={false} class="wordmark-shimmer absolute inset-0 h-auto w-full" />
</div>
</div>
)
}
@@ -0,0 +1,6 @@
import { expect, test } from "bun:test"
import { popularProviders } from "./order"
test("lists OpenCode Go before Zen and other popular providers", () => {
expect(popularProviders.slice(0, 2)).toEqual(["opencode-go", "opencode"])
})
+1 -1
View File
@@ -1,6 +1,6 @@
export const popularProviders = [
"opencode",
"opencode-go",
"opencode",
"anthropic",
"github-copilot",
"openai",
@@ -110,7 +110,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
const all = createMemo(() => {
language.locale()
+1 -1
View File
@@ -13,7 +13,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
+15 -3
View File
@@ -676,7 +676,7 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.moveRunning": "Move to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
@@ -736,6 +736,16 @@ export const dict = {
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"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",
"session.websearch.any": "Any",
"session.websearch.disable": "Dont use search",
"session.websearch.enable": "Enable",
"session.websearch.loadFailed": "Could not load search providers.",
"session.websearch.empty": "No search providers available.",
"session.websearch.failed": "Could not save your choice. Please try again.",
"session.websearch.retry": "Retry",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",
@@ -759,6 +769,8 @@ export const dict = {
"session.header.search.placeholder": "Search {{project}}",
"session.header.searchFiles": "Search files",
"session.search.placeholder": "Find...",
"session.search.noResults": "No matches",
"session.header.openIn": "Open in",
"session.header.open.action": "Open {{app}}",
"session.header.open.ariaLabel": "Open in {{app}}",
@@ -1045,8 +1057,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.showProjectIcon.title": "Project icon",
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
"settings.general.row.showProjectIcon.title": "Show project icon",
"settings.general.row.showProjectIcon.description": "Show project icon in session header",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description": "Place the title bar at the bottom of the screen on mobile",
"settings.general.row.mobileDiffWrap.description":
@@ -59,6 +59,9 @@ type PlatformBase = {
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string
/** Observe native drag cancellation that does not reach the renderer event loop. */
onDragCancel?(callback: () => void): () => void
/** Open a native save file dialog and write content to the selected path (desktop only) */
saveFile?(opts: SaveFilePickerOptions, content: string): Promise<boolean>
+3 -1
View File
@@ -6,6 +6,7 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "./registry"
import { createRefCountMap } from "@/runtime/server/refcount"
import { createRequestQueue } from "@/runtime/server/request-queue"
import { ServerScope } from "@/runtime/server/scope"
import { useServer } from "./current"
@@ -114,8 +115,9 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
readonly api: ServerApi
readonly pty: ReturnType<typeof createPtyClient>
} {
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
const build = (http: ServerConnection.HttpBase) => {
const api = createApiForServer({ server: http, fetch: input.fetch })
const api = createApiForServer({ server: http, fetch: queue.fetch })
return { http, api, pty: createPtyClient(api, { url: http.url }) }
}
const state = { current: build(input.http) }
@@ -6,9 +6,7 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
import { worktreeInventoryKey } from "@/workspaces/inventory"
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
@@ -23,7 +21,6 @@ test("bootstraps projects through the native store setter and preserves subseque
})
if (url.pathname === "/api/project")
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
throw new Error(`Unexpected request: ${url.pathname}`)
},
{ preconnect() {} },
@@ -50,6 +47,18 @@ test("bootstraps projects through the native store setter and preserves subseque
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
expect(store.config).toEqual({})
// A refetch keeps the inventory a view already loaded for this project.
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
expect(store.project[0]?.worktrees).toEqual([
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
} finally {
queryClient.clear()
}
@@ -79,58 +88,39 @@ describe("query keys", () => {
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads projects from the current endpoint", async () => {
const calls: string[] = []
const projects = {
list: async () => [
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
calls.push(projectID)
return [
{ directory: `/${projectID}` },
{ directory: `/${projectID}/clone` },
{ directory: `/${projectID}/copy`, strategy: "git" },
]
},
} as unknown as WorktreeApi
test("loads project metadata without enumerating any project's worktrees", async () => {
const requests: string[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
requests.push(url.pathname)
if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`)
return Response.json([
...Array.from({ length: 300 }, (_, index) => ({
id: `historical-${index.toString().padStart(3, "0")}`,
canonical: `/history/${index}`,
time: { created: 1, updated: 1 },
sandboxes: [],
})),
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] },
{ id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] },
])
},
{ preconnect() {} },
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
["/a/clone", "/a/copy"],
["/b/clone", "/b/copy"],
])
expect(result.map((project) => project.worktrees)).toEqual([
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["a", "b"])
})
test("keeps projects whose directory inventory cannot load", async () => {
const projects = {
list: async () => [
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
if (projectID === "b") throw new Error("unavailable")
return [{ directory: "/a/copy", strategy: "git" as const }]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
{ id: "b", sandboxes: [] },
expect(requests).toEqual(["/api/project"])
expect(result).toHaveLength(302)
expect(result.slice(0, 2)).toMatchObject([
{ id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] },
{ id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] },
])
expect(result.some((project) => project.id === "test")).toBe(false)
})
})
@@ -15,8 +15,7 @@ import { cmp, normalizeProjectInfo } from "./utils"
import { formatServerError } from "@/runtime/server/errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import type { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { sameDirectory } from "@/workspaces/paths"
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
type GlobalStore = {
path: Path
@@ -64,42 +63,21 @@ type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type WorktreeApi = Pick<ServerApi["worktree"], "list">
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory).
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
projects.list().then(async (items) => {
return (
await Promise.all(
items
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ projectID: project.id })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
.filter((directory) => !sameDirectory(project.canonical, directory))
.map((directory) => ({ directory })),
])
return normalizeProjectInfo({
...project,
sandboxes: directories
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.canonical, directory)),
worktrees: directories,
})
}),
)
)
projects.list().then((items) =>
items
.filter((project) => !!project?.id)
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
.sort((a, b) => cmp(a.id, b.id)),
),
),
})
@@ -107,7 +85,6 @@ export async function bootstrapGlobal(input: {
serverAPI: {
readonly location: LocationApi
readonly project: ProjectApi
readonly worktree: WorktreeApi
}
scope: ServerScope
setGlobalStore: SetStoreFunction<GlobalStore>
@@ -117,9 +94,17 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
.then((data) => input.setGlobalStore("project", data)),
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
input.setGlobalStore(
"project",
data.map((project) =>
withWorktreeInventory(
project,
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
),
),
),
),
]
await runAll(slow)
}
@@ -0,0 +1,175 @@
import { describe, expect, test } from "bun:test"
import { createRequestQueue, isSlowRequest } from "./request-queue"
function setup(input?: { limit?: number; slowLimit?: number; stallMs?: number; headersTimeoutMs?: number }) {
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
let clock = 0
const queue = createRequestQueue({
limit: input?.limit ?? 2,
slowLimit: input?.slowLimit,
stallMs: input?.stallMs,
headersTimeoutMs: input?.headersTimeoutMs,
now: () => clock,
log: (message, data) => logs.push({ message, data }),
fetch: Object.assign(
(resource: RequestInfo | URL) =>
new Promise<Response>((resolve, reject) => {
const request = new Request(resource)
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
}),
{ preconnect() {} },
),
})
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) }
}
describe("createRequestQueue", () => {
test("caps concurrent requests and starts queued ones as slots free up", async () => {
const input = setup()
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"])
expect(input.queue.queued()).toBe(1)
input.pending[0]!.resolve()
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"])
input.pending.forEach((item) => item.resolve())
await Promise.all(responses)
expect(input.queue.inflight()).toBe(0)
})
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
const input = setup({ limit: 4, slowLimit: 2 })
const paths = ["/api/vcs?location[directory]=%2Fa", "/api/vcs/diff?location[directory]=%2Fa", "/api/worktree", "/api/session/ses_1"]
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
const started = () => input.pending.map((item) => new URL(item.url).pathname)
// Two slow requests fill the slow share; the worktree read waits while the session read jumps ahead.
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
expect(input.queue.inflight()).toBe(3)
expect(input.queue.queued()).toBe(1)
// A fast request finishing does not free a slow slot.
input.pending[2]!.resolve()
await input.settle()
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
input.pending[0]!.resolve()
await input.settle()
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1", "/api/worktree"])
input.pending.forEach((item) => item.resolve())
await Promise.all(responses)
expect(input.queue.inflight()).toBe(0)
})
test("classifies git and worktree endpoints as slow", () => {
expect(isSlowRequest("/api/vcs")).toBe(true)
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
expect(isSlowRequest("/api/worktree")).toBe(true)
expect(isSlowRequest("/api/vcsx")).toBe(false)
expect(isSlowRequest("/api/session")).toBe(false)
})
test("never counts the event stream against the budget", async () => {
const input = setup({ limit: 1 })
void input.queue.fetch("http://server/api/session")
void input.queue.fetch("http://server/api/event")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"])
expect(input.queue.inflight()).toBe(1)
})
test("aborted requests leave the queue without being sent", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
void input.queue.fetch("http://server/api/first")
const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal })
controller.abort()
await input.settle()
input.pending[0]!.resolve()
await expect(aborted).rejects.toBeInstanceOf(DOMException)
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"])
expect(input.queue.inflight()).toBe(0)
})
test("a request the server never answers times out and frees its slot", async () => {
const input = setup({ limit: 1, headersTimeoutMs: 10 })
const dead = input.queue.fetch("http://server/api/dead")
const next = input.queue.fetch("http://server/api/next")
await input.settle()
expect(input.queue.queued()).toBe(1)
const error = await dead.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(DOMException)
expect((error as DOMException).name).toBe("TimeoutError")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
input.pending[1]!.resolve()
await expect(next).resolves.toBeInstanceOf(Response)
expect(input.queue.inflight()).toBe(0)
})
test("caller aborts still reach the underlying request", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
const request = input.queue.fetch("http://server/api/slow", { signal: controller.signal })
await input.settle()
expect(input.pending[0]!.signal.aborted).toBe(false)
controller.abort()
expect(input.pending[0]!.signal.aborted).toBe(true)
await expect(request).rejects.toBeInstanceOf(DOMException)
expect(input.queue.inflight()).toBe(0)
})
test("a burst that drains promptly is not thrashing", async () => {
const input = setup({ stallMs: 5 })
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
await input.settle()
expect(input.queue.queued()).toBe(10)
// Drain two at a time before the stall threshold elapses.
for (let round = 0; round < 6; round++) {
input.pending.splice(0).forEach((item) => item.resolve())
await input.settle()
}
await Promise.all(responses)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toEqual([])
})
test("logs what is in flight and queued once per burst after requests stall", async () => {
const input = setup({ stallMs: 5 })
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined)
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined)
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/health").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toEqual([
{
message: "server thrashing detected",
data: {
limit: 2,
inflight: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 },
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 },
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
],
},
},
])
// Still stalled within the rate limit: no repeat.
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toHaveLength(1)
input.tick(10_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toHaveLength(2)
})
})
@@ -0,0 +1,120 @@
type Entry = { method: string; url: string; at: number; slow: boolean }
// Chromium allows six connections per origin. The event stream holds one for the life of the
// connection and health probes use their own fetch, so the app's API calls stay below that or
// a burst stalls probes and user actions inside the browser where nothing can observe it.
export const requestQueueLimit = 4
// Endpoints that shell out to git or walk the filesystem take seconds on a large repository. They
// may hold at most this many slots, so a session mount's small reads never queue behind them.
export const requestQueueSlowLimit = 2
export const slowRequestPaths = ["/api/vcs", "/api/worktree"]
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
// for a slot indicates the server is not keeping up.
export const requestStallMs = 2_000
// A socket that dies while the device sleeps can leave fetch waiting for response headers until the
// OS gives up on TCP retransmits, which takes minutes. Bound that so a dead request frees its slot
// instead of wedging every later API call; the body may still stream for as long as it needs.
export const requestHeadersTimeoutMs = 60_000
export function isSlowRequest(pathname: string) {
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
}
export function createRequestQueue(input: {
fetch: typeof globalThis.fetch
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
log?: (message: string, data: Record<string, unknown>) => void
now?: () => number
}) {
const limit = input.limit ?? requestQueueLimit
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
const stallMs = input.stallMs ?? requestStallMs
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
const base = input.fetch
const now = input.now ?? Date.now
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
const inflight = new Set<Entry>()
const waiting: Array<{ entry: Entry; start: () => void }> = []
let warned = -Infinity
let watcher: ReturnType<typeof setTimeout> | undefined
const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at })
// Debug exports include the console, so list what the server is busy with while requests wait.
const watch = () => {
watcher = undefined
const oldest = waiting[0]?.entry
if (!oldest) return
if (now() - oldest.at >= stallMs && now() - warned >= 10_000) {
warned = now()
log("server thrashing detected", {
limit,
inflight: [...inflight].map(describe),
queued: waiting.map((item) => describe(item.entry)),
})
}
watcher = setTimeout(watch, stallMs)
}
const canStart = (entry: Entry) => {
if (inflight.size >= limit) return false
if (!entry.slow) return true
return [...inflight].filter((item) => item.slow).length < slowLimit
}
// FIFO, except a slow request waits its turn behind faster ones while the slow slots are full.
const release = (entry: Entry) => {
inflight.delete(entry)
const index = waiting.findIndex((item) => canStart(item.entry))
if (index === -1) return
waiting.splice(index, 1)[0]?.start()
}
const acquire = (entry: Entry) =>
new Promise<void>((resolve) => {
const start = () => {
entry.at = now()
inflight.add(entry)
resolve()
}
if (canStart(entry)) return start()
waiting.push({ entry, start })
watcher ??= setTimeout(watch, stallMs)
})
const fetch: typeof globalThis.fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(resource, init)
const pathname = new URL(request.url).pathname
// The event stream is long-lived; never count it against the request budget.
if (pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
await acquire(entry)
if (request.signal.aborted) {
release(entry)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
}
const controller = new AbortController()
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
const timer = setTimeout(
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
headersTimeoutMs,
)
return base(new Request(request, { signal: controller.signal })).finally(() => {
clearTimeout(timer)
release(entry)
})
},
// Bun's fetch type carries preconnect; the browser never calls it.
{ preconnect: () => {} },
)
return {
fetch,
inflight: () => inflight.size,
queued: () => waiting.length,
}
}
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createConnectionSync } from "./connection"
import { createConnectionSync, reconnectOrder } from "./connection"
test("invalidates disconnected data and synchronizes after the handshake", () => {
const calls: string[] = []
@@ -19,3 +19,9 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
})
dispose()
})
test("held directories refresh before the rest, otherwise keeping their order", () => {
const held = new Set(["/b", "/d"])
expect(reconnectOrder(["/a", "/b", "/c", "/d"], (directory) => held.has(directory))).toEqual(["/b", "/d", "/a", "/c"])
expect(reconnectOrder(["/a", "/c"], (directory) => held.has(directory))).toEqual(["/a", "/c"])
})
@@ -20,3 +20,8 @@ export function createConnectionSync(input: {
return { handleEvent }
}
// Directories a mounted view holds refresh first; the rest keep their existing order behind them.
export function reconnectOrder(directories: string[], held: (directory: string) => boolean) {
return [...directories.filter(held), ...directories.filter((directory) => !held(directory))]
}
+33 -11
View File
@@ -17,9 +17,11 @@ import type { ServerScope } from "@/runtime/server/scope"
import { persisted } from "@/runtime/persistence/storage"
import type { ServerApi } from "@/runtime/server/api"
import { toggleMcp } from "./global-sync/mcp"
import { createConnectionSync } from "./server-sync/connection"
import { createConnectionSync, reconnectOrder } from "./server-sync/connection"
import { usePlatform } from "@/runtime/platform/platform"
import type { Data } from "@opencode-ai/client/solid"
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
import { sameDirectory } from "@/workspaces/paths"
type GlobalStore = {
path: Path
@@ -79,6 +81,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const queryClient = useQueryClient()
const worktrees = createWorktreeInventory({
scope: serverSDK.scope,
queryClient,
api: () => serverSDK.api.worktree,
updated: (directory, items) =>
setGlobalStore("project", (projects) =>
projects.map((project) =>
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
),
),
})
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
@@ -149,12 +162,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
},
connected: (info) => {
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
Object.keys(children.children)
.filter(children.active)
.forEach((directory) => {
queue.push(directory)
void data.location.sync({ directory }).catch(() => undefined)
})
// The refresh queue re-syncs two directories at a time, held ones first. Syncing every active
// directory here as well sent the whole catalog fan-out for all of them at once.
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach(
(directory) => queue.push(directory),
)
},
})
@@ -197,17 +209,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setGlobalStore("project", (projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
projects.map((project) =>
project.id === update.id
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
: project,
),
)
}
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (event.type === "project.updated") applyProjectUpdate(event.data)
if (event.type === "worktree.updated") {
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
if (root) void worktrees.refresh(root)
void bootstrap.refetch()
return
}
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
bootstrap.refetch()
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
return
}
@@ -216,7 +238,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (!children.children[key]) return
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
})
onCleanup(unsub)
@@ -261,6 +282,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
worktrees,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
+2 -4
View File
@@ -4,16 +4,14 @@ import { useComposerState } from "@/composer/persistence"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { SessionModel } from "../model"
export function createActiveComposerAdapter(input: {
session: SessionModel
sessionID: string
controls: Accessor<ComposerControls>
submitted: () => void
setEditor: (element: HTMLDivElement) => void
}) {
const id = input.session.identity.params.id
if (!id) throw new Error("Active Composer requires a Session ID")
const id = input.sessionID
const prompt = useComposerState()
prompt.current()
@@ -0,0 +1,57 @@
import { createEffect, createMemo, on, type Accessor } from "solid-js"
import type { ComposerControls } from "@/composer/adapter"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerModel } from "@/composer/model"
import { useSettings } from "@/settings/model"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
import { createSessionComposerRegionController } from "./session-composer-region-controller"
export function createSessionComposerController(input: {
sessionID: string
controls: Accessor<ComposerControls>
dock: Parameters<typeof createSessionComposerRegionController>[0]
}) {
const settings = useSettings()
const region = createSessionComposerRegionController(input.dock)
let editor: HTMLDivElement | undefined
const adapter = createActiveComposerAdapter({
sessionID: input.sessionID,
controls: input.controls,
submitted: region.onResponseSubmit,
setEditor: (element) => {
editor = element
region.setPromptRef(element)
},
})
const queue = createSessionQueue({
sessionID: input.sessionID,
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
restoreFocus: (cursor) => {
const target = editor
if (!target) return
requestAnimationFrame(() => {
target.focus()
setCursorPosition(target, cursor)
})
},
})
const composer = createComposerModel(adapter, { queue })
const editable = createMemo(() => region.showComposer() && !region.child())
// Requests hide the view without disposing its draft or queue edit.
createEffect(on(editable, () => composer.onDragLeave()))
return {
region,
queue,
composer,
drop: {
active: () => editable() && composer.state.drag === "active",
input: () => composer.model.selection.current()?.capabilities.input,
},
}
}
export type SessionComposerController = ReturnType<typeof createSessionComposerController>
+5 -6
View File
@@ -4,7 +4,6 @@ import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerModel } from "@/composer/model"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
@@ -30,7 +29,7 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
composer: Accessor<ComposerModel | undefined>
restoreFocus: (cursor: number) => void
}) {
const data = useData()
const server = useServerSDK()
@@ -159,9 +158,9 @@ export function createSessionQueue(input: {
},
})
const text = queuedPromptText(item)
input.composer()?.dispatch({ type: "mode.normal" })
input.draft.mode.set("normal")
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
input.composer()?.restoreFocus(text.length)
input.restoreFocus(text.length)
return true
}
const cancelEdit = () => {
@@ -170,10 +169,10 @@ export function createSessionQueue(input: {
setState("editing", undefined)
// Mode first, then prompt, then retry: mode and prompt writes both clear
// the retry marker.
input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" })
input.draft.mode.set(editing.stash.mode)
input.draft.set(editing.stash.prompt, editing.stash.cursor)
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
input.composer()?.restoreFocus(editing.stash.cursor)
input.restoreFocus(editing.stash.cursor)
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
+31 -62
View File
@@ -4,9 +4,8 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, on, onMount } from "solid-js"
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
import { Composer } from "@/composer/composer"
import { createComposerModel, type ComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
import { setCursorPosition } from "@/composer/editor/dom"
@@ -25,18 +24,16 @@ import { restorePromptModel, syncPromptModel, syncSessionModel } from "../sessio
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
import { createSessionComposerRegionController } from "./session-composer-region-controller"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
import { createSessionComposerController, type SessionComposerController } from "./controller"
import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { useSettings } from "@/settings/model"
export function createActiveSessionRegion(input: {
session: SessionModel
screen: SessionScreenLayout
timeline: SessionTimelineInteraction
visible: Accessor<boolean>
}) {
const command = useCommand()
const dialog = useDialog()
@@ -180,7 +177,30 @@ export function createActiveSessionRegion(input: {
},
])
const dock = {
state,
parentID: input.session.data.parentID,
centered: input.screen.centered,
onResponseSubmit: input.timeline.actions.resume,
openParent,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
setDockRef: input.timeline.view.setDockRef,
}
const active = createMemo(
on(
() => (input.visible() ? input.session.identity.sessionID() : undefined),
(sessionID) => (sessionID ? createSessionComposerController({ sessionID, controls, dock }) : undefined),
),
)
return {
active,
drop: {
active: () => active()?.drop.active() ?? false,
input: () => active()?.drop.input(),
},
actions: {
timeline: {
get revert() {
@@ -190,76 +210,25 @@ export function createActiveSessionRegion(input: {
openAttachment,
} satisfies SessionUserActions,
},
region: {
centered: input.screen.centered,
openParent,
prompt,
setDockRef: input.timeline.view.setDockRef,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
state,
},
input: {
controls,
setPromptRef: (element: HTMLDivElement) => {
promptRef = element
},
},
submitted: () => input.timeline.actions.resume(),
requests: state,
workspaceMoveEligible: () => true,
}
}
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: {
model: ActiveSessionRegionModel
session: SessionModel
onResponseSubmit: () => void
}) {
const settings = useSettings()
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
centered: props.model.region.centered,
onResponseSubmit: props.onResponseSubmit,
openParent: props.model.region.openParent,
setPromptRef: props.model.region.setPromptRef,
setDockRef: props.model.region.setDockRef,
})
const adapter = createActiveComposerAdapter({
session: props.session,
controls: props.model.input.controls,
submitted: props.model.submitted,
setEditor: props.model.input.setPromptRef,
})
let composer: ComposerModel | undefined
const queue = createSessionQueue({
sessionID: requireSessionID(props.session),
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
composer: () => composer,
})
composer = createComposerModel(adapter, { queue })
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
return (
<SessionComposerRegion
controller={region}
controller={props.model.region}
composer={
<div class="relative">
<SessionQueuePanel queue={queue} />
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay />
<Composer model={props.model.composer} borderUnderlay />
</div>
</div>
}
/>
)
}
function requireSessionID(session: SessionModel) {
const id = session.identity.params.id
if (!id) throw new Error("Active Composer requires a Session ID")
return id
}
@@ -2,11 +2,12 @@ import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionWebSearchDock } from "@/session/requests/session-websearch-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
"questionRequest" | "websearch" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
>
export type SessionComposerRegionViewController = Pick<
@@ -32,6 +33,9 @@ export function SessionComposerRegion(props: {
"md:max-w-[1000px] md:mx-auto": controller.centered(),
}}
>
<Show when={controller.state.websearch.request()}>
<SessionWebSearchDock model={controller.state.websearch} onSubmit={controller.onResponseSubmit} />
</Show>
<Show when={controller.state.questionRequest()} keyed>
{(request) => (
<div>
+62 -23
View File
@@ -24,6 +24,11 @@ import {
type FileTreeV2Node,
} from "@/session/files/file-tree-v2-model"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
export type { Kind } from "@/session/files/file-tree"
@@ -99,7 +104,7 @@ const FileTreeNodeV2 = (
{...rest}
>
{local.children}
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<span data-slot="file-tree-v2-label" class="flex-1 shrink-0 text-start text-12-medium whitespace-nowrap">
<bdi dir="auto">
{local.node.type === "directory"
? normalizeFileTreeV2Path(local.node.path).split("/").at(-1)
@@ -136,6 +141,9 @@ export default function FileTreeV2(props: {
onFileDoubleClick?: (file: FileNode) => void
}) {
const file = useFile()
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
@@ -217,6 +225,19 @@ export default function FileTreeV2(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
rows()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -235,6 +256,7 @@ export default function FileTreeV2(props: {
top: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
@@ -244,29 +266,36 @@ export default function FileTreeV2(props: {
<Show
when={row().node.type === "directory"}
fallback={
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
<OpenInAppContextMenuV2
state={openIn}
path={() =>
resolveOpenInAppPath(location().directory, row().node.absolute || row().node.originalPath)
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
</OpenInAppContextMenuV2>
}
>
<FileTreeNodeV2
@@ -303,3 +332,13 @@ export default function FileTreeV2(props: {
</div>
)
}
export function syncFileTreeV2Width(element?: HTMLDivElement) {
if (!element) return
queueMicrotask(() => {
if (!element.isConnected) return
const width = Math.max(element.clientWidth, ...Array.from(element.children, (child) => child.scrollWidth))
if (width <= element.clientWidth) return
element.style.width = `${width}px`
})
}
+56 -34
View File
@@ -2,10 +2,15 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/file-tree.css"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/session/files/file-tree-v2"
import { kindChange, kindLabel, syncFileTreeV2Width, type Kind } from "@/session/files/file-tree-v2"
import { normalizePath } from "@/session/review/review-diff-kinds"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
// Drives the highlight/selection of the flat search-result list from the filter
// input's keyboard events.
@@ -49,6 +54,9 @@ export function SessionFileList(props: {
onFileClick: (path: string) => void
onFileDoubleClick?: (path: string) => void
}) {
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
const normalized = createMemo(() => props.files.map(normalizePath))
@@ -89,6 +97,19 @@ export function SessionFileList(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
normalized()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -114,47 +135,48 @@ export function SessionFileList(props: {
style={{
position: "absolute",
top: "0",
left: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-left: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
<Show when={directory()}>
<OpenInAppContextMenuV2 state={openIn} path={() => resolveOpenInAppPath(location().directory, path)}>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-inline-start: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span data-slot="file-tree-v2-label" class="flex flex-1 shrink-0 items-center whitespace-nowrap">
<Show when={directory()}>
{(value) => <span class="text-12-medium text-text-muted shrink-0">{value()}</span>}
</Show>
<span class="text-12-medium text-text-base shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
</button>
</button>
</OpenInAppContextMenuV2>
</div>
)}
</Show>
@@ -1,4 +1,4 @@
import { For, Show } from "solid-js"
import { createSignal, For, Show, type ParentProps } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -10,7 +10,7 @@ import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
export function OpenInAppButton(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp(props)
const state = useOpenInApp({ path: props.directory })
return (
<Show when={props.directory() && state.canOpen()}>
@@ -25,7 +25,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
state.openDir(state.current().id)
state.openPath(state.current().id)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
@@ -52,42 +52,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="open-in-app-v2-menu">
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Menu.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
<OpenInAppMenuItemsV2 state={state} close={() => state.setMenu("open", false)} />
</Menu.Content>
</Menu.Portal>
</Menu>
@@ -95,3 +60,116 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Show>
)
}
type OpenInAppState = ReturnType<typeof useOpenInApp>
function OpenInAppMenuItemsV2(props: {
state: OpenInAppState
path?: () => string
reveal?: boolean
selection?: boolean
close?: () => void
}) {
const language = useLanguage()
const path = () => props.path?.()
return (
<>
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Show
when={props.selection !== false}
fallback={
<For each={props.state.options()}>
{(option) => (
<Menu.Item
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.Item>
)}
</For>
}
>
<Menu.RadioGroup
value={props.state.current().id}
onChange={(value) => {
props.state.selectApp(value as OpenApp)
}}
>
<For each={props.state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
closeOnSelect
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Show>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
props.close?.()
props.state.copyPath(path())
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
</>
)
}
export function OpenInAppContextMenuV2(
props: ParentProps<{
state?: OpenInAppState
path: () => string
}>,
) {
const state = props.state
if (!state) return props.children
const [open, setOpen] = createSignal(false)
return (
<Show when={state.canOpen() && props.path()} fallback={props.children}>
<Menu.Context modal={false} onOpenChange={setOpen}>
<Menu.Context.Trigger
as="div"
class="h-full w-full min-w-max"
data-slot="file-tree-v2-context-trigger"
data-context-menu-open={open() ? "" : undefined}
>
{props.children}
</Menu.Context.Trigger>
<Menu.Context.Portal>
<Menu.Context.Content class="open-in-app-v2-menu">
<OpenInAppMenuItemsV2
state={state}
path={props.path}
reveal
selection={false}
close={() => setOpen(false)}
/>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</Show>
)
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { openInAppParentPath, resolveOpenInAppPath } from "./open-in-app-path"
describe("resolveOpenInAppPath", () => {
test("joins relative paths using the workspace separator", () => {
expect(resolveOpenInAppPath("/workspace/project", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:\\workspace\\project", "src/file.ts")).toBe("C:\\workspace\\project\\src\\file.ts")
})
test("does not duplicate root separators", () => {
expect(resolveOpenInAppPath("/workspace/project/", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:/workspace/project/", "src\\file.ts")).toBe("C:/workspace/project/src/file.ts")
})
test("preserves backslashes in POSIX filenames", () => {
expect(resolveOpenInAppPath("/workspace", "src\\file.ts")).toBe("/workspace/src\\file.ts")
expect(resolveOpenInAppPath("/workspace", "\\file.ts")).toBe("/workspace/\\file.ts")
})
test("preserves absolute POSIX, Windows, and UNC paths", () => {
expect(resolveOpenInAppPath("/workspace", "/tmp/file.ts")).toBe("/tmp/file.ts")
expect(resolveOpenInAppPath("C:/workspace", "D:\\src\\file.ts")).toBe("D:\\src\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\\\server\\share\\file.ts")).toBe("\\\\server\\share\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\src\\file.ts")).toBe("\\src\\file.ts")
})
})
describe("openInAppParentPath", () => {
test("preserves POSIX and Windows roots", () => {
expect(openInAppParentPath("/file.ts")).toBe("/")
expect(openInAppParentPath("/workspace/file.ts")).toBe("/workspace")
expect(openInAppParentPath("C:\\file.ts")).toBe("C:\\")
expect(openInAppParentPath("C:\\workspace\\file.ts")).toBe("C:\\workspace")
expect(openInAppParentPath("\\\\server\\share\\file.ts")).toBe("\\\\server\\share")
})
})
@@ -0,0 +1,19 @@
export function resolveOpenInAppPath(root: string, path: string) {
if (!path) return root
const windowsRoot = root.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(root)
if (path.startsWith("/") || (windowsRoot && path.startsWith("\\")) || /^[A-Za-z]:[\\/]/.test(path)) return path
if (!root) return path
const separator = root.includes("\\") ? "\\" : "/"
const relative = windowsRoot ? path.replace(/^[\\/]+/, "") : path
return `${root.replace(/[\\/]+$/, "")}${separator}${windowsRoot ? relative.replaceAll(separator === "\\" ? "/" : "\\", separator) : relative}`
}
export function openInAppParentPath(path: string) {
const value = path.replace(/[\\/]+$/, "")
const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"))
if (index < 0) return path
if (index === 0) return value.slice(0, 1)
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
return value.slice(0, index)
}
+30 -21
View File
@@ -7,6 +7,8 @@ import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
export const OPEN_APPS = [
"vscode",
@@ -32,6 +34,8 @@ export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
export const MAC_OPEN_APPS = [
{
id: "vscode",
@@ -108,9 +112,7 @@ export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenA
}
export function openAppFileManager(os: OpenAppOS) {
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
return fileManagerApp(os)
}
export function openAppsForOS(os: OpenAppOS) {
@@ -127,7 +129,7 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
})
}
export function useOpenInApp(input: { directory: () => string }) {
export function useOpenInApp(input: { path: () => string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
@@ -149,12 +151,7 @@ export function useOpenInApp(input: { directory: () => string }) {
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
list.map((app) => checkAppExists(platform, app.openWith).then((ok) => [app.id, ok] as const)),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
@@ -189,33 +186,35 @@ export function useOpenInApp(input: { directory: () => string }) {
setPrefs("app", app)
}
const openDir = (app: OpenApp | "finder") => {
const openPath = (app: OpenApp | "finder", target = input.path(), reveal = false) => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = input.directory()
if (!directory) return
if (!target) return
const open = (path: string, openWith?: string) => platform.openPath!(path, openWith)
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
const request =
app === "finder" && reveal && platform.revealPath
? platform.revealPath(target).then((revealed) => (revealed ? undefined : open(openInAppParentPath(target))))
: open(target, openWith)
request
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = input.directory()
if (!directory) return
const copyPath = (target = input.path()) => {
if (!target) return
navigator.clipboard
.writeText(directory)
.writeText(target)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: directory,
description: target,
})
})
.catch((err: unknown) => showRequestError(language, err))
@@ -228,8 +227,18 @@ export function useOpenInApp(input: { directory: () => string }) {
options,
menu,
setMenu,
openDir,
openPath,
selectApp,
copyPath,
}
}
function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
const cached = appExistence.get(app)
if (cached) return cached
const request = Promise.resolve(platform.checkAppExists?.(app))
.then(Boolean)
.catch(() => false)
appExistence.set(app, request)
return request
}
@@ -3,11 +3,9 @@ import { useLocation } from "@solidjs/router"
import { ComposerEditor } from "@/composer/editor/editor"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerEditor } from "@/composer/editor/interaction"
import { useLanguage } from "@/runtime/i18n/language"
import type { PendingSession } from "@/shell/tabs/tabs"
export function PreparingComposer(props: { pending: PendingSession }) {
const language = useLanguage()
const location = useLocation()
let element: HTMLElement | undefined
const editor = createComposerEditor({
@@ -20,7 +18,6 @@ export function PreparingComposer(props: { pending: PendingSession }) {
},
view: {
draftOnly: true,
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
},
})
+35 -5
View File
@@ -7,7 +7,8 @@ import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
import { createWebSearchRequest } from "./websearch"
import { createSessionBackground } from "@/session/requests/background"
import { useData } from "@/runtime/server/current"
@@ -24,12 +25,40 @@ export function createSessionRequestModel() {
void Promise.all([
data.shell.sync({ directory: sdk().directory }),
data.session.permission.sync(id),
data.session.form.sync(id),
]).catch(() => undefined)
})
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all(
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
).catch(() => undefined)
})
const questionRequest = createMemo((): FormInfo | undefined => {
return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
const formRequest = createMemo((): FormInfo | undefined => {
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
})
const websearch = createWebSearchRequest({
owner: () => params.id,
connected: () => serverSDK.connection.status() === "connected",
request: () => {
const form = formRequest()
return form?.metadata?.kind === "websearch.provider" ? form : undefined
},
providers: async (sessionID) => {
const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
const result = await serverSDK.api.websearch.providers({
location: { directory: session.location.directory, workspace: session.location.workspaceID },
})
return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
},
reply: (input) => data.session.form.reply(input),
events: serverSDK.event,
})
const questionRequest = createMemo(() => {
if (websearch.request()) return
const form = formRequest()
return form?.metadata?.kind === "question" ? form : undefined
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -40,7 +69,7 @@ export function createSessionRequestModel() {
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest()
return !!permissionRequest() || !!questionRequest() || !!websearch.request()
})
const primary = () => {
@@ -96,6 +125,7 @@ export function createSessionRequestModel() {
return {
blocked,
questionRequest,
websearch,
permissionRequest,
permissionResponding,
background: {

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