Compare commits

..

81 Commits

Author SHA1 Message Date
kitlangton d1230689e9 test(tui): await preview tab promotion 2026-09-05 13:06:41 +00:00
kitlangton 595d7ca1f7 test(tui): await consecutive tab closes 2026-09-03 13:07:10 +00:00
Shoubhit Dash 59b29de409 fix(core): detect new ecosystem config roots (#47026) 2026-09-03 16:09:58 +05:30
opencode-agent[bot] 887f319769 fix(desktop): restore compact Windows channel badge (#47016)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 20:19:03 +10:00
opencode-agent[bot] 24f6cb51c8 fix(core): watch new config files and directories (#46925)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-09-03 15:38:28 +05:30
opencode-agent[bot] d2bec894bf fix(app): identify desktop in Console device auth (#47001)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-09-03 11:50:45 +02:00
opencode-agent[bot] d7009156a6 chore: update nix node_modules hashes 2026-09-03 08:56:58 +00:00
opencode-agent[bot] 4cfca7ab68 fix(app): restore terminal application mouse handling (#45140)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-03 16:39:27 +08:00
Luke Parker ae7d9f029d fix(session-ui): preserve configured groups on tool failure (#46998) 2026-09-03 07:30:50 +00:00
Luke Parker 9acdb0be18 fix(session-ui): use standard Used labels for thoughts (#46995) 2026-09-03 07:10:56 +00:00
opencode-agent[bot] b42555cf01 fix: reuse current location for directory browsing (#46970)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 16:45:17 +10:00
Luke Parker cfe9f13963 fix(ui): capitalize notices in used group (#46992) 2026-09-03 06:43:01 +00:00
David Hill 48f246695e fix(app): align new session icon (#46983) 2026-09-03 16:02:01 +10:00
Brendan Allan 4f6060ad94 feat(app): route settings and refine shell styling (#46984) 2026-09-03 13:51:07 +08:00
opencode-agent[bot] bf6ec61a74 fix(app): remove background running indicator (#46972)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 15:40:55 +10:00
Dax Raad cf298f3409 fix(cli): use artifact as client identity 2026-09-03 00:11:34 -04:00
Dax Raad 0089ac9b02 fix(cli): align artifact user agent format 2026-09-03 00:09:48 -04:00
Kit Langton 4680a4aa6f refactor(core): reconcile current watcher policy (#46949) 2026-09-02 23:15:25 -04:00
Luke Parker 88e4ab5735 feat(app): add timeline detail presets and placement controls (#46717) 2026-09-03 13:10:57 +10:00
Kit Langton efefd90443 feat(plugin): add reference editor lookup 2026-09-03 01:59:36 +00:00
Dax Raad 2b87169cc1 feat(tui): polish plugin dialog sizing, actions, and local footer 2026-09-02 21:55:48 -04:00
Kit Langton 962c26bdf2 feat(plugin): add skill editor lookup 2026-09-02 21:55:00 -04:00
David Hill 22de01e84f fix(app): animate subagent card chevron (#46893) 2026-09-03 09:47:53 +08:00
Aiden Cline 8565cb52a1 chore(ai): clean up responses item id comments (#46951) 2026-09-02 20:44:30 -05:00
Kit Langton 050398f51f fix(core): preserve provider identity in catalog updates 2026-09-03 01:44:12 +00:00
Kit Langton 5f1d74fd3f fix(plugin): export Promise ToolEditor 2026-09-03 01:40:03 +00:00
Aiden Cline d9c85d8d95 refactor(ai): resolve responses item ids once at the stream boundary (#46885) 2026-09-02 20:31:00 -05:00
Kit Langton 1c77b1c920 refactor(core): remove unused repository cache success timestamp (#46942) 2026-09-02 21:17:59 -04:00
Kit Langton 27f838f249 fix(client): refresh references for the updated location (#46935) 2026-09-02 21:12:52 -04:00
Kit Langton c992716523 fix(core): activate the initial plugin generation without the reload debounce (#46922) 2026-09-03 01:02:38 +00:00
Kit Langton e402600d92 test(core): keep supervisor reload tests offline (#46939) 2026-09-02 21:02:16 -04:00
opencode-agent[bot] 1e4e9c5d85 fix(cli): disable bytecode until Bun 1.4.1 (#46933)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 10:43:09 +10:00
Kit Langton f34b74b1dc test(core): refuse network in the test harness (#46908) 2026-09-02 20:41:29 -04:00
Kit Langton b257d476c9 refactor(core): preserve normalized tool results (#46927) 2026-09-02 20:39:55 -04:00
Kit Langton 5e38101fba refactor(client): derive session inputs from pending items (#46926) 2026-09-02 20:24:35 -04:00
mayamika 4beaffbda9 fix(app): show review diffs for non-git VCS backends (#46684)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:04:24 +10:00
opencode-agent[bot] 21bbcc33a1 fix(app): restore uniform new session tab width (#46919)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-03 08:01:11 +08:00
Kit Langton 0b1dbaf621 refactor(client): edit streamed message targets directly (#46924) 2026-09-02 20:00:25 -04:00
Dax Raad 0da51e9274 fix(updates): retry transient artifact publication failures 2026-09-02 19:30:11 -04:00
Dax dea1f9cb7b fix(core): update plugin reload npm fixture (#46918) 2026-09-02 19:12:00 -04:00
Dax 969055918f feat(cli): use endpoint-provided update packages (#46910) 2026-09-02 23:01:27 +00:00
Kit Langton f269025416 fix(core): keep plugin activation stable across failures and refreshes (#46899) 2026-09-02 18:56:23 -04:00
Dax Raad 4643cacb4a fix(cli): retry Windows service smoke cleanup 2026-09-02 18:55:26 -04:00
Dax Raad 68a40e2d21 chore: remove package reservation script 2026-09-02 18:15:53 -04:00
Dax 57c02cd04b fix(tui): refresh plugin dialog after updates (#46911) 2026-09-02 17:59:18 -04:00
Dax Raad 46515df4a3 refactor(updates): use useragent event field 2026-09-02 17:51:55 -04:00
Dax Raad 51250e4346 feat(updates): include client IP in request events 2026-09-02 17:51:33 -04:00
Aiden Cline dbd47702b2 fix(core): tell plan agent to discuss plans instead of writing files by default (#46905) 2026-09-02 16:49:04 -05:00
Dax Raad a085bf62a4 feat(updates): log requests to the shared data lake 2026-09-02 17:25:02 -04:00
Luke Parker b605f355ca fix(core): share the models.dev snapshot across Locations (#46784) 2026-09-03 07:22:07 +10:00
opencode-agent[bot] 85e2b0a23a chore: update nix node_modules hashes 2026-09-02 21:21:54 +00:00
Kit Langton 3dc187bf3f refactor(client): share location resource reads (#46831) 2026-09-02 17:05:09 -04:00
Dax ed3259a9b7 refactor(plugin): centralize host resolution (#46901) 2026-09-02 17:04:15 -04:00
Kit Langton db09cc842e fix(util): stage npm installs under the cache directory's real path (#46887) 2026-09-02 16:40:45 -04:00
Aiden Cline 44e0b35303 fix(core): narrow compaction additional context guidance (#46889) 2026-09-02 15:12:27 -05:00
Kit Langton e0c0d5691d fix(core): wait for plugin activation before session entry points (#46878) 2026-09-02 15:58:03 -04:00
Kit Langton 2ae235078a feat(tui): check for plugin updates from the plugins dialog (#46884) 2026-09-02 19:40:40 +00:00
opencode-agent[bot] fc051e49ee feat(core): add pinned session move tool (#46862)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-02 14:26:45 -05:00
Dax Raad af332a2363 chore(cli): enable bytecode compilation 2026-09-02 15:13:29 -04:00
Kit Langton 4772b6a3e8 refactor: rename State drafts to editors (#46876) 2026-09-02 18:39:02 +00:00
Kit Langton 318a82f784 feat(sdk): resolve instance configuration from provided services (#46875) 2026-09-02 14:24:12 -04:00
Kit Langton 4b6e879ba8 fix(core): subscribe before debouncing config plugin updates (#46874) 2026-09-02 14:17:50 -04:00
opencode-agent[bot] d37122350b fix(tui): make prompt metadata responsive (#46801)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-09-02 14:08:50 -04:00
Kit Langton b45882ec87 fix(core): notify reloads immediately and debounce at bursty sources (#46843) 2026-09-02 13:53:55 -04:00
Kit Langton bbc44310dc fix(core): scope the migration lock to each database (#46866) 2026-09-02 13:49:18 -04:00
Aiden Cline a7b8174917 fix(core): replace GPT autonomy section with scope guidance (#46864) 2026-09-02 12:24:17 -05:00
Kit Langton 7aabfd3554 test(core): route test watcher updates to matching watches (#46840) 2026-09-02 13:16:38 -04:00
Kit Langton d4fe3758c4 refactor(core): pass the rebuilt value to State notify (#46837) 2026-09-02 13:04:53 -04:00
Kit Langton 473c292521 fix(core): discover project config once under symlinked paths (#46841) 2026-09-02 13:04:43 -04:00
Dax 36095decd7 fix(core): preserve unchanged plugin prefix (#46857) 2026-09-02 12:50:14 -04:00
James Long 33dd4e3ba8 feat(cli): apply managed updates when idle and wire up ui (#46485) 2026-09-02 12:08:41 -04:00
James Long 955fcad647 fix(tui): remove link from background subagent completion notice (#46838) 2026-09-02 12:06:51 -04:00
James Long ec4a5cbe25 test(core): remove flaky mercurial project test (#46835) 2026-09-02 11:48:54 -04:00
Aiden Cline 79c789be89 fix(core): place model prompt before project instructions (#46829) 2026-09-02 10:42:50 -05:00
Kit Langton 429387d158 fix(core): rebuild registry state on read (#46825) 2026-09-02 11:33:53 -04:00
James Long 268ca2f63d fix(tui): keep terminal panes off by default on Windows (#46821) 2026-09-02 10:50:26 -04:00
Shoubhit Dash 6051a1f987 feat(ai): support typed provider-side compaction (#46431) 2026-09-02 20:17:15 +05:30
Shoubhit Dash c35be481b3 fix(ai): preserve responses image detail (#46429) 2026-09-02 20:17:15 +05:30
Shoubhit Dash 45e2035c0b refactor(ai): separate conversation and generation lowering (#46428) 2026-09-02 20:17:14 +05:30
Kit Langton 74fbe199af fix(cli): await plugin activation before caching ACP catalog (#46682) 2026-09-02 10:44:10 -04:00
James Long fe4ea1d693 feat(tui): enable session terminal panes by default (#46797) 2026-09-02 10:25:44 -04:00
418 changed files with 13605 additions and 4717 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2' || github.ref_name == 'beta'
if: github.ref_name == 'v2'
working-directory: packages/updates
run: bun run deploy
env:
+3 -4
View File
@@ -575,6 +575,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
@@ -585,6 +586,7 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
@@ -971,7 +973,6 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1045,6 +1046,7 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
@@ -1123,7 +1125,6 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.1",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.4.3",
"solid-js": "1.9.15",
@@ -5171,8 +5172,6 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
}
}
+1 -2
View File
@@ -27,7 +27,6 @@
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -93,7 +92,6 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"solid-list": "0.3.0",
@@ -179,6 +177,7 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
}
}
+102 -1
View File
@@ -241,14 +241,115 @@ Constructing `stream()` or `generate()` does not record a request, invoke a resp
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
future reply.
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
Generation responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
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.
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`.
## Provider compaction
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider. Bedrock compaction is deferred to a separate follow-up.
This is different from prompt caching, server-side history storage, or truncation. Compaction returns provider-owned context that must be replayed to continue the conversation.
### 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.
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
```ts
const result = yield * LLMClient.compact(request)
const next = LLMRequest.update(request, {
messages: result.replacement,
})
const response = yield * LLMClient.generate(next)
```
`replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly.
The selected model carries explicit-compaction capability through request construction and updates. Calls using unsupported routes fail type checking. When the model is selected dynamically, narrow the request with `LLMClient.canCompact(request)` before calling `LLMClient.compact`; a model or route switch does not inherit the old capability. Runtime validation still rejects unsupported calls from untyped consumers. Capability describes the route's API, not whether every model or custom deployment supports the operation.
Generation-only body overlays such as `stream` and `store` are not sent to the compact endpoint. Supported compact controls such as service tier and prompt-cache settings preserve request defaults and HTTP-overlay precedence. Retained image and file detail settings survive serialization and replay.
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.
### 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.
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
```ts
import { LLM, LLMClient, LLMRequest, Message } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
const request = LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-5.3-codex"),
messages,
providerOptions: {
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
},
})
const response = yield * LLMClient.generate(request)
const next = LLMRequest.update(request, {
messages: [...request.messages, response.message, Message.user("Continue")],
})
```
`store: false` remains the default. Keep the entire `response.message`, not just `response.text`. Compaction events become ordered `CompactionPart`s alongside text and reasoning. The conversation contains everything needed to continue; there is no separate replay object or hidden provider transcript.
A compaction part has `provider` and exactly one representation: `encrypted` for Responses, or `text` for Anthropic. Responses also preserves the optional checkpoint `id`. These fields survive message serialization without becoming visible assistant text. Sending a checkpoint to another provider or an incompatible API fails rather than silently losing context.
```ts
import { CompactionPart, ProviderID } from "@opencode-ai/ai"
CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_123", encrypted: "..." })
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "Summary of the conversation..." })
```
For Anthropic, use:
```ts
providerOptions: {
contextManagement: {
edits: [{
type: "compact_20260112",
trigger: { type: "input_tokens", value: 150_000 },
pauseAfterCompaction: true,
instructions: "Summarize the task and decisions. Do not call tools while summarizing.",
}],
},
}
```
- The trigger is optional (provider default: 150,000 tokens), with a minimum of 50,000.
- Custom instructions replace Anthropic's default summarization instructions.
- The route adds `compact-2026-01-12` to existing beta headers, including when replaying a checkpoint without enabling new compactions.
- A pause is exposed as `response.finishReason.raw === "compaction"`. It occurs only if the threshold triggers compaction: `pauseAfterCompaction` does not mean "compact now". The caller explicitly issues the next request; the package never automatically resumes.
- Anthropic can return a compaction block with `content: null` when summarization fails. This becomes a compaction part with `text: null`, which is **not** a successful replacement for prior history. The package never prunes history automatically.
- `Usage` totals include all reported Anthropic `usage.iterations`, including compaction. `contextTokens` separately reports the final message iteration's inclusive input size, when available. A compaction-only pause does not report a post-compaction context size. Raw iteration usage remains in `providerMetadata`.
### Ownership and verification
The AI package transports options and typed conversation parts. It does not schedule compaction, persist Session checkpoints, select history, switch providers, or replace Core's existing local compaction policy. Native compaction is not enabled for OpenCode Sessions by this feature; Session integration must persist these parts before enabling it. The AI SDK bridge rejects native compaction parts rather than dropping them. Provider-executed tool APIs and persistence changes are a separate follow-up.
Tests cover serialized round trips, real local HTTP plus a tool loop, WebSocket recovery, provider errors, malformed blocks, and usage accounting. Live provider tests are gated by `RECORD=true` and the relevant API keys:
```sh
# Run from packages/ai. Only records the selected new cassette group.
RECORD=true RECORDED_PREFIX=openai-compaction bun test test/provider/compaction.recorded.test.ts
RECORD=true RECORDED_PREFIX=xai-compaction bun test test/provider/compaction.recorded.test.ts
RECORD=true RECORDED_PREFIX=anthropic-compaction bun test test/provider/compaction.recorded.test.ts
```
Provider references: [OpenAI](https://developers.openai.com/api/docs/guides/compaction), [Azure](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses#server-side-compaction), [Anthropic](https://platform.claude.com/docs/en/build-with-claude/compaction), [xAI](https://docs.x.ai/developers/advanced-api-usage/context-compaction).
## Caching
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
+180 -27
View File
@@ -6,8 +6,12 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { Headers } from "effect/unstable/http"
import { HttpTransport } from "../route/transport/index.js"
import {
AIError,
HttpOptions,
LLMRequest,
LLMEvent,
mergeJsonRecords,
Usage,
@@ -15,7 +19,6 @@ import {
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ToolCallPart,
@@ -61,6 +64,8 @@ export type ThinkingInput =
))
export interface OptionsInput {
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
readonly contextManagement?: ContextManagement
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
@@ -89,6 +94,23 @@ export interface OptionsInput {
export type ProviderOptionsInput = OptionsInput
export const ContextManagement = Schema.Struct({
edits: Schema.Array(
Schema.Struct({
type: Schema.Literal("compact_20260112"),
trigger: Schema.optional(
Schema.Struct({
type: Schema.Literal("input_tokens"),
value: Schema.Int.check(Schema.isGreaterThanOrEqualTo(50000)),
}),
),
pauseAfterCompaction: Schema.optional(Schema.Boolean),
instructions: Schema.optional(Schema.String),
}),
),
})
export type ContextManagement = typeof ContextManagement.Type
// =============================================================================
// Request Body Schema
// =============================================================================
@@ -236,7 +258,12 @@ const AnthropicUserBlock = Schema.Union([
AnthropicToolResultBlock,
])
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
const AnthropicCompactionBlock = Schema.Struct({
type: Schema.Literal("compaction"),
content: Schema.NullOr(Schema.String),
})
const AnthropicAssistantBlock = Schema.Union([
AnthropicCompactionBlock,
AnthropicTextBlock,
AnthropicThinkingBlock,
AnthropicRedactedThinkingBlock,
@@ -312,6 +339,18 @@ const AnthropicContainer = Schema.Union([
])
const AnthropicBodyFields = {
context_management: Schema.optional(
Schema.Struct({
edits: Schema.Array(
Schema.Struct({
type: Schema.Literal("compact_20260112"),
trigger: ContextManagement.fields.edits.value.fields.trigger,
pause_after_compaction: Schema.optional(Schema.Boolean),
instructions: Schema.optional(Schema.String),
}),
),
}),
),
model: Schema.String,
system: optionalArray(AnthropicTextBlock),
messages: Schema.Array(AnthropicMessage),
@@ -335,7 +374,7 @@ const AnthropicBodyFields = {
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
const AnthropicUsage = Schema.StructWithRest(
const AnthropicIterationUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: optionalNull(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
@@ -354,6 +393,13 @@ const AnthropicUsage = Schema.StructWithRest(
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
...AnthropicIterationUsage.schema.fields,
iterations: Schema.optional(Schema.Array(AnthropicIterationUsage)),
}),
[JsonObject],
)
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
const AnthropicStreamBlock = Schema.Struct({
@@ -377,6 +423,7 @@ type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.Struct({
content: optionalNull(Schema.String),
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
@@ -406,6 +453,8 @@ const AnthropicEvent = Schema.Struct({
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly provider: LLMRequest["model"]["provider"]
readonly compactions: Readonly<Record<number, string | null>>
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -848,6 +897,12 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (message.role === "assistant") {
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "compaction") {
if (part.provider !== request.model.provider || part.text === undefined)
return yield* invalid("Compaction state must be replayed to its originating provider and API")
content.push({ type: "compaction", content: part.text })
continue
}
if (part.type === "text") {
if (part.text.trim().length === 0) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
@@ -1003,6 +1058,9 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
@@ -1037,7 +1095,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
const options = yield* resolveOptions(request)
return {
const body = {
model: request.model.id,
system,
messages,
@@ -1058,6 +1116,18 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
metadata: options.metadata,
service_tier: options.service_tier,
}
if (!management) return body
return {
...body,
context_management: {
edits: management.edits.map((edit) => ({
type: edit.type,
trigger: edit.trigger,
pause_after_compaction: edit.pauseAfterCompaction,
instructions: edit.instructions,
})),
},
}
})
// =============================================================================
@@ -1079,18 +1149,31 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const iterations = usage.iterations?.length ? usage.iterations : [usage]
const last = usage.iterations?.at(-1)
const nonCached = ProviderShared.sumTokens(...iterations.map((item) => item.input_tokens ?? undefined))
const cacheRead = ProviderShared.sumTokens(...iterations.map((item) => item.cache_read_input_tokens ?? undefined))
const cacheWrite = ProviderShared.sumTokens(
...iterations.map((item) => item.cache_creation_input_tokens ?? undefined),
)
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
const outputTokens = ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens))
return new Usage({
inputTokens,
outputTokens: usage.output_tokens,
outputTokens,
contextTokens:
last?.type === "message"
? ProviderShared.sumTokens(
last.input_tokens ?? undefined,
last.cache_read_input_tokens ?? undefined,
last.cache_creation_input_tokens ?? undefined,
)
: undefined,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cacheRead,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)),
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -1112,6 +1195,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
return new Usage({
inputTokens,
outputTokens,
contextTokens: right.contextTokens ?? left.contextTokens,
nonCachedInputTokens,
cacheReadInputTokens,
cacheWriteInputTokens,
@@ -1170,7 +1254,6 @@ const onContentBlockStart = (
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
): StepResult => {
const block = event.content_block
if (!block) return [state, NO_EVENTS]
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
@@ -1265,7 +1348,16 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
) {
const delta = event.delta
if (delta?.type === "text_delta" && delta.text) {
if (delta.type === "compaction_delta") {
if (event.index === undefined || !(event.index in state.compactions) || delta.content === undefined)
return yield* ProviderShared.eventError(ADAPTER, "Compaction delta is missing its block or content")
return [
{ ...state, compactions: { ...state.compactions, [event.index]: delta.content } },
NO_EVENTS,
] satisfies StepResult
}
if (delta.type === "text_delta" && delta.text) {
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
@@ -1274,7 +1366,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
] satisfies StepResult
}
if (delta?.type === "thinking_delta" && delta.thinking) {
if (delta.type === "thinking_delta" && delta.thinking) {
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
@@ -1286,7 +1378,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
] satisfies StepResult
}
if (delta?.type === "signature_delta" && delta.signature) {
if (delta.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
return [
@@ -1298,7 +1390,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
] satisfies StepResult
}
if (delta?.type === "input_json_delta" && event.index !== undefined) {
if (delta.type === "input_json_delta" && event.index !== undefined) {
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
@@ -1323,6 +1415,18 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
event: AnthropicEvent,
) {
if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult
if (event.index in state.compactions) {
const { [event.index]: content, ...compactions } = state.compactions
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.compaction({
provider: state.provider,
text: content,
}),
)
return [{ ...state, compactions, lifecycle }, events] satisfies StepResult
}
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
@@ -1374,6 +1478,8 @@ const onMessageDelta = (
}
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
if (Object.keys(state.compactions).length)
return yield* ProviderShared.eventError(ADAPTER, "Response ended with an incomplete compaction block")
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
@@ -1418,16 +1524,21 @@ const onError = (event: AnthropicEvent) => {
)
}
const isKnownStreamBlockType = (type: string) =>
type === "text" ||
type === "thinking" ||
type === "redacted_thinking" ||
type === "tool_use" ||
type === "server_tool_use" ||
isServerToolResultType(type)
const isKnownStreamDeltaType = (type: string) =>
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
const STREAM_BLOCK_TYPES = new Set([
"compaction",
"text",
"thinking",
"redacted_thinking",
"tool_use",
"server_tool_use",
])
const STREAM_DELTA_TYPES = new Set([
"compaction_delta",
"text_delta",
"thinking_delta",
"signature_delta",
"input_json_delta",
])
const invalidStreamEvent = (event: AnthropicEvent) =>
Effect.fail(
@@ -1456,7 +1567,16 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_start") {
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
return invalidStreamEvent(event)
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
if (event.content_block.type === "compaction") {
const decoded = Schema.decodeUnknownOption(AnthropicCompactionBlock)(event.content_block)
if (event.index === undefined || Option.isNone(decoded)) return invalidStreamEvent(event)
return Effect.succeed<StepResult>([
{ ...state, compactions: { ...state.compactions, [event.index]: decoded.value.content } },
NO_EVENTS,
])
}
if (!STREAM_BLOCK_TYPES.has(event.content_block.type) && !isServerToolResultType(event.content_block.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamBlock(event.content_block)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
const block = decoded.value
@@ -1470,7 +1590,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
}
if (event.type === "content_block_delta") {
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
if (typeof event.delta.type === "string" && !STREAM_DELTA_TYPES.has(event.delta.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
@@ -1504,6 +1624,8 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: (request) => ({
provider: request.model.provider,
compactions: {},
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
@@ -1513,6 +1635,37 @@ export const protocol = Protocol.make({
},
})
export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" | "context_management">>() => {
const http = HttpTransport.httpJson<Body, string>({ framing })
return {
...http,
prepare: (input: Parameters<typeof http.prepare>[0]) => {
if (
!input.body.context_management?.edits.length &&
!input.body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
)
return http.prepare(input)
const headers = Headers.fromInput(input.request.http?.headers)
const betas = new Set(
(headers["anthropic-beta"] ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
)
betas.add("compact-2026-01-12")
return http.prepare({
...input,
request: LLMRequest.update(input.request, {
http: new HttpOptions({
...input.request.http,
headers: { ...headers, "anthropic-beta": [...betas].join(",") },
}),
}),
})
},
}
}
export const route = Route.make({
id: ADAPTER,
provider: "anthropic",
@@ -1522,7 +1675,7 @@ export const route = Route.make({
baseURL: DEFAULT_BASE_URL,
}),
auth: Auth.none,
framing,
transport: transport<AnthropicMessagesBody>(),
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
+203 -116
View File
@@ -32,17 +32,19 @@ export const PATH = "/responses"
// =============================================================================
// Request Body Schema
// =============================================================================
const OpenResponsesInputText = Schema.Struct({
export const OpenResponsesInputText = Schema.Struct({
type: Schema.tag("input_text"),
text: Schema.String,
})
const OpenResponsesInputImage = Schema.Struct({
export const OpenResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
detail: Schema.optional(Schema.String),
})
const OpenResponsesInputFile = Schema.Struct({
export const OpenResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
detail: Schema.optional(Schema.String),
file_data: Schema.optional(Schema.String),
file_url: Schema.optional(Schema.String),
})
@@ -54,7 +56,7 @@ const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput])
const OpenResponsesOutputText = Schema.Struct({
export const OpenResponsesOutputText = Schema.Struct({
type: Schema.tag("output_text"),
text: Schema.String,
})
@@ -62,6 +64,13 @@ const OpenResponsesOutputText = Schema.Struct({
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
export const MessageMetadata = Schema.Struct({
itemId: Schema.optional(Schema.String),
type: Schema.optional(Schema.Literal("message")),
status: Schema.optional(Schema.String),
phase: Schema.optional(MessagePhase),
})
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
@@ -72,7 +81,7 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
text: Schema.String,
})
const OpenResponsesReasoningItem = Schema.Struct({
export const OpenResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
@@ -149,16 +158,30 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
Schema.Array(OpenResponsesFunctionCallOutputContent),
])
export const CompactionItem = Schema.Struct({
type: Schema.Literal("compaction"),
id: optionalNull(Schema.String),
encrypted_content: Schema.String,
})
export const InputItem = Schema.Union([
CompactionItem,
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("user"),
content: Schema.Array(OpenResponsesInputContent),
type: Schema.optional(Schema.Literal("message")),
id: Schema.optional(Schema.String),
status: Schema.optional(Schema.String),
}),
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
status: Schema.optional(Schema.String),
}),
OpenResponsesReasoningItem,
Schema.Struct({
@@ -267,7 +290,7 @@ const OpenResponsesBody = Schema.Struct({
})
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
const OpenResponsesUsage = Schema.Struct({
export const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.Struct({
@@ -281,6 +304,11 @@ const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
// later item events (Bedrock Mantle renames it to `item_id` on
// `output_item.done` and `response.completed.output`). Decode it as optional
// and let `normalize` recover or mint it once before the parser runs.
// https://www.openresponses.org/specification#extending-items
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -293,6 +321,7 @@ export const StreamItem = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
@@ -372,6 +401,7 @@ export const Event = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
export interface ProviderAdapter {
readonly id: string
@@ -387,18 +417,19 @@ export interface ProviderAdapter {
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly provider: LLMRequest["model"]["provider"]
readonly completedCompactions: ReadonlySet<string>
readonly id: string
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -491,6 +522,9 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const media = ProviderShared.normalizeMedia(part)
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
part.providerMetadata?.[metadataKey(request.model)]?.detail,
)
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -501,10 +535,15 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
return {
type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
detail,
...(url ? { file_url: url } : { file_data: media.dataUrl }),
}
}
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
return {
type: "input_image" as const,
image_url: url ?? media.dataUrl,
detail,
}
})
const lowerUserContent = Effect.fnUntraced(function* (
@@ -571,9 +610,12 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
const providerMetadataKey = metadataKey(request.model)
for (const message of request.messages) {
const metadata = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[providerMetadataKey])
if (message.role === "system") {
input.push({
role: "developer",
@@ -584,7 +626,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
if (content.length > 0) input.push({ role: "user", content })
if (content.length > 0)
input.push({ role: "user", content, type: metadata?.type, id: metadata?.itemId, status: metadata?.status })
continue
}
@@ -597,9 +640,10 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
const groups = content.reduce<
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
const partMetadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey) ?? metadata?.itemId
const partPhase = messagePhase(partMetadata?.phase)
const phase = partPhase === undefined ? metadata?.phase : partPhase
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
else groups.push({ id, phase, parts: [part] })
@@ -610,6 +654,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
status: metadata?.status,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
@@ -617,6 +662,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
content.splice(0, content.length)
}
for (const part of message.content) {
if (part.type === "compaction") {
flushText()
if (part.provider !== request.model.provider || part.encrypted === undefined)
return yield* ProviderShared.invalidRequest(
"Compaction state must be replayed to its originating provider and API",
)
input.push({ type: "compaction", id: part.id, encrypted_content: part.encrypted })
continue
}
if (part.type === "text") {
content.push(part)
continue
@@ -693,13 +747,30 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
return input
})
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const instructions = ProviderShared.joinText(request.system)
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
...(instructions ? { instructions } : {}),
}
})
export const lowerGeneration = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const generation = request.generation
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(instructions ? { instructions } : {}),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
@@ -727,7 +798,7 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
return disabled === undefined ? undefined : !disabled
}
const allowedToolChoice = (request: LLMRequest) => {
export const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
@@ -741,11 +812,10 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
request: LLMRequest,
adapter: ProviderAdapter,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
...(yield* lowerConversation(request, adapter)),
...lowerGeneration(request),
tools:
request.tools.length === 0
? undefined
@@ -759,13 +829,6 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request),
}
})
@@ -782,7 +845,7 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (req
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
// with a `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
@@ -812,13 +875,12 @@ const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason =
return hasFunctionCall ? "tool-calls" : "unknown"
}
export const metadataKey = (model: LLMRequest["model"]) => model.route.providerMetadataKey ?? "openresponses"
export const providerMetadata = (state: ParserState, metadata: Record<string, unknown>): ProviderMetadata => ({
[state.providerMetadataKey]: metadata,
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -862,9 +924,34 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
export const outputItemID = (state: ParserState, event: Event) =>
const outputItemID = (state: ParserState, 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>> = {
message: "msg",
reasoning: "rs",
function_call: "fc",
compaction: "cmp",
}
// 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 => ({
...item,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
`${ITEM_ID_PREFIX[item.type] ?? "item"}_${crypto.randomUUID().replaceAll("-", "")}`,
})
// 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 => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
})
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
@@ -938,7 +1025,7 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
@@ -951,18 +1038,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
if (state.message !== undefined && state.message.id !== item.id) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.filter((id) => id !== item.id)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
@@ -979,14 +1066,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
},
},
events,
]
}
if (item && isReasoningItem(item)) {
if (item.type === "reasoning") {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
@@ -1006,18 +1093,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
events,
]
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined || state.completedTools.has(item.id)) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, id, {
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
@@ -1089,11 +1174,30 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: Event["item"],
item: NormalizedEvent["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
if (item.type === "compaction") {
if (typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.compaction({
provider: state.provider,
id: item.id,
encrypted: item.encrypted_content,
}),
)
return [
{ ...state, lifecycle, completedCompactions: new Set([...state.completedCompactions, item.id]) },
events,
] satisfies StepResult
}
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
@@ -1126,36 +1230,23 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
if (state.completedTools.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered !== undefined || finished.length === 0
registered || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1166,13 +1257,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
completedTools: new Set([...state.completedTools, item.id]),
},
events,
] satisfies StepResult
}
if (isReasoningItem(item)) {
if (item.type === "reasoning") {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
@@ -1256,30 +1347,35 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
if (
item.type !== "function_call" ||
!item.call_id ||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
)
continue
// An output item's array position is its output index.
for (const item of (event.response?.output ?? []).map((item, index) => resolveItem(state, item, index))) {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (item.type === "compaction" && state.lifecycle.stepStarted && !state.completedCompactions.has(item.id))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
const recoverable =
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
if (!recoverable) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
}
// Some compatible providers omit output_item.done even after completing the response.
const pending = yield* ToolStream.finishAll(current.id, current.tools)
current = {
...current,
tools: pending.tools,
hasFunctionCall:
current.hasFunctionCall ||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)),
}
events.push(...pending.events)
}
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
events.push(...pending.events)
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
current.hasFunctionCall
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, hasFunctionCall),
normalized: mapFinishReason(event, current.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
@@ -1291,7 +1387,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
})
: undefined,
})
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
return [{ ...current, lifecycle }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
@@ -1328,12 +1424,9 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
return new AIError({ reason })
}
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
// Callers must pass events through `normalize` first. The OpenAPI requires
// string IDs but imposes no minLength; empty is not missing.
export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1373,20 +1466,16 @@ export const step = (state: ParserState, input: Event) => {
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item &&
isReasoningItem(event.item) &&
event.item?.type === "reasoning" &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
event.output_index !== undefined && event.item
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
: state,
event,
),
@@ -1396,11 +1485,7 @@ export const step = (state: ParserState, input: Event) => {
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
@@ -1426,9 +1511,11 @@ export const step = (state: ParserState, input: Event) => {
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
provider: request.model.provider,
completedCompactions: new Set<string>(),
id: adapter.id,
name: adapter.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
providerMetadataKey: metadataKey(request.model),
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
@@ -1448,7 +1535,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(Event),
initial,
step,
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
terminal,
},
})
+30 -13
View File
@@ -5,13 +5,14 @@ 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 { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import type { LLMRequest, JsonSchema, ToolDefinition } 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"
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"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -20,6 +21,14 @@ const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = OpenResponses.PATH
export const ContextManagement = Schema.Array(
Schema.Struct({
type: Schema.Literal("compaction"),
compactThreshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
)
export type ContextManagement = typeof ContextManagement.Type
const OpenAIResponsesImageGenerationTool = Schema.Struct({
type: Schema.tag("image_generation"),
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
@@ -78,6 +87,14 @@ const OpenAIResponsesCoreFields = {
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
Schema.Array(
Schema.Struct({
type: Schema.Literal("compaction"),
compact_threshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
),
),
}
const OpenAIResponsesBody = Schema.Struct({
@@ -125,15 +142,14 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithAdapter(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
adapter,
)
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return yield* decodeBody({
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
...(yield* OpenResponses.lowerConversation(request, adapter)),
...OpenResponses.lowerGeneration(request),
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
tools:
request.tools.length === 0
? undefined
@@ -141,7 +157,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
})
})
@@ -184,12 +201,11 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -223,6 +239,7 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: ResponsesCompaction.make(adapter),
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
@@ -0,0 +1,174 @@
import { Effect, Schema, Stream } from "effect"
import {
AIError,
InvalidProviderOutputError,
CompactionPart,
CompactionResponse,
HttpOptions,
LLMRequest,
Message,
type ContentPart,
mergeJsonRecords,
} from "../../schema/index.js"
import type { CompactOperation } from "../../route/client.js"
import { Endpoint } from "../../route/endpoint.js"
import { RequestExecutor } from "../../route/executor.js"
import { HttpTransport } from "../../route/transport/index.js"
import { OpenResponses } from "../open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
const Body = Schema.Struct({
model: Schema.String,
input: Schema.Array(Schema.Unknown),
instructions: optionalNull(Schema.String),
previous_response_id: optionalNull(Schema.String),
service_tier: optionalNull(Schema.String),
prompt_cache_key: optionalNull(Schema.String),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
),
})
const Text = Schema.Union([OpenResponses.OpenResponsesInputText, OpenResponses.OpenResponsesOutputText])
const File = Schema.Union([
Schema.Struct({
...OpenResponses.OpenResponsesInputFile.fields,
file_url: Schema.String,
file_data: Schema.optional(Schema.Never),
}),
Schema.Struct({
...OpenResponses.OpenResponsesInputFile.fields,
file_data: Schema.String,
file_url: Schema.optional(Schema.Never),
}),
])
const MessageFields = {
type: Schema.Literal("message"),
id: Schema.optional(Schema.String),
status: Schema.optional(Schema.String),
phase: Schema.optional(OpenResponses.MessagePhase),
}
const Response = Schema.Struct({
object: Schema.Literal("response.compaction"),
output: Schema.Array(
Schema.Union([
OpenResponses.CompactionItem,
OpenResponses.OpenResponsesReasoningItem,
Schema.Struct({
...MessageFields,
role: Schema.Literal("user"),
content: Schema.Array(Schema.Union([Text, OpenResponses.OpenResponsesInputImage, File])).check(
Schema.isMinLength(1),
),
}),
Schema.Struct({
...MessageFields,
role: Schema.Literal("assistant"),
content: Schema.Array(Text).check(Schema.isMinLength(1)),
}),
]),
),
usage: Schema.optional(Schema.StructWithRest(OpenResponses.OpenResponsesUsage, [JsonObject])),
})
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
const route = request.model.route
const native = yield* OpenResponses.lowerConversation(request, adapter)
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
mergeJsonRecords(
{
...native,
service_tier: request.providerOptions?.serviceTier,
prompt_cache_key: ProviderShared.promptCacheKey(request),
},
request.http?.body,
),
)
const url = Endpoint.render(route.endpoint, { request, body: native })
url.pathname = `${url.pathname.replace(/\/$/, "")}/compact`
const parts = yield* HttpTransport.jsonRequestParts({
request: LLMRequest.update(request, {
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
}),
body,
endpoint: Endpoint.path(url.toString()),
auth: route.auth,
encodeBody: Schema.encodeSync(Schema.fromJsonString(Body)),
})
const response = yield* executor.execute(
ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
options?.http,
)
const text = yield* RequestExecutor.responseStream(response).pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(text, chunk) => text + chunk,
),
)
const invalid = (message: string, cause?: unknown) =>
new AIError({
reason: new InvalidProviderOutputError({
route: route.id,
message,
body: text,
cause,
http: RequestExecutor.responseHttp(response),
}),
})
const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(text).pipe(
Effect.mapError((cause) => invalid("Invalid compaction response", cause)),
)
if (!result.output.some((item) => item.type === "compaction"))
return yield* invalid("Compaction response did not contain a checkpoint")
return new CompactionResponse({
replacement: result.output.map((item) => toMessage(item, request.model)),
usage: OpenResponses.mapUsage(result.usage, OpenResponses.metadataKey(request.model)),
})
})
function toMessage(item: (typeof Response.Type.output)[number], model: LLMRequest["model"]): Message {
if (item.type === "compaction")
return Message.assistant(
CompactionPart.make({ provider: model.provider, id: item.id ?? undefined, encrypted: item.encrypted_content }),
)
const key = OpenResponses.metadataKey(model)
if (item.type === "reasoning") {
const summary = item.summary.length ? item.summary : [{ text: "" }]
return Message.assistant(
summary.map((part) => ({
type: "reasoning" as const,
text: part.text,
providerMetadata: { [key]: { itemId: item.id, reasoningEncryptedContent: item.encrypted_content } },
})),
)
}
return Message.make({
role: item.role,
providerMetadata: { [key]: { itemId: item.id, type: item.type, status: item.status, phase: item.phase } },
content: item.content.map((part): ContentPart => {
if (part.type === "input_text" || part.type === "output_text") return { type: "text", text: part.text }
if (part.type === "input_image")
return {
type: "media",
data: part.image_url,
mediaType: /^data:([^;,]+)/.exec(part.image_url)?.[1] ?? "image/*",
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
}
const data = part.file_url === undefined ? part.file_data : part.file_url
return {
type: "media",
data,
filename: part.filename,
mediaType: /^data:([^;,]+)/.exec(data)?.[1] ?? "application/octet-stream",
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
}
}),
})
}
export * as ResponsesCompaction from "./responses-compaction.js"
@@ -3,8 +3,7 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.StreamItem & {
readonly id: string
export type Item = OpenResponses.OutputItem & {
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
@@ -27,8 +26,8 @@ export interface Definition {
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
item.type in tools
export const onDone: (
state: OpenResponses.ParserState,
+9 -1
View File
@@ -4,6 +4,7 @@ import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
@@ -44,6 +45,10 @@ const adapter = {
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
if (request.providerOptions?.contextManagement !== undefined)
return yield* ProviderShared.invalidRequest(
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
)
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
@@ -64,7 +69,8 @@ const HOSTED_TOOLS = {
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
@@ -84,4 +90,6 @@ export const protocol = Protocol.make({
},
})
export const compact = ResponsesCompaction.make(adapter)
export * as XAIResponses from "./xai-responses.js"
+3 -1
View File
@@ -1,4 +1,5 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactOperation } from "./route/client.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@@ -9,8 +10,9 @@ 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,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
}
export * as ProviderPackage from "./provider-package.js"
+11 -6
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 as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactOperation } 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"
@@ -102,7 +102,11 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
) =>
route.with({
auth: auth(input),
endpoint: endpoint(input, modelID),
@@ -161,10 +165,11 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
CompactOperation
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
@@ -57,7 +57,9 @@ const route = Route.make({
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
framing: AnthropicMessages.framing,
transport: AnthropicMessages.transport<
Omit<AnthropicMessages.AnthropicMessagesBody, "model"> & { readonly anthropic_version: typeof VERSION }
>(),
headers: () => ({ "anthropic-version": HEADER_VERSION }),
})
@@ -1,10 +1,13 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
import type { ContextManagement } from "../protocols/openai-responses.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
readonly contextManagement?: ContextManagement
readonly serviceTier?: OpenAIServiceTier
readonly [key: string]: unknown
}
+9 -3
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactOperation } 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,10 @@ const defaults = (input: Config) => {
return rest
}
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
route.with({
auth: auth(input),
endpoint: { baseURL: input.baseURL, query: input.queryParams },
@@ -129,7 +132,10 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
return configure(config(settings)).responses(modelID)
}
+7 -3
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Route, type RouteDefaultsInput, type CompactOperation } 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"
@@ -13,7 +13,7 @@ import type { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = OpenAIOptionsInput
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
@@ -32,6 +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,
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
@@ -102,7 +103,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+82 -13
View File
@@ -13,6 +13,7 @@ import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
AIErrorReason,
GenerationOptions,
HttpOptions,
@@ -34,7 +35,12 @@ export interface RouteBody<Body> {
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
}
export interface Route<Body, Prepared = unknown> {
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly compact: Compact
readonly id: string
readonly provider?: ProviderID
/** ProviderMetadata namespace emitted and consumed by this route. */
@@ -42,13 +48,15 @@ export interface Route<Body, Prepared = unknown> {
readonly protocol: ProtocolID
readonly endpoint: Endpoint.Definition<Body>
readonly auth: Auth.Definition
/** Deployment headers resolved once for every operation, before transport authentication. */
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options>
) => LanguageModel<Options, Compact>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
@@ -66,7 +74,11 @@ export interface Route<Body, Prepared = unknown> {
// 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 = Route<any, any>
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
any,
any,
Compact
>
export type HttpOptionsInput = HttpOptions.Input
@@ -99,15 +111,15 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
route: AnyRoute<Compact>,
mapped: RouteMappedLanguageModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options>({
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
route,
@@ -150,6 +162,10 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
readonly compact: (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -167,6 +183,19 @@ export interface GenerateMethod {
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
}
export type CompactOperation = (
request: LLMRequest,
executor: RequestExecutor.Interface,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
export type CompactionRequest = LLMRequest & {
readonly model: LanguageModel<ProviderOptions, CompactOperation>
}
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
request.model.route.compact !== undefined
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const resolveRequestOptions = (request: LLMRequest) => {
@@ -187,6 +216,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
}
export interface MakeInput<Body, Frame, Event, State> {
readonly compact?: CompactOperation
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -208,6 +238,7 @@ export interface MakeInput<Body, Frame, Event, State> {
}
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
readonly compact?: CompactOperation
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -283,12 +314,14 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
const route: Route<Body, Prepared> = {
compact: routeInput.compact,
id: routeInput.id,
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
providerMetadataKey: routeInput.providerMetadataKey,
protocol: protocol.id,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
headers: routeInput.headers,
transport: routeInput.transport,
defaults: routeInput.defaults ?? {},
body: protocol.body,
@@ -310,7 +343,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options>(route, input),
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@@ -318,7 +351,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
middleware: options?.http,
webSocket: options?.webSocket,
}),
@@ -408,6 +440,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>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
@@ -435,6 +473,7 @@ export function make<Body, Prepared, Frame, Event, State>(
if ("transport" in input) return makeFromTransport(input)
const protocol = input.protocol
return makeFromTransport({
compact: input.compact,
id: input.id,
provider: input.provider,
providerMetadataKey: input.providerMetadataKey,
@@ -447,11 +486,19 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const prepareRequest = (request: LLMRequest) => {
const original = applyCachePolicy(resolveRequestOptions(request))
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
const headers = resolved.model.route.headers?.({ request: resolved })
return headers === undefined
? resolved
: LLMRequest.update(resolved, { http: mergeHttpOptions(new HttpOptions({ headers }), resolved.http) })
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const resolved = prepareRequest(request)
const route = resolved.model.route
const body = yield* route.body
@@ -510,6 +557,15 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
})
}
export const compact = (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
): Effect.Effect<CompactionResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.compact(request, options)
})
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
@@ -520,16 +576,29 @@ export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const stream = streamRequestWith({
http: yield* RequestExecutor.Service,
const executor = yield* RequestExecutor.Service
const stream = streamRequestWith({ http: executor })
return Service.of({
stream,
generate: generateWith(stream),
compact: (request, options) =>
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.invalidRequest(
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
)
return operation(prepareRequest(request), executor, options)
}),
})
return Service.of({ stream, generate: generateWith(stream) })
}),
)
export const Route = { make } as const
export const LLMClient = {
canCompact,
compact,
Service,
layer,
stream,
+18 -4
View File
@@ -3,6 +3,7 @@ import { LLM } from "@opencode-ai/schema/llm"
import { ContentBlockID, ToolCallID } from "./ids.js"
import {
Message,
CompactionPart,
ProviderMetadata,
ToolCallPart,
ToolOutput,
@@ -62,6 +63,8 @@ export { ProviderMetadata } from "./messages.js"
* Matches the same escape-hatch field on `LLMEvent`.
*/
export class Usage extends Schema.Class<Usage>("AI.Usage")({
/** Effective input size of the final message iteration, when reported; not billed totals. */
contextTokens: Schema.optional(Schema.Number),
inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number),
@@ -72,7 +75,7 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
providerMetadata: Schema.optional(ProviderMetadata),
}) {
/**
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
* Non-reasoning output tokens (including compaction summaries) — `outputTokens` minus `reasoningTokens`, clamped
* to zero. The one place subtraction happens in this contract; the clamp
* means a provider reporting `reasoningTokens > outputTokens` produces a
* harmless zero rather than a negative that crashes downstream schemas.
@@ -88,6 +91,12 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
export type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
/** A replacement context window, not an assistant message to append to prior history. */
export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.CompactionResponse")({
replacement: Schema.Array(Message),
usage: Schema.optional(Usage),
}) {}
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
index: Schema.Number,
@@ -241,6 +250,7 @@ export const ProviderErrorEvent = Schema.Struct({
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
const llmEventTagged = Schema.Union([
CompactionPart,
StepStart,
TextStart,
TextDelta,
@@ -274,6 +284,7 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
* `events.filter(LLMEvent.guards["tool-call"])`.
*/
export const LLMEvent = Object.assign(llmEventTagged, {
compaction: CompactionPart.make,
stepStart: StepStart.make,
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
@@ -311,6 +322,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
}),
providerError: ProviderErrorEvent.make,
is: {
compaction: llmEventTagged.guards.compaction,
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
textDelta: llmEventTagged.guards["text-delta"],
@@ -333,10 +345,10 @@ export const LLMEvent = Object.assign(llmEventTagged, {
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
const joinFragments = (
events: ReadonlyArray<LLMEvent>,
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
isDelta: (event: LLMEvent) => event is LLMEvent & { id: string; text: string },
isEnd: (event: LLMEvent) => event is LLMEvent & { id: string; text?: string },
) => {
const order: string[] = []
const parts = new Map<string, string>()
@@ -563,6 +575,8 @@ const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState =>
const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => {
const next = appendEvent(state, event)
switch (event.type) {
case "compaction":
return appendContent(next, event)
case "text-start":
return ensureText(next, event.id, event.providerMetadata)
case "text-delta":
+66 -8
View File
@@ -7,8 +7,10 @@ import {
HttpOptions,
JsonSchema,
LanguageModelSchema,
type LanguageModel,
ProviderOptions,
} from "./options.js"
import { ProviderID } from "./ids.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
@@ -52,6 +54,7 @@ export const MediaPart = Schema.Struct({
filename: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
@@ -185,9 +188,40 @@ export const ReasoningPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Reasoning" })
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
Schema.toTaggedUnion("type"),
)
/** A provider-generated context checkpoint, distinct from visible assistant text. */
type CompactionContent =
| { readonly encrypted: string; readonly text?: never }
| { readonly text: string | null; readonly encrypted?: never }
const compactionPartSchema = Schema.Struct({
type: Schema.Literal("compaction"),
provider: ProviderID,
id: Schema.optional(Schema.String),
encrypted: Schema.optional(Schema.String),
/** Null means the provider failed to produce a summary; prior history must be retained. */
text: Schema.optional(Schema.NullOr(Schema.String)),
})
.pipe(
Schema.refine(
(part): part is typeof part & CompactionContent => (part.encrypted !== undefined) !== (part.text !== undefined),
{ message: "Compaction requires either encrypted content or a summary" },
),
)
.annotate({ identifier: "LLM.Content.Compaction" })
export type CompactionPart = typeof compactionPartSchema.Type
export const CompactionPart = Object.assign(compactionPartSchema, {
make: (input: Omit<CompactionPart, "type" | "encrypted" | "text"> & CompactionContent): CompactionPart =>
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
})
export const ContentPart = Schema.Union([
TextPart,
MediaPart,
ToolCallPart,
ToolResultPart,
ReasoningPart,
CompactionPart,
]).pipe(Schema.toTaggedUnion("type"))
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
export class Message extends Schema.Class<Message>("LLM.Message")({
@@ -195,6 +229,7 @@ export class Message extends Schema.Class<Message>("LLM.Message")({
role: MessageRole,
content: Schema.Array(ContentPart),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
@@ -272,7 +307,7 @@ export namespace ToolChoice {
}
}
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
const requestSchema = Schema.Struct({
id: Schema.optional(Schema.String),
model: LanguageModelSchema,
system: Schema.Array(SystemPart),
@@ -286,12 +321,26 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
// Stable cache affinity for protocols that support provider-managed prompt caching.
promptCacheKey: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
})
export class LLMRequest<Model extends LanguageModel = LanguageModel> extends Schema.Class<LLMRequest>("LLM.Request")(
requestSchema.fields,
) {
declare readonly model: Model
// Preserve model inference instead of inheriting the schema's erased constructor signature.
// oxlint-disable-next-line no-useless-constructor
constructor(input: LLMRequest.Input<Model>) {
super(input)
}
}
export namespace LLMRequest {
export type Input = ConstructorParameters<typeof LLMRequest>[0]
export type Input<Model extends LanguageModel = LanguageModel> = Omit<typeof requestSchema.Type, "model"> & {
readonly model: Model
}
export const input = (request: LLMRequest): Input => ({
export const input = <Model extends LanguageModel>(request: LLMRequest<Model>): Input<Model> => ({
id: request.id,
model: request.model,
system: request.system,
@@ -306,7 +355,16 @@ export namespace LLMRequest {
metadata: request.metadata,
})
export const update = (request: LLMRequest, patch: Partial<Input>) => {
export function update<Model extends LanguageModel>(
request: LLMRequest,
patch: Partial<Input<Model>> & { readonly model: Model },
): LLMRequest<Model>
export function update<Model extends LanguageModel>(
request: LLMRequest<Model>,
patch: Partial<Omit<Input, "model">> & { readonly model?: undefined },
): LLMRequest<Model>
export function update(request: LLMRequest, patch: Partial<Input>): LLMRequest
export function update(request: LLMRequest, patch: Partial<Input>) {
if (Object.keys(patch).length === 0) return request
return new LLMRequest({
...input(request),
+34 -10
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute } from "../route/client.js"
import type { AnyRoute, CompactOperation } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -173,15 +173,18 @@ export namespace LanguageModelCompatibility {
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
}
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
export class LanguageModel<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly route: AnyRoute<Compact>
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
constructor(input: LanguageModel.ConstructorInput) {
constructor(input: LanguageModel.ConstructorInput<Compact>) {
this.id = input.id
this.provider = input.provider
this.route = input.route
@@ -189,8 +192,11 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
this.compatibility = input.compatibility
}
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
return new LanguageModel<Options>({
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
>(input: LanguageModel.Input<Compact>) {
return new LanguageModel<Options, Compact>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
@@ -200,7 +206,9 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
})
}
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
return {
id: model.id,
provider: model.provider,
@@ -210,25 +218,41 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
}
}
static update<Options extends ProviderOptions, Compact extends CompactOperation | 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>(
model: LanguageModel<Options, Compact>,
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input>,
): LanguageModel<Options>
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
if (Object.keys(patch).length === 0) return model
return LanguageModel.make<Options>({
...LanguageModel.input(model),
...patch,
route: patch.route ?? model.route,
})
}
}
export namespace LanguageModel {
export type ConstructorInput = {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly route: AnyRoute<Compact>
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
}
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly defaults?: LanguageModelDefaults.Input
+25 -11
View File
@@ -4,6 +4,7 @@ import { LLMClient } from "./route/client.js"
import {
LLMEvent,
LLMResponse,
CompactionResponse,
type FinishReasonDetails,
type AIError,
type LLMRequest,
@@ -12,7 +13,7 @@ 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>
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -99,8 +100,6 @@ export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
const make = (options: LayerOptions) =>
Effect.sync(() => {
const requests: LLMRequest[] = []
@@ -113,26 +112,41 @@ const make = (options: LayerOptions) =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
const stream: ClientInterface["stream"] = (request) =>
Stream.suspend(() => {
const take = (request: LLMRequest) =>
Effect.suspend(() => {
const count = requests.push(options.transformRequest?.(request) ?? request)
const waiting = started
started = Deferred.makeUnsafe()
const gate = activeGate
try {
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
const streamed = toStream(response)
if (!gate) return streamed
return Stream.unwrap(
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
)
if (!response) return Effect.die(new Error(`TestLLM has no response for request ${count}`))
if (!gate) return Effect.succeed(response)
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
} finally {
// Waiters can resume synchronously; assign the reply and gate before notifying them.
Deferred.doneUnsafe(waiting, Effect.void)
}
})
const stream: ClientInterface["stream"] = (request) =>
Stream.unwrap(
take(request).pipe(
Effect.map((response) => {
if (response instanceof CompactionResponse)
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
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse"),
),
),
stream,
generate: (request) =>
stream(request).pipe(
+1
View File
@@ -56,6 +56,7 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
role: message.role,
content,
metadata: message.metadata,
providerMetadata: message.providerMetadata,
native: message.native,
})
}
+97
View File
@@ -0,0 +1,97 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { LLM, LLMRequest, Message } from "../src/index.js"
import { LLMClient } from "../src/route/client.js"
import { OpenAI } from "../src/providers.js"
import { testEffect } from "./lib/effect.js"
import { runtimeLayer } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
testEffect(runtimeLayer(FetchHttpClient.layer)).live("compaction and a tool loop work end to end over HTTP", () =>
Effect.gen(function* () {
const checkpoint = { type: "compaction", id: "cmp_local", encrypted_content: "opaque-local-state" }
const calls: string[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const path = new URL(request.url).pathname
calls.push(path)
const body = await request.json()
expect(request.headers.get("authorization")).toBe("Bearer fixture")
if (path === "/v1/responses/compact") {
expect(body.stream).toBeUndefined()
return Response.json({
object: "response.compaction",
output: [checkpoint],
usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 },
})
}
expect(body.input[0]).toEqual(checkpoint)
expect(body.stream).toBe(true)
if (calls.length === 2)
return new Response(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
{ headers: { "content-type": "text/event-stream" } },
)
expect(body.input.at(-2)).toMatchObject({ type: "function_call", call_id: "call_1" })
expect(body.input.at(-1)).toEqual({ type: "function_call_output", call_id: "call_1", output: "42" })
const output = sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "The answer is 42." },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "The answer is 42." }] },
},
{ type: "response.completed", response: { id: "resp_2" } },
)
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(output.slice(0, 37)))
controller.enqueue(new TextEncoder().encode(output.slice(37)))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
}),
),
(server) => Effect.sync(() => server.stop(true)),
)
const model = OpenAI.configure({ apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` }).responses(
"fixture",
)
const request = LLM.request({
model,
prompt: "original",
tools: [{ name: "lookup", description: "Lookup a number", inputSchema: { type: "object", properties: {} } }],
})
const compacted = yield* LLMClient.compact(request)
const messages = [...compacted.replacement, Message.user("Look up the answer")]
const first = yield* LLMClient.generate(LLMRequest.update(request, { messages }))
expect(first.toolCalls).toHaveLength(1)
const call = first.toolCalls[0]!
const last = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: "42", resultType: "text" }),
],
}),
)
expect(last.text).toBe("The answer is 42.")
expect(calls).toEqual(["/v1/responses/compact", "/v1/responses", "/v1/responses"])
}),
)
+77
View File
@@ -0,0 +1,77 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { CompactionPart, CompactionResponse, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
import { OpenAI, Anthropic } from "../src/providers.js"
test("runtime capability checks follow model and route updates", () => {
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
const unsupported = Anthropic.configure({ apiKey: "test" }).model("fixture")
const request = LLM.request({ model: supported, prompt: "hello" })
expect(LLMClient.canCompact(request)).toBe(true)
expect(LLMClient.canCompact(LLMRequest.update(request, { messages: [] }))).toBe(true)
expect(LLMClient.canCompact(LLMRequest.update(request, { model: unsupported }))).toBe(false)
expect(
LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: unsupported.route }) })),
).toBe(false)
expect(LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: undefined }) }))).toBe(true)
})
test("explicit compaction serializes a replacement window without a messages alias", () => {
const response = new CompactionResponse({
replacement: [
Message.user("retained input"),
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
],
})
const codec = Schema.fromJsonString(CompactionResponse)
const decoded = Schema.decodeSync(codec)(Schema.encodeSync(codec)(response))
expect(decoded.replacement).toEqual(response.replacement)
expect("messages" in decoded).toBe(false)
})
test("compaction survives event assembly and message serialization without becoming text", () => {
const part = CompactionPart.make({
provider: ProviderID.make("openai"),
id: "cmp_1",
encrypted: "opaque",
})
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "before" }),
LLMEvent.textDelta({ id: "before", text: "Before" }),
LLMEvent.textEnd({ id: "before" }),
part,
LLMEvent.textStart({ id: "after" }),
LLMEvent.textDelta({ id: "after", text: "After" }),
LLMEvent.textEnd({ id: "after" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])!
expect(response.message.content.map((part) => part.type)).toEqual(["text", "compaction", "text"])
expect(response.text).toBe("BeforeAfter")
expect(response.reasoning).toBe("")
expect(response.events.filter(LLMEvent.is.compaction)).toEqual([part])
const codec = Schema.fromJsonString(Message)
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(response.message))).toEqual(response.message)
})
test("compaction requires exactly one typed representation", () => {
const provider = ProviderID.make("anthropic")
expect(CompactionPart.make({ provider, text: null })).toEqual({ type: "compaction", provider, text: null })
const decode = Schema.decodeUnknownSync(CompactionPart)
expect(() => decode({ type: "compaction", provider })).toThrow()
expect(() => decode({ type: "compaction", provider, text: "summary", encrypted: "opaque" })).toThrow()
})
test("tagged content and event guards accept both checkpoint representations", () => {
for (const part of [
CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "opaque" }),
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "summary" }),
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: null }),
]) {
expect(LLMEvent.is.compaction(part)).toBe(true)
expect(LLMEvent.guards.compaction(part)).toBe(true)
const codec = Schema.fromJsonString(Message)
const message = Message.assistant(part)
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message)
}
})
@@ -0,0 +1,133 @@
import { Effect } from "effect"
import {
CompactionPart,
LanguageModel,
LLM,
LLMClient,
LLMEvent,
LLMRequest,
Message,
ProviderID,
} from "../../src/index.js"
import { OpenAI, Azure, XAI, Anthropic, OpenAICompatibleResponses } from "../../src/providers.js"
const openai = OpenAI.configure({
apiKey: "test",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
}).responses("gpt-5.3-codex")
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" }))
for (const model of [
OpenAI.configure().responses("fixture"),
Azure.configure({ resourceName: "test" }).responses("fixture"),
XAI.configure().responses("fixture"),
OpenAI.model("fixture", {}),
Azure.responsesModel("fixture", { resourceName: "test" }),
XAI.model("fixture", {}),
openai.route.with({ headers: { "x-test": "test" } }).model({ id: "fixture" }),
LanguageModel.update(openai, { defaults: { generation: { maxTokens: 100 } } }),
LanguageModel.make(LanguageModel.input(openai)),
]) {
LLMClient.compact(LLM.request({ model, prompt: "hello" }))
}
const unsupported = {
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
openaiChat: LLM.request({ model: OpenAI.configure().chat("fixture") }),
azureChat: LLM.request({ model: Azure.configure({ resourceName: "test" }).chat("fixture") }),
xaiChat: LLM.request({ model: XAI.configure().chat("fixture") }),
compatible: LLM.request({
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
}),
}
// @ts-expect-error Anthropic has no standalone compact endpoint.
LLMClient.compact(unsupported.anthropic)
// @ts-expect-error Chat does not expose Responses compaction.
LLMClient.compact(unsupported.openaiChat)
// @ts-expect-error Azure Chat does not expose Responses compaction.
LLMClient.compact(unsupported.azureChat)
// @ts-expect-error xAI Chat does not expose Responses compaction.
LLMClient.compact(unsupported.xaiChat)
// @ts-expect-error Protocol compatibility does not guarantee endpoint support.
LLMClient.compact(unsupported.compatible)
LLMClient.Service.use((client) => {
// @ts-expect-error The service enforces the same capability as the convenience function.
return client.compact(unsupported.anthropic)
})
const request = LLM.request({ model: openai, prompt: "hello" })
LLMClient.compact(LLMRequest.update(request, { messages: [Message.user("continue")] }))
LLMClient.compact(new LLMRequest(LLMRequest.input(request)))
const switched = LLMRequest.update(request, { model: Anthropic.configure().model("fixture") })
// @ts-expect-error Switching models replaces, rather than inherits, the capability.
LLMClient.compact(switched)
LLMClient.compact(LLMRequest.update(switched, { model: openai }))
LLMClient.compact(
// @ts-expect-error Replacing the route also replaces compaction capability.
LLM.request({ model: LanguageModel.update(openai, { route: Anthropic.configure().model("fixture").route }) }),
)
declare const dynamicModel: LanguageModel
declare const dynamicPatch: Partial<LLMRequest.Input>
const dynamicRequest = LLM.request({ model: dynamicModel, prompt: "hello" })
// @ts-expect-error A dynamically selected model must be narrowed first.
LLMClient.compact(dynamicRequest)
if (LLMClient.canCompact(dynamicRequest)) LLMClient.compact(dynamicRequest)
// @ts-expect-error An optional model override cannot retain the old capability statically.
LLMClient.compact(LLMRequest.update(request, dynamicPatch))
const checkpoint = CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_1", encrypted: "opaque" })
const provider = ProviderID.make("anthropic")
CompactionPart.make({ provider, text: "summary" })
CompactionPart.make({ provider, text: null })
// @ts-expect-error A checkpoint must have a representation.
CompactionPart.make({ provider })
// @ts-expect-error Encrypted and summary representations are mutually exclusive.
CompactionPart.make({ provider, encrypted: "opaque", text: "summary" })
// @ts-expect-error A failed summary cannot also carry encrypted content.
LLMEvent.compaction({ provider, encrypted: "opaque", text: null })
// @ts-expect-error The canonical message type also enforces the invariant.
Message.assistant({ type: "compaction", provider })
if (checkpoint.encrypted !== undefined) {
checkpoint.encrypted satisfies string
checkpoint.text satisfies undefined
}
if (checkpoint.text !== undefined) {
checkpoint.text satisfies string | null
checkpoint.encrypted satisfies undefined
}
checkpoint.encrypted
// @ts-expect-error Compaction parts do not contain a generic provider payload.
checkpoint.value
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" })).pipe(
Effect.map((result) => {
result.replacement satisfies ReadonlyArray<Message>
// @ts-expect-error The replacement window is named explicitly; the old field is not an alias.
result.messages
// @ts-expect-error Compaction returns replacement history, not a synthetic assistant message.
result.message
}),
)
LLM.request({
model: openai,
providerOptions: {
// @ts-expect-error A token threshold is numeric.
contextManagement: [{ type: "compaction", compactThreshold: "100000" }],
},
})
const anthropic = Anthropic.configure().model("claude-opus-4-6")
LLM.request({
model: anthropic,
providerOptions: {
contextManagement: {
edits: [{ type: "compact_20260112", pauseAfterCompaction: true, instructions: "Summarize without using tools" }],
},
},
})
LLM.request({
model: anthropic,
providerOptions: {
// @ts-expect-error A pause setting is boolean.
contextManagement: { edits: [{ type: "compact_20260112", pauseAfterCompaction: "yes" }] },
},
})
@@ -0,0 +1,170 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { Anthropic, GoogleVertexMessages } from "../../src/providers/index.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
for (const fixture of [
{
name: "empty iterations fall back to top-level usage",
usage: { input_tokens: 2, output_tokens: 3, cache_read_input_tokens: null, iterations: [] },
expected: { inputTokens: 2, outputTokens: 3, totalTokens: 5, contextTokens: undefined },
},
{
name: "compaction-only usage has no post-compaction context size",
usage: {
input_tokens: 0,
output_tokens: 0,
iterations: [{ type: "compaction", input_tokens: 7, cache_read_input_tokens: 3, output_tokens: 2 }],
},
expected: { inputTokens: 10, outputTokens: 2, totalTokens: 12, contextTokens: undefined },
},
{
name: "partially reported iterations preserve known totals",
usage: {
iterations: [
{ type: "compaction", input_tokens: 7, cache_creation_input_tokens: 2 },
{ type: "message", output_tokens: 3 },
],
},
expected: { inputTokens: 9, outputTokens: 3, totalTokens: 12, contextTokens: undefined },
},
{
name: "missing counters remain unknown rather than zero",
usage: { iterations: [{ type: "message" }] },
expected: { inputTokens: undefined, outputTokens: undefined, totalTokens: undefined, contextTokens: undefined },
},
]) {
testEffect(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: fixture.usage } },
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
{ type: "message_stop" },
),
),
).effect(fixture.name, () =>
Effect.gen(function* () {
const result = yield* LLMClient.generate(
LLM.request({
model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
prompt: "hello",
}),
)
expect(result.usage).toMatchObject(fixture.expected)
}),
)
}
for (const model of [
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
GoogleVertexMessages.configure({ accessToken: "test", project: "test" }).model("claude-opus-4-6"),
]) {
for (const summary of ["Summary of the conversation", null]) {
const block = { type: "compaction", content: summary }
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
if (body.messages.length === 1) {
expect(body.context_management.edits).toEqual([
{
type: "compact_20260112",
trigger: { type: "input_tokens", value: 50000 },
pause_after_compaction: true,
},
])
}
if (body.messages.length > 1) {
expect(body.messages[1].content).toEqual([block])
expect(body.context_management).toBeUndefined()
}
return respond(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 50000, output_tokens: 0 } } },
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: summary } },
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "compaction" },
usage: {
input_tokens: 1000,
output_tokens: 5,
iterations: [
{ type: "compaction", input_tokens: 50000, output_tokens: 1000, cache_read_input_tokens: 10 },
{ type: "message", input_tokens: 1000, output_tokens: 5 },
],
},
},
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
).effect(
`${model.provider} replays ${summary === null ? "failed" : "successful"} compaction with billing and context usage`,
() =>
Effect.gen(function* () {
const request = LLM.request({
model,
prompt: "hello",
http: { headers: { "anthropic-beta": "existing-beta" } },
providerOptions: {
contextManagement: {
edits: [
{
type: "compact_20260112",
trigger: { type: "input_tokens", value: 50000 },
pauseAfterCompaction: true,
},
],
},
},
})
const first = yield* LLMClient.generate(request)
expect(first.finishReason.raw).toBe("compaction")
expect(first.message.content).toEqual([{ type: "compaction", provider: model.provider, text: summary }])
expect(first.text).toBe("")
expect(first.usage?.inputTokens).toBe(51010)
expect(first.usage?.outputTokens).toBe(1005)
expect(first.usage?.totalTokens).toBe(52015)
expect(first.usage?.contextTokens).toBe(1000)
const codec = Schema.fromJsonString(Message)
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
yield* LLMClient.generate(
LLMRequest.update(request, {
providerOptions: {},
messages: [...request.messages, message, Message.user("continue")],
}),
)
}),
)
}
}
for (const events of [
[{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: 42 } }],
[{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: "no start" } }],
[
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
{ type: "message_stop" },
],
]) {
testEffect(fixedResponse(sseEvents(...events))).effect(
`rejects malformed compaction lifecycle: ${JSON.stringify(events)}`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.http?.status).toBe(200)
}),
)
}
@@ -0,0 +1,50 @@
import { expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient, WebSocketTransport } from "../../src/route.js"
import { OpenAI } from "../../src/providers.js"
import { testEffect } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
testEffect(fixedResponse("unexpected HTTP fallback")).effect(
"WebSocket responses preserve compaction options and replay state",
() =>
Effect.gen(function* () {
const checkpoint = { type: "compaction", id: "cmp_ws", encrypted_content: "opaque" }
const sent: unknown[] = []
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: (message) =>
Effect.sync(() => {
const body = JSON.parse(message)
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
expect(body.stream).toBeUndefined()
if (sent.length) expect(body.input[1]).toEqual(checkpoint)
sent.push(body)
}),
messages: Stream.fromIterable(
[
{ type: "response.created", response: { id: "resp_ws" } },
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.completed", response: { id: "resp_ws", output: [checkpoint] } },
].map((event) => JSON.stringify(event)),
),
close: Effect.void,
}),
})
const request = LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
prompt: "hello",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
})
const first = yield* LLMClient.generate(request, { webSocket })
expect(first.message.content).toHaveLength(1)
expect(first.message.content[0]?.type).toBe("compaction")
yield* LLMClient.generate(
LLMRequest.update(request, { messages: [...request.messages, first.message, Message.user("continue")] }),
{ webSocket },
)
expect(sent).toHaveLength(2)
}),
)
@@ -0,0 +1,91 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { OpenAI, XAI, Anthropic } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
const history = [
Message.user("Remember the project codename COPPER-ORBIT-42."),
Message.assistant(
"The project codename is COPPER-ORBIT-42. " + "We reviewed the implementation and tests. ".repeat(1000),
),
]
for (const provider of [
{
id: "openai",
key: "OPENAI_API_KEY",
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.3-codex"),
},
{
id: "xai",
key: "XAI_API_KEY",
model: XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }).responses("grok-4.6"),
},
]) {
recordedTests({ prefix: `${provider.id}-compaction`, provider: provider.id, requires: [provider.key] }).effect(
"compacts and continues with the provider checkpoint",
() =>
Effect.gen(function* () {
const request = LLM.request({ model: provider.model, messages: history, generation: { maxTokens: 1024 } })
const compacted = yield* LLMClient.compact(request)
const result = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...compacted.replacement,
Message.user("What is the project codename? Reply only with the codename."),
],
}),
)
expect(result.text).toContain("COPPER-ORBIT-42")
}),
120000,
)
}
recordedTests({
prefix: "anthropic-compaction",
provider: "anthropic",
requires: ["ANTHROPIC_API_KEY"],
options: { redact: { allowRequestHeaders: ["anthropic-version", "anthropic-beta"] } },
}).effect(
"automatically compacts and continues after a pause",
() =>
Effect.gen(function* () {
const model = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" }).model(
"claude-sonnet-4-6",
)
const request = LLM.request({
model,
messages: [
Message.user(
"Remember the project codename COPPER-ORBIT-42. " +
"The implementation and tests were reviewed. ".repeat(10000),
),
],
generation: { maxTokens: 4096 },
providerOptions: {
contextManagement: {
edits: [
{ type: "compact_20260112", trigger: { type: "input_tokens", value: 50000 }, pauseAfterCompaction: true },
],
},
},
})
const first = yield* LLMClient.generate(request)
expect(first.finishReason.raw).toBe("compaction")
expect(first.message.content.some((part) => part.type === "compaction")).toBe(true)
const result = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.user("What is the project codename? Reply only with the codename."),
],
}),
)
expect(result.text).toContain("COPPER-ORBIT-42")
}),
120000,
)
@@ -0,0 +1,139 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { OpenAI, Azure, XAI } from "../../src/providers/index.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 response = sseEvents(
{ type: "response.output_item.done", item: checkpoint },
{
type: "response.completed",
response: { id: "resp_1", output: [checkpoint], usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } },
},
)
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
]) {
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
expect(body.store).toBe(false)
if (body.input.length > 1) expect(body.input[1]).toEqual(checkpoint)
return respond(response, { headers: { "content-type": "text/event-stream" } })
}),
),
).effect(`${model.provider} compaction survives generation, serialization, and a second request`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
prompt: "hello",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
})
const first = yield* LLMClient.generate(request)
expect(first.message.content).toHaveLength(1)
expect(first.message.content[0]?.type).toBe("compaction")
expect(first.text).toBe("")
const codec = Schema.fromJsonString(Message)
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
yield* LLMClient.generate(
LLMRequest.update(request, { messages: [...request.messages, message, Message.user("continue")] }),
)
const rejected = yield* LLMClient.generate(
LLMRequest.update(request, {
model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
providerOptions: {},
messages: [message],
}),
).pipe(Effect.flip)
expect(rejected.reason._tag).toBe("InvalidRequest")
}),
)
}
testEffect(fixedResponse(sseEvents({ type: "response.completed", response: { output: [checkpoint] } }))).effect(
"recovers compaction from the terminal output when item completion is absent",
() =>
Effect.gen(function* () {
const result = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
)
expect(result.message.content).toHaveLength(1)
expect(result.message.content[0]?.type).toBe("compaction")
}),
)
testEffect(
fixedResponse(sseEvents({ type: "response.output_item.done", item: { type: "compaction", id: "cmp_bad" } })),
).effect("rejects incomplete compaction payloads without publishing a checkpoint", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain("cmp_bad")
}),
)
const textItem = {
type: "message",
id: "msg_after",
role: "assistant",
content: [{ type: "output_text", text: "After checkpoint" }],
}
for (const completed of [false, true]) {
testEffect(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id: checkpoint.id } },
...(completed ? [{ type: "response.output_item.done", output_index: 0, item: checkpoint }] : []),
{ type: "response.output_item.added", output_index: 1, item: textItem },
{ type: "response.output_text.delta", output_index: 1, item_id: textItem.id, delta: "After checkpoint" },
{ type: "response.output_item.done", output_index: 1, item: textItem },
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, textItem] } },
),
),
).effect(completed ? "keeps streamed checkpoints before later text" : "rejects order-unsafe terminal recovery", () =>
Effect.gen(function* () {
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
if (completed) {
const response = yield* LLMClient.generate(request)
expect(response.message.content.map((part) => part.type)).toEqual(["compaction", "text"])
return
}
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Cannot recover a compaction checkpoint")
expect(error.reason.body).toContain("response.completed")
expect(error.reason.http?.status).toBe(200)
}),
)
}
testEffect(
fixedResponse(
sseEvents({
type: "response.completed",
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("mints an id for terminal checkpoints that omit one", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
)
const part = response.message.content[0]
expect(part?.type).toBe("compaction")
if (part?.type !== "compaction") return
expect(part.id).toMatch(/^cmp_[0-9a-f]{32}$/)
expect(part.encrypted).toBe("opaque")
}),
)
@@ -0,0 +1,30 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { it } from "../lib/effect.js"
it.effect("conversation lowering excludes generation settings and tool definitions", () =>
Effect.gen(function* () {
const body = yield* OpenResponses.lowerConversation(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
system: "Keep the context",
messages: [Message.user("hello"), Message.assistant("hi")],
generation: { maxTokens: 100, temperature: 0.5 },
providerOptions: { store: false },
tools: [{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } }],
}),
{ id: "open-responses", name: "Open Responses" },
)
expect(body).toEqual({
model: "fixture",
instructions: "Keep the context",
input: [
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
],
})
}),
)
@@ -0,0 +1,447 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient, Route } from "../../src/route/client.js"
import { Auth } from "../../src/route/auth.js"
import { Endpoint } from "../../src/route/endpoint.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { OpenAI, Azure, XAI, Anthropic, OpenAICompatibleResponses } from "../../src/providers/index.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 retained = {
type: "message",
role: "user",
id: "msg_1",
status: "completed",
content: [{ type: "input_text", text: "retained" }],
}
const output = [retained, checkpoint]
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(request.headers["x-deployment"]).toBe("fixture")
expect(request.headers["x-override"]).toBe("request")
expect(request.headers["x-default"]).toBe("configured")
expect(request.headers.authorization).toBe("Bearer test")
expect(new URL(request.url).searchParams.get("api-version")).toBe("fixture")
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
if (new URL(request.url).pathname.endsWith("/compact")) {
expect(JSON.parse(text)).toEqual({
model: "overlaid",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
instructions: "request instructions",
previous_response_id: "resp_previous",
})
return respond(JSON.stringify({ object: "response.compaction", output }))
}
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
}),
),
).effect("generation and compaction share deployment headers, defaults, auth, query, and middleware", () =>
Effect.gen(function* () {
const headers: string[] = []
const middleware: string[] = []
const route = Route.make({
id: "compaction-headers",
provider: "openai",
protocol: OpenAIResponses.protocol,
compact: OpenAIResponses.route.compact,
transport: OpenAIResponses.httpTransport,
endpoint: Endpoint.path(({ body }) => `/${body.model}/responses`, {
baseURL: "https://example.com",
query: { "api-version": "fixture" },
}),
auth: Auth.bearer("test"),
headers: ({ request }) => {
expect(request.providerOptions?.store).toBe(false)
headers.push(String(request.model.id))
return { "x-deployment": "fixture", "x-override": "route" }
},
defaults: {
headers: { "x-default": "configured", "x-override": "configured" },
providerOptions: { store: false },
http: { body: { instructions: "default instructions" } },
},
})
const request = LLM.request({
model: route.model({ id: "fixture" }),
prompt: "hello",
system: "system instructions",
http: {
headers: { "x-override": "request" },
query: { trace: "request" },
body: {
model: "overlaid",
instructions: "request instructions",
previous_response_id: "resp_previous",
store: false,
stream: true,
},
},
})
const options: Parameters<typeof LLMClient.compact>[1] = {
http: (request, next) => {
middleware.push(new URL(request.url).pathname)
return next(request)
},
}
yield* LLMClient.generate(request, options)
yield* LLMClient.compact(request, options)
expect(headers).toEqual(["fixture", "fixture"])
expect(middleware).toEqual(["/fixture/responses", "/fixture/responses/compact"])
}),
)
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
const item = {
type: model.provider === "xai" ? "x_search_call" : "computer_call",
id: "hosted_1",
status: "completed",
}
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
expect(JSON.parse(text)).toEqual({ model: "fixture", input: [item], instructions: "Keep the context" })
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect(`${model.provider} compacts provider-specific history without lowering generation settings`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
system: "Keep the context",
messages: [
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { [model.route.providerMetadataKey ?? model.provider]: { itemId: item.id } },
}),
],
})
for (const candidate of [
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
]) {
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
const response = yield* LLMClient.compact(candidate)
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
}
}),
)
}
const retainedItems = [
retained,
{
type: "message",
id: "msg_assistant",
role: "assistant",
status: "completed",
phase: "commentary",
content: [
{ type: "output_text", text: "First" },
{ type: "output_text", text: "Second" },
],
},
{
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "Thinking" },
{ type: "summary_text", text: "More thinking" },
],
encrypted_content: "reasoning-state",
},
{ type: "reasoning", id: "rs_2", summary: [], encrypted_content: "hidden-reasoning" },
{
type: "message",
id: "msg_media",
role: "user",
content: [
{ type: "input_image", image_url: "https://example.com/image.png" },
{ type: "input_file", filename: "report.pdf", file_data: "data:application/pdf;base64,cGRm", detail: "high" },
{ type: "input_file", filename: "other.pdf", file_url: "https://example.com/report.pdf", detail: "low" },
],
},
checkpoint,
]
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
...[undefined, "custom"].map((providerMetadataKey) =>
Route.make({
id: providerMetadataKey ?? "default-metadata",
provider: "openai",
providerMetadataKey,
protocol: OpenAIResponses.protocol,
compact: OpenAIResponses.route.compact,
endpoint: OpenAIResponses.route.endpoint,
transport: OpenAIResponses.httpTransport,
}).model({ id: "fixture" }),
),
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
if (new URL(request.url).pathname.endsWith("/compact"))
return respond(JSON.stringify({ object: "response.compaction", output: retainedItems }))
expect(JSON.parse(text).input).toEqual(retainedItems)
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect(`${model.route.id} retains messages, reasoning, and media through typed conversation parts`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
prompt: "hello",
})
const compacted = yield* LLMClient.compact(request)
expect(compacted.replacement.map((message) => message.role)).toEqual([
"user",
"assistant",
"assistant",
"assistant",
"user",
"assistant",
])
expect(compacted.replacement[1]?.content).toEqual([
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
])
expect(compacted.replacement[2]?.content.map((part) => part.type)).toEqual(["reasoning", "reasoning"])
expect(compacted.replacement[4]?.content.map((part) => part.type)).toEqual(["media", "media", "media"])
const codec = Schema.fromJsonString(Schema.Array(Message))
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement))
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
}),
)
}
for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key: "overridden" }]) {
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
expect(JSON.parse(text)).toEqual({
model: "fixture",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
service_tier: overlay?.service_tier ?? "flex",
prompt_cache_key: overlay?.prompt_cache_key ?? "affinity",
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
})
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect(`compact preserves supported request controls${overlay ? " with HTTP overrides" : ""}`, () =>
LLMClient.compact(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
prompt: "hello",
promptCacheKey: "affinity",
providerOptions: { serviceTier: "flex" },
generation: { maxTokens: 100 },
http: {
body: {
stream: true,
store: false,
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
...overlay,
},
},
}),
),
)
}
for (const item of [
{ type: "unknown_provider_item", data: "do not hide in a compaction part" },
{
type: "message",
role: "user",
content: [{ type: "input_image", image_url: "https://example.com/image.png", detail: 42 }],
},
{ type: "message", role: "user", content: [] },
{
type: "message",
role: "assistant",
content: [{ type: "input_image", image_url: "https://example.com/image.png" }],
},
{ type: "message", role: "user", content: [{ type: "input_file", filename: "missing.pdf" }] },
{
type: "message",
role: "user",
content: [{ type: "input_file", filename: "bad.pdf", file_url: "https://example.com/report.pdf", detail: 42 }],
},
{
type: "message",
role: "user",
content: [
{
type: "input_file",
filename: "both.pdf",
file_url: "https://example.com/report.pdf",
file_data: "data:application/pdf;base64,cGRm",
},
],
},
]) {
testEffect(fixedResponse(JSON.stringify({ object: "response.compaction", output: [item, checkpoint] }))).effect(
`rejects unsupported compact output: ${JSON.stringify(item)}`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain(JSON.stringify(item))
expect(error.reason.http?.status).toBe(200)
}),
)
}
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
const images = [undefined, "low", "high", "auto"].map((detail) => ({
type: "input_image",
image_url: "https://example.com/image.png",
...(detail === undefined ? {} : { detail }),
}))
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
if (new URL(request.url).pathname.endsWith("/compact"))
return respond(
JSON.stringify({
object: "response.compaction",
output: [{ type: "message", role: "user", content: images }, checkpoint],
}),
)
expect(JSON.parse(text).input[0].content).toEqual(images)
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
}),
),
).effect(`${model.provider} preserves retained image detail through serialization and replay`, () =>
Effect.gen(function* () {
const request = LLM.request({ model, prompt: "hello" })
const compacted = yield* LLMClient.compact(request)
const codec = Schema.fromJsonString(Schema.Array(Message))
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement))
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
}),
)
}
testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic compaction options", () =>
Effect.gen(function* () {
const request = LLMRequest.update(
LLM.request({ model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"), prompt: "hello" }),
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
)
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("LLMClient.compact")
}),
)
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(request.method).toBe("POST")
expect(request.headers[model.provider === "azure" ? "api-key" : "authorization"]).toBe(
model.provider === "azure" ? "test" : "Bearer test",
)
if (new URL(request.url).pathname.endsWith("/responses/compact")) {
expect(body).toEqual({
model: model.id,
input: [{ role: "user", content: [{ type: "input_text", text: "original" }] }],
instructions: "system",
})
return respond(
JSON.stringify({
object: "response.compaction",
output,
usage: { input_tokens: 1000, output_tokens: 10, total_tokens: 1010 },
}),
{ headers: { "content-type": "application/json" } },
)
}
expect(new URL(request.url).pathname.endsWith("/responses")).toBe(true)
expect(body.input).toEqual([...output, { role: "user", content: [{ type: "input_text", text: "continue" }] }])
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect(`${model.provider} explicitly compacts and replays the entire canonical window`, () =>
Effect.gen(function* () {
const request = LLM.request({ model, prompt: "original", system: "system", http: { body: { store: false } } })
const compacted = yield* LLMClient.compact(request)
expect(compacted.usage?.totalTokens).toBe(1010)
expect(compacted.replacement.map((message) => message.role)).toEqual(["user", "assistant"])
expect(compacted.replacement[0]?.content).toEqual([{ type: "text", text: "retained" }])
expect(compacted.replacement[1]?.content).toEqual([
{ type: "compaction", provider: model.provider, id: "cmp_1", encrypted: "opaque" },
])
const codec = Schema.fromJsonString(Schema.Array(Message))
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement))
yield* LLMClient.generate(LLMRequest.update(request, { messages: [...messages, Message.user("continue")] }))
}),
)
}
for (const model of [
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
OpenAICompatibleResponses.configure({ apiKey: "test", baseURL: "https://compatible.example/v1" }).model("model"),
]) {
testEffect(fixedResponse("must not execute")).effect(
`${model.route.id} does not inherit an unsupported compact endpoint`,
() =>
Effect.gen(function* () {
// @ts-expect-error Untyped callers must still receive the runtime capability error.
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
}
testEffect(
fixedResponse(JSON.stringify({ object: "response.compaction", output: [retained], debug: "original payload" })),
).effect("invalid explicit compaction preserves the original response and HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain("original payload")
expect(error.reason.http?.status).toBe(200)
}),
)
@@ -0,0 +1,60 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, Message } from "../../src/index.js"
import { OpenAI, Azure, XAI } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
it.effect(`${model.provider} preserves image detail through message serialization and lowering`, () =>
Effect.gen(function* () {
const details = [undefined, "low", "high", "auto"]
const message = Message.user(
details.map((detail) => ({
type: "media",
mediaType: "image/png",
data: "https://example.com/image.png",
providerMetadata:
detail === undefined ? undefined : { [model.route.providerMetadataKey ?? model.provider]: { detail } },
})),
)
const codec = Schema.fromJsonString(Message)
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))],
}),
)
expect(prepared.body.input[0].content).toEqual(
details.map((detail) => ({
type: "input_image",
image_url: "https://example.com/image.png",
detail,
})),
)
}),
)
}
it.effect("rejects malformed image detail instead of silently discarding it", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
messages: [
Message.user({
type: "media",
mediaType: "image/png",
data: "https://example.com/image.png",
providerMetadata: { openai: { detail: 42 } },
}),
],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
@@ -329,69 +329,126 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
Effect.gen(function* () {
const terminal = {
type: "function_call",
item_id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: '{\n "city": "Paris"\n}',
output_index: 1,
status: "completed",
}
const events = yield* collect(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "msg_879a68b589198b4c" },
},
{ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "msg_879a68b589198b4c" } },
{
type: "response.output_item.added",
output_index: 1,
item: {
type: "function_call",
id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: "",
status: "in_progress",
},
])
}),
)
},
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "fc_828bee50dee1d029",
delta: '{\n "city": "Paris"\n}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_828bee50dee1d029",
arguments: '{\n "city": "Paris"\n}',
},
{ type: "response.output_item.done", output_index: 1, item: terminal },
{
type: "response.completed",
response: { id: "resp_1", output: [{ type: "reasoning", id: "msg_879a68b589198b4c" }, terminal] },
},
)
const providerMetadata = { "openai-compatible": { itemId: "fc_828bee50dee1d029" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-input-delta",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
text: '{\n "city": "Paris"\n}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-call",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
input: { city: "Paris" },
providerMetadata,
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("mints an id for a done-only tool that never had one", () =>
Effect.gen(function* () {
const events = yield* collect(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
completed,
)
const call = events.find(LLMEvent.is.toolCall)
expect(call).toMatchObject({ id: "call_1", name: "lookup", input: { query: "weather" } })
expect(call?.providerMetadata?.["openai-compatible"]).toMatchObject({
itemId: expect.stringMatching(/^fc_[0-9a-f]{32}$/),
})
}),
)
it.effect("opens and closes a done-only tool once", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
@@ -436,21 +493,6 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
@@ -500,14 +542,15 @@ describe("Open Responses basic-item lifecycles", () => {
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
{ type: "text-end", id: "msg_1" },
])
}),
@@ -586,23 +586,21 @@ describe("Open Responses-compatible route", () => {
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
Effect.forEach([null, 0, false, {}, []], (id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
@@ -610,43 +608,6 @@ describe("Open Responses-compatible route", () => {
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -469,7 +469,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues an item-id-less tool call with only the new tool output", () =>
it.effect("continues a streamed tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,6 +485,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -2129,47 +2130,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2389,7 +2349,7 @@ describe("OpenAI Responses route", () => {
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2931,14 +2891,10 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
// A completed item that is re-added stays closed.
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -3793,43 +3749,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -4017,7 +3936,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,6 +3951,7 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -4053,37 +3973,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
+62 -1
View File
@@ -1,5 +1,18 @@
import { describe, expect } from "bun:test"
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
import {
AIError,
CompactionPart,
CompactionResponse,
LanguageModel,
LLM,
LLMClient,
LLMEvent,
LLMRequest,
Message,
ProviderID,
RateLimitError,
} from "../src/index.js"
import { OpenAI } from "../src/providers.js"
import { OpenAIChat } from "../src/protocols/openai-chat.js"
import { TestLLM } from "../src/testing.js"
import { Effect, Fiber, Latch, Stream } from "effect"
@@ -66,6 +79,54 @@ describe("TestLLM legacy client", () => {
})
describe("TestLLM first-class client", () => {
it.effect("rejects response fixtures for the wrong operation", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
yield* client.push(TestLLM.stop(), new CompactionResponse({ replacement: [] }))
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("scripts replacement windows with the same lazy recording, gates, and fallback controls", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
const compacted = new CompactionResponse({
replacement: [
Message.user("retained input"),
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
Message.user("retained tail"),
],
})
yield* client.push(compacted, TestLLM.text("continued", "answer"))
const operation = LLMClient.compact(request)
expect(yield* client.requests()).toEqual([])
const gate = yield* client.gate()
const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true }))
yield* gate.started
yield* client.wait(1)
expect(fiber.pollUnsafe()).toBeUndefined()
yield* gate.release
expect(yield* Fiber.join(fiber)).toBe(compacted)
const next = LLMRequest.update(request, { messages: compacted.replacement })
expect((yield* LLMClient.generate(next)).text).toBe("continued")
yield* client.serve((observed) => {
expect(observed).toBe(next)
return compacted
})
expect(yield* LLMClient.compact(next)).toBe(compacted)
yield* client.always(compacted)
expect(yield* LLMClient.compact(next)).toBe(compacted)
expect(yield* client.requests()).toEqual([request, next, next, next])
}),
)
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
Effect.gen(function* () {
const llm = yield* TestLLM.Test
@@ -11,6 +11,7 @@ import type {
} from "@opencode-ai/client/promise"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -122,7 +123,7 @@ export async function setupTimeline(
messages?: TimelineMessage[]
sessionMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean>
settings?: Record<string, boolean | TimelineDetail>
sessions?: Session[]
cpuRate?: number
viewport?: { width: number; height: number }
@@ -92,6 +92,8 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(false)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
@@ -114,6 +116,8 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(true)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
await expect(openFile).toBeVisible()
@@ -67,6 +67,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
const panel = settings.getByRole("tabpanel")
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
@@ -87,6 +88,7 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
.toBe(true)
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -30,9 +30,11 @@ test("session settings use the remote server context", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeAttached()
await expect(sessionHeading).toBeHidden()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
@@ -66,6 +68,15 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
})
@@ -67,6 +67,12 @@ test("follows a live session move while the agent catalog is still loading", asy
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { timelineDetail: { notices: { placement: "separate" } } } }),
)
})
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
@@ -95,9 +101,7 @@ test("follows a live session move while the agent catalog is still loading", asy
await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return (
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
)
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
})
session.directory = destination
await transport.send({
@@ -221,7 +225,8 @@ function recoveryRequests(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path))
requests.push(path)
})
return requests
}
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { setupTimeline } from "../performance/timeline-stability/fixture"
for (const width of [1400, 390]) {
@@ -16,6 +17,9 @@ for (const width of [1400, 390]) {
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
]
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
locale: profile.locale,
viewport: { width, height: 900 },
sessionMessages: [
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
@@ -36,7 +37,9 @@ test("renders a completed single-file patch", async ({ page }) => {
),
]),
],
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -68,7 +71,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
@@ -100,6 +105,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() =>
row.evaluate((element) => {
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -23,20 +24,23 @@ for (const expanded of [false, true]) {
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
const id = `prt_shell_default_${expanded}`
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
messages: [userMessage(), assistantMessage([shell(id, "running", lines(3))], { completed: false })],
settings: {
timelineDetail: {
...timelinePresets[2].value,
shell: { placement: "separate", details: expanded ? "expanded" : "collapsed" },
},
},
})
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 250)
await timeline.send(partUpdated(shell(id, "completed", lines(6))))
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")))
await timeline.send(status("idle"))
await expect(page.getByText("Sibling content", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
@@ -46,6 +50,9 @@ test("transitions a streaming shell from writing through command execution", asy
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
@@ -95,7 +102,9 @@ test("shimmers and expands a running shell command", async ({ page }) => {
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: { shellToolPartsExpanded: false },
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -110,7 +119,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
})
for (const open of [false, true]) {
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
test(`keeps ${open ? "expanded" : "collapsed"} Separate reasoning intent through shell completion`, async ({
page,
}) => {
const reasoningID = `prt_reasoning_hidden_${open}`
@@ -118,7 +127,13 @@ for (const open of [false, true]) {
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { showReasoningSummaries: false },
settings: {
timelineDetail: {
...timelinePresets[2].value,
thinking: { placement: "separate", details: "collapsed" },
shell: { placement: "separate", details: "collapsed" },
},
},
cpuRate: 4,
})
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
@@ -141,28 +156,11 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(open),
)
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(group).toHaveCount(0)
await expect(thought).toHaveAttribute("aria-expanded", String(open))
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
})
}
@@ -172,6 +170,9 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
compactionDelta,
compactionEnded,
@@ -49,6 +50,9 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
@@ -84,7 +88,12 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
})
await timeline.send(
compactionStarted({
@@ -161,7 +170,12 @@ test("updates running compactions to failed and cancelled boundaries", async ({
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true)],
})
const card = page.locator('[data-component="task-tool-card"]')
await expect(card).toBeVisible()
await expect(card).toContainText("Inspect code")
@@ -198,6 +212,9 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
@@ -213,6 +230,7 @@ for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -281,6 +299,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -369,6 +388,13 @@ test("separates blocking and already-backgrounded work into two rows", async ({
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
@@ -376,6 +402,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(list).not.toContainText("Foreground task")
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -14,7 +15,9 @@ test.describe("session timeline projection", () => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
messages: [
userMessage(),
assistantMessage([
@@ -34,6 +37,7 @@ test.describe("session timeline projection", () => {
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
await expect(initialFile).toBeVisible()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await initialFile.getByRole("button").click()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
@@ -110,8 +114,11 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
await timeline.send(status("idle"), 100)
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
})
await timeline.send(status("idle"))
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
@@ -127,6 +134,7 @@ test.describe("session timeline projection", () => {
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
settings: { timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } } },
sessionMessages: [
{
id: "msg_model_fast_nano",
@@ -5,134 +5,48 @@ import {
reasoningPart,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
test("changes timeline presets and saves custom thinking details", async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
reasoningPart(
"prt_reasoning_settings",
"## Inspecting stability\n\nThe selected mode controls these details.",
),
],
{ completed: false },
),
assistantMessage([
reasoningPart("prt_reasoning_settings", "## Inspecting stability\n\nThe selected mode controls these details."),
]),
],
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
const settings = page.getByTestId("settings-screen")
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
for (const label of ["Full", "Hidden", "Compact"] as const) {
await page.keyboard.press("Control+,")
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
await expect(select).toHaveAttribute("aria-expanded", "false")
await select.click()
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
await page.getByRole("option", { name: label, exact: true }).click()
await expect(select).toHaveText(label)
await expect(select).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
.toBe(label.toLowerCase())
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
if (label === "Hidden") {
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
continue
}
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
if (label === "Full")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
if (label === "Compact") {
await expect(part.getByRole("button")).toContainText("Inspecting stability")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
}
}
await page.keyboard.press("Control+,")
await expect(select).toHaveText("Compact")
})
// The persisted boolean migrates to compact (false) or full (true).
for (const summaries of [false, true]) {
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
...(profile === "none"
? []
: [
reasoningPart(
`prt_reasoning_${summaries}_${profile}`,
profile === "blank"
? " "
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
),
]),
...(profile === "tool"
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
: []),
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
],
{ completed: false },
),
],
settings: { showReasoningSummaries: summaries },
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
profile === "blank" || profile === "heading" ? 1 : 0,
)
if (profile === "none") {
await expect(part).toHaveCount(0)
return
}
if (profile === "blank") {
await expect(part).toContainText("Thinking")
await expect(part.getByRole("heading")).toHaveCount(0)
return
}
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
}
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
const trigger = part.locator('[data-slot="collapsible-trigger"]')
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
if (!summaries) {
await expect(body).toBeHidden()
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
await expect(body).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(body).toBeHidden()
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
})
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(slider).toBeEnabled()
await slider.press("Home")
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
if (index) await slider.press("ArrowRight")
await expect(slider).toHaveValue(String(index))
await expect(slider).toHaveAttribute("aria-valuetext", name)
}
}
await slider.press("Home")
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
await page.getByRole("option", { name: "Grouped", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await expect
.poll(() =>
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.timelineDetail?.thinking),
)
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
})
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
await setupTimeline(page, {
@@ -18,10 +18,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await timeline.send(status("busy"))
await timeline.send(status("idle"))
await timeline.send(partUpdated(textPart(textID, "Final after early idle")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
@@ -161,12 +161,14 @@ test("keeps failed search calls and their error cards inside the collapsed stack
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep")
await expect(summary).toHaveAttribute("aria-expanded", "false")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await glob.getByRole("button").click()
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
@@ -180,6 +182,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await group.locator('[data-timeline-part-id="prt_error_grep"]').getByRole("button").click()
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
setupTimeline,
@@ -74,21 +75,29 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
additions: 1,
deletions: 1,
}
const timeline = await setupTimeline(page, {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update src/outline.ts" },
{ metadata: { files: [file] } },
),
]),
],
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await timeline.settle()
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
@@ -106,8 +115,6 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -7,11 +8,11 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("transitions shell and question through running error outcomes", async ({ page }) => {
test("keeps shell and question failures in their Used group", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: { shellToolPartsExpanded: true },
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -23,30 +24,46 @@ test("transitions shell and question through running error outcomes", async ({ p
),
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
partUpdated(
toolPart(
shellID,
"shell",
"completed",
{ command: "exit 1" },
{ output: "Command exited 1", metadata: { exit: 1 } },
),
),
)
await timeline.send(
partUpdated(
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
),
250,
)
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${shellID},${questionID}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
const shell = group.locator(`[data-timeline-part-id="${shellID}"]`)
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await shell.locator('[data-slot="collapsible-trigger"]').click()
await expect(shell).toContainText("Command exited 1")
const question = group.locator(`[data-timeline-part-id="${questionID}"]`)
await expect(question).toContainText(/dismissed/i)
})
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -77,9 +94,12 @@ test("preserves surviving grouped patch state when its first patch fails", async
],
})
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
@@ -99,23 +119,20 @@ test("preserves surviving grouped patch state when its first patch fails", async
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await group.locator(`[data-timeline-part-id="${failed}"] [data-slot="collapsible-trigger"]`).click()
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
settings: { timelineDetail: { ...timelinePresets[2].value, tools: { placement: "separate" } } },
messages: [
userMessage(),
assistantMessage([
@@ -148,7 +165,10 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [userMessage(), assistantMessage(parts)],
})
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
await group.getByRole("button").click()
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -19,6 +20,9 @@ for (const width of [1400, 390]) {
messages: [userMessage()],
sessionStatus: { [sessionID]: { type: "busy" } },
viewport: { width, height: 900 },
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toHaveCount(1)
@@ -51,7 +55,9 @@ for (const name of ["shell", "patch", "subagent"] as const) {
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: { editToolPartsExpanded: true },
settings: {
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
@@ -89,13 +95,8 @@ for (const name of ["shell", "patch", "subagent"] as const) {
await expect(working).toHaveCount(0)
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
if (name === "shell") {
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
).toHaveAttribute("aria-expanded", "false")
await expect(group).toBeVisible()
}
await expect(tool).toBeVisible()
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeVisible()
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
@@ -259,6 +260,7 @@ for (const failed of [false, true]) {
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(editor).toBeEditable()
await editor.fill("Check the working indicator immediately.")
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
const requested = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
@@ -32,6 +32,24 @@ test.beforeEach(async ({ page }) => {
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("settings has its own route and returns through app history", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
const home = page.getByRole("button", { name: "Home", exact: true })
await expect(page).toHaveURL("/settings")
await expect(home).toHaveAttribute("aria-pressed", "false")
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(home).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
await expect(home).toHaveAttribute("aria-pressed", "false")
await home.click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -9,30 +9,42 @@ const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("new session tab hugs its content", async ({ page }) => {
test("new session tab matches neighboring session widths", async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID, directory }) => {
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionID },
{ type: "session", server, sessionId: sessionA },
{ type: "draft", server, directory, draftID: "draft_tab_width" },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionID: sessionA.id, directory: sessionA.directory },
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
await expect(draftTab).toContainText("New session")
const width = (await draftTab.boundingBox())?.width ?? 0
expect(width).toBeGreaterThan(100)
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title])
await testInfo.attach("new-session-between-tabs", {
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
contentType: "image/png",
})
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() =>
tabs.evaluateAll((tabs) => {
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThan(1)
}
})
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
@@ -182,7 +194,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
})
.toBe(true)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
@@ -280,9 +292,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
"tab-project",
])
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText(["tab-project"])
await expect(
page
.locator('[data-slot="mobile-tabs-drawer"]')
@@ -53,9 +53,8 @@ test("creates a session in a new project and selects its model", async ({ page }
}),
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
// Listings are requested by absolute path and returned relative to the stable Location.
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
})
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
@@ -76,12 +75,23 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
const listing = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
})
await picker.getByRole("button", { name: "Parent", exact: true }).click()
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await expect(picker).toBeHidden()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="composer"]'))
+2 -1
View File
@@ -2,6 +2,7 @@
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
@@ -50,6 +50,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
if (!connection) return
pickDirectory({
server: connection,
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
+1 -1
View File
@@ -52,7 +52,7 @@ export function NewSessionView(props: {
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
@@ -1,5 +1,6 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
@@ -15,6 +16,7 @@ export function createProviderConnectionController(options: {
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
const location = () => {
@@ -184,7 +186,14 @@ export function createProviderConnectionController(options: {
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => ({ ok: true as const, authorization: response.data }))
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
+34
View File
@@ -951,6 +951,40 @@ export const dict = {
"settings.general.section.feed": "Feed",
"settings.general.section.display": "Display",
"settings.timeline.title": "Timeline",
"settings.timeline.detail": "Timeline detail",
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
"settings.timeline.preset.everything": "Everything",
"settings.timeline.preset.detailed": "Detailed",
"settings.timeline.preset.compact": "Compact",
"settings.timeline.preset.quiet": "Quiet",
"settings.timeline.preset.text-only": "Text only",
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
"settings.timeline.description.detailed":
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
"settings.timeline.custom": "Custom",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
"settings.timeline.activity": "Activity",
"settings.timeline.category.shell": "Shell",
"settings.timeline.category.edit": "Edits",
"settings.timeline.category.thinking": "Thinking",
"settings.timeline.category.subagents": "Subagents",
"settings.timeline.category.notices": "Notices",
"settings.timeline.category.tools": "Other tools",
"settings.timeline.placement.title": "Placement",
"settings.timeline.placement.separate": "Separate",
"settings.timeline.placement.grouped": "Grouped",
"settings.timeline.placement.hidden": "Hidden",
"settings.timeline.expansion.title": "Details",
"settings.timeline.expansion.collapsed": "Collapsed",
"settings.timeline.expansion.expanded": "Expanded",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.shell.title": "Terminal shell",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -85,6 +85,15 @@ describe("normalizeProviderList", () => {
})
})
describe("normalizeProjectInfo", () => {
test("keeps the project VCS backend", () => {
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
})
})
describe("directoryKey", () => {
test("normalizes slashes", () => {
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
@@ -135,6 +135,5 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
...project,
worktree,
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
vcs: project.vcs === "git" ? "git" : undefined,
}
}
-2
View File
@@ -230,8 +230,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
if (event.type === "reference.updated" && children.active(key))
void data.location.reference.sync({ directory: key }).catch(() => undefined)
})
onCleanup(unsub)
+5 -5
View File
@@ -55,9 +55,9 @@ export function createSessionReview(input: {
const options = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
const project = input.session.project()
if (project?.vcs === "git") list.push("git")
if (project?.vcs) list.push("git")
if (
project?.vcs === "git" &&
project?.vcs &&
vcs()?.branch.current &&
vcs()?.branch.default &&
vcs()?.branch.current !== vcs()?.branch.default
@@ -94,7 +94,7 @@ export function createSessionReview(input: {
const value = vcsMode()
return {
queryKey: [...vcsKey(), value] as const,
enabled: server.connection.status() === "connected" && wantsReview() && input.session.project()?.vcs === "git",
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
@@ -110,7 +110,7 @@ export function createSessionReview(input: {
})
const detailsQuery = createQuery(() => ({
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
enabled: state.detailsOpen && server.connection.status() === "connected" && input.session.project()?.vcs === "git",
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
queryFn: () =>
server.api.vcs
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
@@ -413,7 +413,7 @@ export function createSessionReview(input: {
tab: () => state.mobileTab,
},
mode,
noGit: createMemo(() => !!input.session.project() && input.session.project()?.vcs !== "git"),
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
open,
openFile,
options,
+1 -1
View File
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
baseBranch={
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
}
diffs={project().vcs === "git" ? review.details.diffs() : []}
diffs={project().vcs ? review.details.diffs() : []}
sessionID={session.identity.params.id ?? ""}
moveEligible={composer.workspaceMoveEligible()}
moveDismissed={store.mobileMoveDismissed}
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { Ghostty, Terminal } from "ghostty-web"
describe("terminal application mouse reporting", () => {
let container: HTMLDivElement
let terminal: Terminal
let canvas: HTMLCanvasElement
let data: string[]
beforeEach(async () => {
container = document.createElement("div")
document.body.appendChild(container)
terminal = new Terminal({ cols: 80, rows: 24, ghostty: await Ghostty.load() })
terminal.open(container)
terminal.write("\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1015h\x1b[?1006h")
canvas = container.querySelector("canvas")!
data = []
terminal.onData((value) => data.push(value))
})
afterEach(() => {
terminal.dispose()
container.remove()
})
const wheel = (deltaX: number, deltaY: number) => {
const event = new WheelEvent("wheel", { deltaX, deltaY, bubbles: true })
Object.defineProperties(event, { clientX: { value: 40 }, clientY: { value: 30 } })
canvas.dispatchEvent(event)
}
test("reports wheel input with its terminal coordinates instead of arrow keys", () => {
wheel(0, -100)
expect(data).toEqual(["\x1b[<64;6;3M"])
})
test("does not discard small trackpad deltas", () => {
wheel(0, -1)
expect(data).toEqual(["\x1b[<64;6;3M"])
})
test("reports horizontal scrolling", () => {
wheel(100, 0)
expect(data).toEqual(["\x1b[<67;6;3M"])
})
test("distinguishes pointer movement from a left-button drag", () => {
canvas.dispatchEvent(new MouseEvent("mousemove", { clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual(["\x1b[<35;6;3M"])
})
test("encodes Alt-modified clicks", () => {
canvas.dispatchEvent(new MouseEvent("mousedown", { altKey: true, clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual(["\x1b[<8;6;3M"])
})
test("leaves application clicks available for process selection", () => {
canvas.dispatchEvent(new MouseEvent("mousedown", { clientX: 40, clientY: 30, bubbles: true }))
canvas.dispatchEvent(new MouseEvent("mouseup", { clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual(["\x1b[<0;6;3M", "\x1b[<0;6;3m"])
})
test("preserves Shift as the terminal selection override", () => {
canvas.dispatchEvent(new MouseEvent("mousedown", { shiftKey: true, clientX: 40, clientY: 30, bubbles: true }))
expect(data).toEqual([])
})
})
@@ -3,6 +3,7 @@ import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/p
import { createRoot } from "solid-js"
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
@@ -63,6 +64,7 @@ describe("visibleTimelineMessages", () => {
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
timelineDetail: () => timelinePresets[2].value,
pendingUserMessageIDs: () => new Set([steer.id]),
})
expect(projection.activeMessageID()).toBe("msg_1")
@@ -21,6 +21,7 @@ import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessage
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
const emptyMessages: SessionMessageInfo[] = []
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
@@ -101,12 +102,32 @@ export function createTimelineController(input: { session: TimelineSessionSource
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
const timelineDetail = createMemo(() => {
const detail = settings.general.timelineDetail()
return {
shell: { ...detail.shell },
edit: { ...detail.edit },
thinking: { ...detail.thinking },
subagents: { ...detail.subagents },
notices: { ...detail.notices },
tools: { ...detail.tools },
}
})
const reasoningMode = (): ReasoningMode =>
timelineDetail().thinking.placement === "hidden"
? "hidden"
: timelineDetail().thinking.details === "expanded"
? "full"
: "compact"
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
const projection = createTimelineProjection({
sessionMessages: projectedMessages,
status: input.session.data.status,
reasoningMode: settings.general.reasoningMode,
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
editToolDefaultOpen: settings.general.editToolPartsExpanded,
reasoningMode,
shellToolDefaultOpen: shellToolPartsExpanded,
editToolDefaultOpen: editToolPartsExpanded,
timelineDetail,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -235,9 +256,10 @@ export function createTimelineController(input: { session: TimelineSessionSource
childTitle,
showHeader,
projection,
reasoningMode: settings.general.reasoningMode,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
editToolPartsExpanded: settings.general.editToolPartsExpanded,
timelineDetail,
reasoningMode,
shellToolPartsExpanded,
editToolPartsExpanded,
},
pending: {
rename: () => pending.rename,
@@ -420,6 +420,7 @@ function MessageTimelineView(
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
projection,
showHeader,
pinned,
@@ -527,6 +528,7 @@ function MessageTimelineView(
reasoningMode: props.data.reasoningMode,
shellToolDefaultOpen: props.data.shellToolPartsExpanded,
editToolDefaultOpen: props.data.editToolPartsExpanded,
timelineDetail: props.data.timelineDetail,
disclosure: virtualized.disclosure,
centered: () => props.centered,
padding: turnPadding,
@@ -21,7 +21,7 @@ import {
type Accessor,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, reconcile } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import type { createTimelineProjection } from "./projection"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
@@ -33,7 +33,15 @@ const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
const upwardKeys = new Set(["up", "page-up", "home"])
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
const cache = new Map<
string,
{
measurements: VirtualItem[]
toolOpen: Record<string, boolean | undefined>
patchGroupKeys: Map<string, string>
presentationKey?: string
}
>()
type Projection = Pick<
ReturnType<typeof createTimelineProjection>,
@@ -42,6 +50,7 @@ type Projection = Pick<
type Input = {
sessionKey: Accessor<string>
presentationKey?: Accessor<string>
projection: Projection
showHeader: Accessor<boolean>
/** True while the timeline follows the newest content. Drives every anchoring decision. */
@@ -77,11 +86,20 @@ export function createTimelineVirtualizer(input: Input) {
const isDesktop = createMediaQuery("(min-width: 768px)")
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
const ownerSessionKey = input.sessionKey()
const cached = cache.get(ownerSessionKey)
const entry = cache.get(ownerSessionKey)
const cached = entry?.presentationKey === input.presentationKey?.() ? entry : undefined
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const patchGroupKeys = cached?.patchGroupKeys ?? new Map<string, string>()
createEffect(
on(
() => input.presentationKey?.(),
() => setToolOpen(reconcile({})),
{ defer: true },
),
)
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
@@ -189,7 +207,8 @@ export function createTimelineVirtualizer(input: Input) {
},
scrollEndThreshold: 80,
get scrollMargin() {
return topOffset()
// Empty projections still need the bottom spacer for running status.
return rows().length > 0 ? topOffset() : 0
},
paddingEnd: 64,
get rangeExtractor() {
@@ -540,15 +559,13 @@ export function createTimelineVirtualizer(input: Input) {
}}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<Show when={rows().length > 0}>
<div
data-timeline-row="bottom-spacer"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
>
{props.bottomSpacer}
</div>
</Show>
<div
data-timeline-row="bottom-spacer"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
>
{props.bottomSpacer}
</div>
</div>
</ScrollView>
</div>
@@ -557,7 +574,12 @@ export function createTimelineVirtualizer(input: Input) {
onCleanup(() => {
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
cache.set(ownerSessionKey, {
measurements: virtualizer.takeSnapshot(),
toolOpen: { ...toolOpen },
patchGroupKeys,
presentationKey: input.presentationKey?.(),
})
while (cache.size > 16) cache.delete(cache.keys().next().value!)
coldPending = false
contentObserver?.disconnect()
@@ -569,6 +591,7 @@ export function createTimelineVirtualizer(input: Input) {
return {
disclosure: {
patchGroupKeys,
value: (key: string) => toolOpen[key],
set: (key: string, open: boolean) => setToolOpen(key, open),
},
+13 -55
View File
@@ -4,7 +4,7 @@ import { Button } from "@opencode-ai/ui/button"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { TimelineDetailControl } from "@/settings/timeline-detail"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useUpdaterAction } from "@/shell/updates/action"
@@ -184,34 +184,6 @@ const FollowUpBehaviorSetting: Component = () => {
)
}
const ReasoningModeSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: ReasoningMode; label: string }[] => [
{ value: "hidden", label: language.t("settings.general.row.reasoningMode.hidden") },
{ value: "compact", label: language.t("settings.general.row.reasoningMode.compact") },
{ value: "full", label: language.t("settings.general.row.reasoningMode.full") },
])
return (
<SettingsRow
title={language.t("settings.general.row.reasoningMode.title")}
description={language.t("settings.general.row.reasoningMode.description")}
>
<Select
data-action="settings-reasoning-mode"
options={options()}
current={options().find((option) => option.value === settings.general.reasoningMode())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.general.setReasoningMode(option.value)}
/>
</SettingsRow>
)
}
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
@@ -360,8 +332,6 @@ export const SettingsGeneral: Component<{
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<ReasoningModeSetting />
<SettingsRow
title={language.t("session.review.wrapLines")}
description={language.t("settings.general.row.mobileDiffWrap.description")}
@@ -378,30 +348,6 @@ export const SettingsGeneral: Component<{
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.showProjectIcon.title")}
@@ -585,6 +531,18 @@ export const SettingsGeneral: Component<{
<div class="settings-tab-body">
<GeneralSection />
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
<h3 class="settings-section-title">{language.t("settings.timeline.title")}</h3>
<SettingsList>
<div class="py-5">
<TimelineDetailControl
value={settings.general.timelineDetail()}
onChange={settings.general.setTimelineDetail}
/>
</div>
</SettingsList>
</section>
<Show when={desktop()}>
<UpdatesSection />
</Show>
+20 -53
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { Persistence } from "@/runtime/persistence/schema"
import {
settingsSchema,
@@ -16,54 +17,19 @@ const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
describe("settings reasoning mode migration", () => {
test.each([
[true, "full"],
[false, "compact"],
] as const)("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
const settings = decode(value)
expect(settings.general.reasoningMode).toBe(reasoningMode)
expect(settings.general.showTerminal).toBe(true)
describe("settings timeline detail migration", () => {
test("migrates saved switches and round trips the current settings", () => {
const settings = decode({
general: { shellToolPartsExpanded: true, editToolPartsExpanded: false, showReasoningSummaries: true },
appearance: { fontSize: 16 },
})
expect(settings.general.timelineDetail).toEqual({
...timelinePresets[2].value,
shell: { placement: "separate", details: "expanded" },
thinking: { placement: "separate", details: "expanded" },
})
expect(settings.appearance.fontSize).toBe(16)
expect(settings.general).not.toHaveProperty("showReasoningSummaries")
expect(value.general).not.toHaveProperty("reasoningMode")
})
test.each(["hidden", "compact", "full"])(
"preserves an explicit %s mode over either legacy value",
(reasoningMode) => {
;[true, false].forEach((showReasoningSummaries) => {
const value = { general: { reasoningMode, showReasoningSummaries } }
expect(decode(value).general.reasoningMode).toBe(reasoningMode)
})
},
)
test.each([undefined, null, {}, { showReasoningSummaries: "true" }])(
"defaults invalid or absent legacy settings: %j",
(general) => {
expect(decode({ general }).general.reasoningMode).toBe("compact")
},
)
test("migrates an undefined current mode but defaults an invalid current mode", () => {
expect(decode({ general: { reasoningMode: undefined, showReasoningSummaries: true } }).general.reasoningMode).toBe(
"full",
)
expect(decode({ general: { reasoningMode: "invalid", showReasoningSummaries: true } }).general.reasoningMode).toBe(
"compact",
)
})
test("encodes only the current format and round trips migrated settings", () => {
const settings = decode({ general: { showReasoningSummaries: true, obsolete: true }, obsolete: true })
const encoded = encode(settings)
expect(encoded).toEqual(settings)
expect(encoded).not.toHaveProperty("obsolete")
expect(encoded).not.toHaveProperty("general.obsolete")
expect(encoded).not.toHaveProperty("general.showReasoningSummaries")
expect(decode(encoded)).toEqual(settings)
expect(decode(encode(settings))).toEqual(settings)
})
})
@@ -71,13 +37,16 @@ describe("settings schema", () => {
test("uses the supplied initial values independently of the current schema", () => {
const initial = {
...defaultSettings,
general: { ...defaultSettings.general, reasoningMode: "hidden" as const, autoSave: false },
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
appearance: { ...defaultSettings.appearance, fontSize: 20 },
}
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
expect(restore({ general: { showReasoningSummaries: true } }).general.reasoningMode).toBe("full")
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
placement: "separate",
details: "expanded",
})
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
})
@@ -92,9 +61,7 @@ describe("settings schema", () => {
showStatus: false,
showProjectIcon: false,
showTerminal: false,
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
timelineDetail: timelinePresets[2].value,
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
@@ -144,7 +111,7 @@ describe("settings schema", () => {
showTerminal: true,
autoSave: false,
releaseNotes: true,
reasoningMode: "compact",
timelineDetail: timelinePresets[2].value,
followUpBehavior: "steer",
})
expect(settings.appearance).toEqual({
+89 -31
View File
@@ -1,8 +1,8 @@
import { reconcile } from "solid-js/store"
import { reconcile, unwrap } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
@@ -68,7 +68,10 @@ export function terminalFontFamily(font: string | undefined) {
return stack(font, terminalBase)
}
const reasoningModeSchema = Schema.Literals(["hidden", "compact", "full"])
const placementSchema = Schema.Literals(["separate", "grouped", "hidden"])
const detailsSchema = Schema.Literals(["collapsed", "expanded"])
const activitySchema = Persistence.struct({ placement: placementSchema, details: detailsSchema })
const placementOnlySchema = Persistence.struct({ placement: placementSchema })
const generalSchema = Persistence.struct({
autoSave: Schema.Boolean,
@@ -79,9 +82,14 @@ const generalSchema = Persistence.struct({
showStatus: Schema.Boolean,
showProjectIcon: Schema.Boolean,
showTerminal: Schema.Boolean,
reasoningMode: reasoningModeSchema,
shellToolPartsExpanded: Schema.Boolean,
editToolPartsExpanded: Schema.Boolean,
timelineDetail: Persistence.struct({
shell: activitySchema,
edit: activitySchema,
thinking: activitySchema,
subagents: placementOnlySchema,
notices: placementOnlySchema,
tools: placementOnlySchema,
}),
showCustomAgents: Schema.Boolean,
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
mobileDiffWrap: Schema.Boolean,
@@ -134,25 +142,91 @@ export const settingsSchema = Persistence.struct({
sounds: soundsSchema,
})
function storedTimelineCategory(category: TimelineCategory) {
return Persistence.optional(
Schema.Union([
Schema.Struct({
placement: Persistence.optional(placementSchema),
details: Persistence.optional(detailsSchema),
}),
Schema.Literals(["expanded", "collapsed", "hidden", "visible"]),
]).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
if (typeof value !== "string") return value
return {
placement:
value === "hidden"
? "hidden"
: category === "subagents"
? "separate"
: category === "tools"
? "grouped"
: value === "expanded"
? "separate"
: value === "collapsed"
? "grouped"
: undefined,
details: value === "expanded" ? "expanded" : "collapsed",
}
}),
encode: SchemaGetter.passthrough(),
}),
),
)
}
function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" | null | undefined) {
if (value === undefined || value === null) return
const expanded = value === true || value === "full"
return {
placement: value === "hidden" ? "hidden" : expanded ? "separate" : "grouped",
details: expanded ? "expanded" : "collapsed",
} as const
}
export const settingsPersistence = Persistence.migrate(
settingsSchema,
Schema.Struct({
general: Persistence.optional(
Schema.Struct({
reasoningMode: Schema.optional(Schema.Unknown),
// Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them.
timelineDetail: Schema.optional(
Schema.NullOr(
Schema.Struct({
shell: storedTimelineCategory("shell"),
edit: storedTimelineCategory("edit"),
thinking: storedTimelineCategory("thinking"),
subagents: storedTimelineCategory("subagents"),
notices: storedTimelineCategory("notices"),
tools: storedTimelineCategory("tools"),
}),
),
).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(null)))),
reasoningMode: Schema.optional(Schema.NullOr(Schema.Literals(["hidden", "compact", "full"]))).pipe(
Schema.catchDecoding(() => Effect.succeed(Option.some(null))),
),
showReasoningSummaries: Persistence.optional(Schema.Boolean),
shellToolPartsExpanded: Persistence.optional(Schema.Boolean),
editToolPartsExpanded: Persistence.optional(Schema.Boolean),
}),
),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
if (value.general?.reasoningMode !== undefined || value.general?.showReasoningSummaries === undefined)
return value
const general = value.general
if (!general || general.timelineDetail !== undefined) return value
return {
...value,
general: {
...value.general,
reasoningMode: value.general.showReasoningSummaries ? "full" : "compact",
...general,
timelineDetail: {
shell: legacyTimelineActivity(general.shellToolPartsExpanded),
edit: legacyTimelineActivity(general.editToolPartsExpanded),
thinking: legacyTimelineActivity(
general.reasoningMode === undefined ? general.showReasoningSummaries : general.reasoningMode,
),
},
},
}
}),
@@ -171,9 +245,7 @@ export const defaultSettings: Settings = {
showStatus: false,
showProjectIcon: false,
showTerminal: false,
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
timelineDetail: { ...timelinePresets[2].value },
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
@@ -256,23 +328,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
reasoningMode: withFallback(() => store.general?.reasoningMode, defaultSettings.general.reasoningMode),
setReasoningMode(value: ReasoningMode) {
setStore("general", "reasoningMode", value)
},
shellToolPartsExpanded: withFallback(
() => store.general?.shellToolPartsExpanded,
defaultSettings.general.shellToolPartsExpanded,
),
setShellToolPartsExpanded(value: boolean) {
setStore("general", "shellToolPartsExpanded", value)
},
editToolPartsExpanded: withFallback(
() => store.general?.editToolPartsExpanded,
defaultSettings.general.editToolPartsExpanded,
),
setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value)
timelineDetail: withFallback(() => store.general?.timelineDetail, defaultSettings.general.timelineDetail),
setTimelineDetail(value: TimelineDetail) {
setStore("general", "timelineDetail", structuredClone(unwrap(value)))
},
showCustomAgents,
setShowCustomAgents(value: boolean) {
+11 -7
View File
@@ -7,13 +7,17 @@
.settings-screen {
display: flex;
width: 100%;
height: 100%;
flex: 1;
width: calc(100% - 16px);
min-width: 0;
min-height: 0;
margin-inline: 8px;
margin-block: var(--shell-top-inset, 8px) var(--shell-bottom-inset, 8px);
justify-content: center;
overflow: hidden;
background: var(--v2-background-bg-deep);
border-radius: 10px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
outline: none;
container: settings-screen / inline-size;
}
@@ -27,7 +31,7 @@
@media (max-width: 767px) {
.settings-screen {
--settings-mobile-inner-inset: 8px;
padding-block: var(--settings-top-inset, var(--shell-top-inset, 8px)) var(--shell-bottom-inset, 8px);
margin-block-start: var(--settings-top-inset, var(--shell-top-inset, 8px));
}
}
@@ -51,7 +55,7 @@
.settings-screen .settings-tab-header {
padding: 48px 0 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-deep) calc(100% - 24px), transparent);
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
}
.settings-screen .settings-tab-body {
@@ -349,7 +353,7 @@
gap: 16px;
padding: 8px var(--settings-mobile-inner-inset, 16px);
border-bottom: 0.5px solid var(--v2-border-border-muted);
background: var(--v2-background-bg-deep);
background: var(--v2-background-bg-base);
}
.settings-mobile-nav::after {
@@ -358,7 +362,7 @@
inset-inline: 0;
inset-block-start: 100%;
height: 1px;
background: var(--v2-background-bg-deep);
background: var(--v2-background-bg-base);
pointer-events: none;
}
+13 -24
View File
@@ -1,14 +1,4 @@
import {
Component,
createEffect,
createMemo,
createSignal,
For,
Show,
onCleanup,
onMount,
startTransition,
} from "solid-js"
import { Component, createEffect, createMemo, For, Show, onCleanup, onMount, startTransition } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
@@ -54,9 +44,7 @@ const sections = [
],
] as const
export const SettingsScreen: Component<{
defaultValue?: string
}> = (props) => {
export const SettingsScreen: Component = () => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
@@ -66,7 +54,6 @@ export const SettingsScreen: Component<{
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
let root: HTMLDivElement | undefined
onMount(() => {
@@ -75,10 +62,8 @@ export const SettingsScreen: Component<{
})
onCleanup(() => command.keybinds(true))
createEffect(() => setTab(props.defaultValue ?? "general"))
const server = createMemo(() => {
const route = layout.route()
const route = surface.route()
switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
@@ -101,7 +86,7 @@ export const SettingsScreen: Component<{
const selected = global.settings.server.selected()
const current = server()
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
const route = layout.route()
const route = surface.route()
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
@@ -112,7 +97,7 @@ export const SettingsScreen: Component<{
const showProviders = () => {
dialog.close()
setTab("providers")
surface.open("providers")
}
return (
@@ -130,8 +115,8 @@ export const SettingsScreen: Component<{
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
class="settings"
>
<div class="settings-mobile-nav">
@@ -143,14 +128,18 @@ export const SettingsScreen: Component<{
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>
{language.t(
sections.flat().find((section) => section.value === tab())?.label ?? "settings.tab.preferences",
sections.flat().find((section) => section.value === surface.tab())?.label ??
"settings.tab.preferences",
)}
</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup value={tab()} onChange={(value) => void startTransition(() => setTab(value))}>
<Menu.RadioGroup
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
>
<For each={sections}>
{(group, index) => (
<>
+36 -16
View File
@@ -1,32 +1,52 @@
import { useLocation } from "@solidjs/router"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
import { useCommand } from "@/shell/commands/command"
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
name: "SettingsSurface",
gate: false,
init: () => {
const location = useLocation()
const [store, setStore] = createStore({ open: false, tab: "general" })
const navigate = useNavigate()
const layout = useLayout()
const command = useCommand()
const location = useLocation<{
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; tab: string }
}>()
const open = () => layout.route().type === "settings"
const source = () => location.state?.settings?.route ?? { type: "home" as const }
let focus: HTMLElement | undefined
const close = () => {
if (!store.open) return
setStore("open", false)
if (focus?.isConnected) focus.focus({ preventScroll: true })
focus = undefined
}
createEffect(on(() => `${location.pathname}${location.search}`, close, { defer: true }))
createEffect(
on(
open,
(value) => {
if (value) return
if (focus?.isConnected) focus.focus({ preventScroll: true })
focus = undefined
},
{ defer: true },
),
)
return {
store,
active: open,
route: source,
tab: () => location.state?.settings?.tab ?? "general",
open(tab = "general") {
if (!store.open && document.activeElement instanceof HTMLElement) focus = document.activeElement
setStore({ open: true, tab })
const route = layout.route()
if (route.type !== "settings") {
if (document.activeElement instanceof HTMLElement) focus = document.activeElement
}
navigate("/settings", {
replace: open(),
state: { settings: { route: route.type === "settings" ? source() : route, tab } },
})
},
close() {
if (open()) command.trigger("common.goBack")
},
close,
}
},
})
@@ -0,0 +1,249 @@
[data-component="timeline-detail-control"] {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
container: timeline-detail / inline-size;
[data-slot="timeline-detail-heading"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
font-weight: 530;
line-height: var(--line-height-compact);
}
[data-slot="timeline-detail-current"] {
color: var(--v2-text-text-muted);
text-align: end;
}
p {
margin: 0;
color: var(--v2-text-text-muted);
}
[data-slot="timeline-detail-scale"] {
position: relative;
height: 28px;
}
[data-slot="timeline-detail-track"] {
position: absolute;
inset-inline: 8px;
top: 12px;
height: 4px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 2px;
background: var(--v2-background-bg-layer-03);
pointer-events: none;
span {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--v2-border-border-strong);
}
}
input[type="range"] {
appearance: none;
position: relative;
display: block;
width: 100%;
height: 28px;
margin: 0;
background: transparent;
cursor: pointer;
border-radius: 4px;
&:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
&::-webkit-slider-runnable-track {
height: 4px;
background: transparent;
}
&::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
margin-top: -6px;
border: 1px solid var(--v2-border-border-base);
border-radius: 50%;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-button-neutral);
}
&::-moz-range-track {
height: 4px;
background: transparent;
}
&::-moz-range-thumb {
box-sizing: border-box;
width: 16px;
height: 16px;
border: 1px solid var(--v2-border-border-base);
border-radius: 50%;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-button-neutral);
}
}
[data-slot="timeline-detail-advanced"] {
margin-top: 4px;
padding-top: 8px;
border-top: 0.5px solid var(--v2-border-border-base);
}
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
width: fit-content;
height: 28px;
align-self: flex-start;
gap: 6px;
font-size: inherit;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: inherit;
color: var(--v2-text-text-muted);
[data-slot="collapsible-arrow"] {
width: 16px;
}
&:focus-visible [data-slot="collapsible-arrow"] {
opacity: 1;
}
&:dir(rtl):not([aria-expanded="true"]) [data-slot="collapsible-arrow-icon"] {
transform: rotate(90deg);
}
}
[data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 100px 108px;
display: flex;
flex-direction: column;
margin-top: 8px;
}
[data-slot="timeline-detail-explainer"] {
margin-bottom: 12px;
}
[data-slot="timeline-detail-field-label"] {
display: none;
}
[data-slot="timeline-detail-columns"],
[data-slot="timeline-detail-category"] {
display: grid;
grid-template-columns: var(--timeline-detail-columns);
align-items: center;
column-gap: 8px;
line-height: var(--line-height-compact);
> span {
min-width: 0;
overflow-wrap: normal;
}
}
[data-slot="timeline-detail-columns"] {
min-height: 28px;
color: var(--v2-text-text-muted);
span:not(:first-child) {
padding-inline-start: 8px;
}
}
[data-slot="timeline-detail-category"] {
min-height: 40px;
padding-block: 8px;
border-bottom: 0.5px solid var(--v2-border-border-base);
&:last-child {
border-bottom: 0;
}
[data-component="select-v2-root"],
[data-slot="timeline-detail-placement"],
[data-slot="timeline-detail-expansion"] {
min-width: 0;
}
[data-component="select-v2-root"][data-field] {
width: 100%;
}
[data-component="select-v2"][data-appearance="inline"] {
width: 100%;
}
}
}
@container timeline-detail (max-width: 250px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-heading"] {
flex-wrap: wrap;
gap: 4px 8px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-current"] {
margin-inline-start: auto;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"] {
display: none;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
grid-template-columns: minmax(0, 1fr);
row-gap: 8px;
padding-block: 12px;
> span {
font-weight: 530;
}
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-field-label"] {
display: block;
margin-bottom: 2px;
padding-inline-start: 8px;
font-size: 12px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-expansion"]:empty {
display: none;
}
}
@container timeline-detail (max-width: 310px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 90px 96px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"],
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
column-gap: 4px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
min-height: 48px;
}
}
@@ -0,0 +1,127 @@
import { For, Show, createMemo, createUniqueId } from "solid-js"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { Select } from "@opencode-ai/ui/select"
import {
timelineCategories,
timelinePreset,
timelinePresets,
type TimelineDetail,
type TimelineExpansion,
type TimelinePlacement,
} from "@opencode-ai/session-ui/timeline/detail"
import { useLanguage } from "@/runtime/i18n/language"
import "./timeline-detail.css"
const placements: TimelinePlacement[] = ["separate", "grouped", "hidden"]
const expansions: TimelineExpansion[] = ["collapsed", "expanded"]
export function TimelineDetailControl(props: { value: TimelineDetail; onChange: (value: TimelineDetail) => void }) {
const language = useLanguage()
const id = createUniqueId()
const preset = createMemo(() => timelinePreset(props.value))
const position = () => {
const current = preset()
return current ? timelinePresets.indexOf(current) : 2
}
const label = () => {
const current = preset()
return current ? language.t(`settings.timeline.preset.${current.id}`) : language.t("settings.timeline.custom")
}
return (
<div data-component="timeline-detail-control">
<div data-slot="timeline-detail-heading">
<label for={`${id}-slider`}>{language.t("settings.timeline.detail")}</label>
<span data-slot="timeline-detail-current" aria-live="polite">
{label()}
</span>
</div>
<p id={`${id}-description`} class="sr-only">
{language.t("settings.timeline.description")}
</p>
<div data-slot="timeline-detail-scale">
<div data-slot="timeline-detail-track" aria-hidden="true">
<For each={timelinePresets}>{() => <span />}</For>
</div>
<input
id={`${id}-slider`}
data-action="settings-timeline-detail"
type="range"
min="0"
max={timelinePresets.length - 1}
step="1"
value={position()}
aria-valuetext={label()}
aria-describedby={`${id}-description ${id}-preset-description`}
onInput={(event) => props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })}
/>
</div>
<p id={`${id}-preset-description`}>{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}</p>
<Collapsible variant="ghost" data-slot="timeline-detail-advanced">
<Collapsible.Trigger>
<span>{language.t("settings.timeline.advanced")}</span>
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
<div
data-slot="timeline-detail-categories"
role="group"
aria-label={language.t("settings.timeline.advanced.description")}
>
<p data-slot="timeline-detail-explainer">{language.t("settings.timeline.advanced.explainer")}</p>
<div data-slot="timeline-detail-columns">
<span>{language.t("settings.timeline.activity")}</span>
<span id={`${id}-placement`}>{language.t("settings.timeline.placement.title")}</span>
<span id={`${id}-expansion`}>{language.t("settings.timeline.expansion.title")}</span>
</div>
<For each={timelineCategories}>
{(category) => (
<div data-slot="timeline-detail-category" role="group" aria-labelledby={`${id}-${category}`}>
<span id={`${id}-${category}`}>{language.t(`settings.timeline.category.${category}`)}</span>
<div data-slot="timeline-detail-placement">
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.placement.title")}
</span>
<Select
data-category={category}
data-field="placement"
aria-labelledby={`${id}-${category} ${id}-placement`}
options={placements}
current={props.value[category].placement}
label={(value) => language.t(`settings.timeline.placement.${value}`)}
onSelect={(placement) =>
placement &&
props.onChange({ ...props.value, [category]: { ...props.value[category], placement } })
}
/>
</div>
<div data-slot="timeline-detail-expansion">
{category === "shell" || category === "edit" || category === "thinking" ? (
<Show when={props.value[category].placement !== "hidden"}>
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.expansion.title")}
</span>
<Select
data-category={category}
data-field="details"
aria-labelledby={`${id}-${category} ${id}-expansion`}
options={expansions}
current={props.value[category].details}
label={(value) => language.t(`settings.timeline.expansion.${value}`)}
onSelect={(details) =>
details &&
props.onChange({ ...props.value, [category]: { ...props.value[category], details } })
}
/>
</Show>
) : null}
</div>
</div>
)}
</For>
</div>
</Collapsible.Content>
</Collapsible>
</div>
)
}
+3
View File
@@ -13,6 +13,7 @@ import { requireServerKey } from "./session"
export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
@@ -20,6 +21,7 @@ const TargetSessionRouteContent = lazy(() =>
export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -29,6 +31,7 @@ export function AppRoutes() {
return (
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
+2 -13
View File
@@ -10,7 +10,6 @@ import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
export default function Layout(props: ParentProps) {
const platform = usePlatform()
@@ -86,24 +85,14 @@ export default function Layout(props: ParentProps) {
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
style={{
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
"padding-bottom": bottomTitlebar() || settings.store.open ? "0px" : "env(safe-area-inset-bottom, 0px)",
"padding-bottom": bottomTitlebar() || settings.active() ? "0px" : "env(safe-area-inset-bottom, 0px)",
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
<div
class="flex size-full min-h-0 min-w-0 flex-col"
hidden={settings.store.open}
inert={settings.store.open}
aria-hidden={settings.store.open}
>
<div class="flex size-full min-h-0 min-w-0 flex-col">
<Suspense>{props.children}</Suspense>
</div>
<Show when={settings.store.open}>
<Suspense>
<SettingsScreen defaultValue={settings.store.tab} />
</Suspense>
</Show>
</main>
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
+5 -1
View File
@@ -3,9 +3,13 @@ import { createRoot, createSignal } from "solid-js"
import { Schema } from "effect"
import { ServerConnection } from "@/runtime/server/registry"
import { Persistence } from "@/runtime/persistence/schema"
import { initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
test("settings has its own layout route", () => {
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
+2
View File
@@ -66,6 +66,7 @@ export type TabPanes = {
export type LayoutRoute =
| { type: "home" }
| { type: "settings" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
@@ -104,6 +105,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "settings") return { type: "settings" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
+36 -17
View File
@@ -8,56 +8,75 @@ function history(): TitlebarHistory {
describe("titlebar history", () => {
test("append and trim keeps max bounded", () => {
let state = history()
state = applyPath(state, "/", 3)
state = applyPath(state, "/a", 3)
state = applyPath(state, "/b", 3)
state = applyPath(state, "/c", 3)
state = applyPath(state, { url: "/" }, 3)
state = applyPath(state, { url: "/a" }, 3)
state = applyPath(state, { url: "/b" }, 3)
state = applyPath(state, { url: "/c" }, 3)
expect(state.stack).toEqual(["/a", "/b", "/c"])
expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(state.stack.length).toBe(3)
expect(state.index).toBe(2)
})
test("back and forward indexes stay correct after trimming", () => {
let state = history()
state = applyPath(state, "/", 3)
state = applyPath(state, "/a", 3)
state = applyPath(state, "/b", 3)
state = applyPath(state, "/c", 3)
state = applyPath(state, { url: "/" }, 3)
state = applyPath(state, { url: "/a" }, 3)
state = applyPath(state, { url: "/b" }, 3)
state = applyPath(state, { url: "/c" }, 3)
expect(state.stack).toEqual(["/a", "/b", "/c"])
expect(state.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(state.index).toBe(2)
const back = backPath(state)
expect(back?.to).toBe("/b")
expect(back?.to.url).toBe("/b")
expect(back?.state.index).toBe(1)
const afterBack = applyPath(back!.state, back!.to, 3)
expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
expect(afterBack.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(afterBack.index).toBe(1)
const forward = forwardPath(afterBack)
expect(forward?.to).toBe("/c")
expect(forward?.to.url).toBe("/c")
expect(forward?.state.index).toBe(2)
const afterForward = applyPath(forward!.state, forward!.to, 3)
expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
expect(afterForward.stack.map((entry) => entry.url)).toEqual(["/a", "/b", "/c"])
expect(afterForward.index).toBe(2)
})
test("action-driven navigation does not push duplicate history entries", () => {
const state: TitlebarHistory = {
stack: ["/", "/a", "/b"],
stack: [{ url: "/" }, { url: "/a" }, { url: "/b" }],
index: 2,
action: undefined,
}
const back = backPath(state)
expect(back?.to).toBe("/a")
expect(back?.to.url).toBe("/a")
const next = applyPath(back!.state, back!.to, 10)
expect(next.stack).toEqual(["/", "/a", "/b"])
expect(next.stack.map((entry) => entry.url)).toEqual(["/", "/a", "/b"])
expect(next.index).toBe(1)
expect(next.action).toBeUndefined()
})
test("settings visits retain their own route state", () => {
const first = { url: "/settings", state: { settings: { type: "draft", draftID: "a" } } }
const second = { url: "/settings", state: { settings: { type: "draft", draftID: "b" } } }
const state = applyPath(applyPath(applyPath(history(), first), { url: "/b" }), second)
const back = backPath(state)!
const previous = backPath(applyPath(back.state, back.to))!
expect(previous.to).toEqual(first)
expect(forwardPath(applyPath(back.state, back.to))?.to).toEqual(second)
})
test("replacing settings state does not add a back navigation", () => {
const initial = applyPath(history(), { url: "/settings", state: { tab: "general" } })
const updated = applyPath(initial, { url: "/settings", state: { tab: "models" } })
expect(updated.stack).toHaveLength(2)
const back = backPath(updated)!
expect(back.to.url).toBe("/")
expect(forwardPath(applyPath(back.state, back.to))?.to.state).toEqual({ tab: "models" })
})
})
+18 -8
View File
@@ -2,22 +2,32 @@ export const MAX_TITLEBAR_HISTORY = 100
export type TitlebarAction = "back" | "forward" | undefined
export type HistoryLocation = { url: string; state?: unknown }
export type TitlebarHistory = {
stack: string[]
stack: HistoryLocation[]
index: number
action: TitlebarAction
}
export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
export function applyPath(
state: TitlebarHistory,
current: HistoryLocation,
max = MAX_TITLEBAR_HISTORY,
): TitlebarHistory {
if (!state.stack.length) {
const stack = current === "/" ? ["/"] : ["/", current]
const stack = current.url === "/" ? [current] : [{ url: "/" }, current]
return { stack, index: stack.length - 1, action: undefined }
}
const active = state.stack[state.index]
if (current === active) {
if (!state.action) return state
return { ...state, action: undefined }
if (current.url === active.url) {
if (!state.action && current.state === active.state) return state
return {
...state,
stack: state.stack.map((entry, index) => (index === state.index ? current : entry)),
action: undefined,
}
}
if (state.action) return { ...state, action: undefined }
@@ -25,13 +35,13 @@ export function applyPath(state: TitlebarHistory, current: string, max = MAX_TIT
return pushPath(state, current, max)
}
export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
export function pushPath(state: TitlebarHistory, path: HistoryLocation, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
const stack = state.stack.slice(0, state.index + 1).concat(path)
const next = trimHistory(stack, stack.length - 1, max)
return { ...state, ...next, action: undefined }
}
export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
export function trimHistory(stack: HistoryLocation[], index: number, max = MAX_TITLEBAR_HISTORY) {
if (stack.length <= max) return { stack, index }
const cut = stack.length - max
return {
+18 -10
View File
@@ -395,11 +395,8 @@ export function DraftTabItem(props: {
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 whitespace-nowrap"
classList={{
invisible: props.hidden,
"w-full [container-type:inline-size]": props.orientation === "vertical",
}}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
@@ -432,16 +429,27 @@ export function DraftTabItem(props: {
if (props.suppressNavigation) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
classList={{ "flex-1": props.orientation === "vertical", "flex-none pe-10": props.orientation !== "vertical" }}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<Icon name="edit" />
<svg
class="text-v2-icon-icon-muted group-data-[active='true']:text-v2-icon-icon-base"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M9.00002 13.5H14M2.60419 10.9167V13.3958H5.08335L13.3959 5.08333L10.9167 2.60416L2.60419 10.9167Z"
stroke="currentColor"
/>
</svg>
</span>
<span
data-titlebar-tab-title
class="min-w-0 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
classList={{ "flex-1": props.orientation === "vertical", "flex-none": props.orientation !== "vertical" }}
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
>
{props.title}
</span>
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
data-orientation={props.orientation}
class="relative flex"
classList={{
"w-max min-w-7 max-w-56 shrink-0": props.orientation === "horizontal",
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
>
@@ -377,7 +377,7 @@ export function TitlebarTabStrip(props: {
index={visibleIndex()}
active={props.currentTab === tab}
orientation={vertical() ? "vertical" : "horizontal"}
title={language.t("command.session.new")}
title={language.t("session.tab.session")}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
+48 -19
View File
@@ -1,5 +1,5 @@
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, unwrap } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -13,7 +13,7 @@ import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { WindowsAppMenu } from "./windows-menu"
import { applyPath, backPath, forwardPath } from "./history"
import { applyPath, backPath, forwardPath, type HistoryLocation } from "./history"
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
@@ -75,7 +75,7 @@ export function Titlebar(props: {
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
const [history, setHistory] = createStore({
stack: [] as string[],
stack: [] as HistoryLocation[],
index: 0,
action: undefined as "back" | "forward" | undefined,
})
@@ -83,7 +83,7 @@ export function Titlebar(props: {
const path = () => `${location.pathname}${location.search}${location.hash}`
createEffect(() => {
const current = path()
const current = { url: path(), state: location.state }
untrack(() => {
const next = applyPath(history, current)
@@ -113,14 +113,14 @@ export function Titlebar(props: {
const next = backPath(history)
if (!next) return
setHistory(next.state)
navigate(next.to)
navigate(next.to.url, { state: unwrap(next.to.state) })
}
const forward = () => {
const next = forwardPath(history)
if (!next) return
setHistory(next.state)
navigate(next.to)
navigate(next.to.url, { state: unwrap(next.to.state) })
}
command.register(() => [
@@ -297,6 +297,7 @@ export function Titlebar(props: {
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
case "settings":
case "home": {
const selection = layout.home.selection()
const conn =
@@ -432,7 +433,7 @@ export function Titlebar(props: {
"md:pl-4": !macTrafficLights(),
}}
>
<Show when={!mobile() && !props.verticalTabs}>
<Show when={!mobile() && (!props.verticalTabs || windows())}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
@@ -624,10 +625,22 @@ export function Titlebar(props: {
>
<Show when={macVerticalTabs()}>
<div
class="relative w-full shrink-0"
class="relative mb-2 w-full shrink-0"
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
data-tauri-drag-region
></div>
>
<div
class="absolute -top-0.5 bottom-0.5 flex items-center"
style={{
// Native traffic lights stay on the physical left; subtract the sidebar padding.
left: macTrafficLights()
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
: "0px",
}}
>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
</Show>
{homeButton(true)}
<button
@@ -637,10 +650,10 @@ export function Titlebar(props: {
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="plus" />
<Icon name="edit" />
{language.t("command.session.new")}
</button>
<div class="my-1 h-px w-full shrink-0 bg-v2-border-border-muted" aria-hidden="true" />
<div class="h-4 w-full shrink-0" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
<TitlebarTabStrip
orientation="vertical"
@@ -657,13 +670,14 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div data-slot="vertical-tabs-footer" class="relative mt-auto h-9 w-full shrink-0">
<div class="absolute bottom-0 left-0 flex h-9 items-center">
<div
data-slot="vertical-tabs-footer"
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
>
<TitlebarRightMount />
<Show when={!macVerticalTabs() && !windows()}>
<ChannelIndicator debugTools={props.debugTools} />
</div>
<div class="absolute bottom-0 right-0 flex h-9 items-center">
<TitlebarRightMount />
</div>
</Show>
</div>
</Portal>
)}
@@ -740,12 +754,23 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
}
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
const platform = usePlatform()
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const classes = () => ({
"px-2 rounded-sm": windows(),
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
})
const style = () => ({
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
})
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
classList={classes()}
style={style()}
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
@@ -759,7 +784,11 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
return (
<Show when={label}>
{(value) => (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
<div
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
classList={classes()}
style={style()}
>
{value()}
</div>
)}
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import type { Path } from "@/runtime/server/types"
import type { LocationRef } from "@opencode-ai/client/promise"
import {
absoluteTreePath,
activeTreeNavigation,
@@ -26,16 +26,19 @@ import {
displayPickerPath,
pickerParent,
pickerRoot,
listPickerDirectory,
pickerRelativePath,
pickerAbsolutePath,
} from "./domain"
import "./dialog.css"
import { Divider } from "@opencode-ai/ui/divider"
import { getFilename } from "@opencode-ai/util/path"
interface DirectoryPickerDialogProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
location?: LocationRef
mode?: "directory" | "file"
start?: string
}
@@ -67,32 +70,24 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
let navigation = 0
const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
() => (props.location ? undefined : true),
() => sdk.api.location.get().catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const home = createMemo(() => sync.data.path.home || "")
const location = createMemo(() => {
const current = props.location ?? fallbackPath()
return current ? { directory: current.directory, workspace: current.workspaceID } : undefined
})
const start = createMemo(
() =>
props.start ||
sync.data.path.home ||
props.location?.directory ||
sync.data.path.directory ||
fallbackPath()?.home ||
fallbackPath()?.directory,
)
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
@@ -100,12 +95,14 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = pickerRoot(cleaned) || root() || start()
const base = location()?.directory
if (!base) return { query: value, items: directories.slice(0, 5) }
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
location: location(),
query,
type: "file",
limit: 20,
})
@@ -113,7 +110,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
.catch(() => [])
const results = [
...directories,
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
]
return {
query: value,
@@ -132,15 +129,9 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.api.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
.catch(() => undefined)
const current = location()
if (!current) return Promise.resolve(undefined)
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
})
listings.set(key, request)
const nodes = await request
@@ -282,7 +273,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
createEffect(() => {
const path = start()
if (!path || root()) return
if (!path || !location() || root()) return
void navigate(path)
})
@@ -16,6 +16,7 @@ import {
currentPickerSuggestions,
createDirectorySearch,
createPriorityTaskQueue,
listPickerDirectory,
displayPickerPath,
pickerParent,
pickerRoot,
@@ -130,44 +131,181 @@ test("scopes file autocomplete to the current browser root", () => {
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
})
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
test("resolves directory autocomplete from the browser root without changing location", async () => {
const calls: unknown[] = []
const location = { directory: "/repo", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
find: (input: unknown) => {
calls.push(input)
return Promise.resolve({ location, data: [{ path: "src/components/", type: "directory" }] })
},
list: () => Promise.resolve({ data: [] }),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
let base = "/repo"
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base, location: () => location })
await search("components")
expect(await search("components")).toEqual(["/repo/src/components"])
base = "/repo/src"
await search("components")
expect(await search("components")).toEqual(["/repo/src/components"])
expect(directories).toEqual(["/repo", "/repo/src"])
expect(calls).toEqual([
{ location, query: "components", type: "directory", limit: 50 },
{ location, query: "src/components", type: "directory", limit: 50 },
])
})
test("keeps indexed directory results for servers that support empty search", async () => {
test("lists absolute parents and preloads siblings through a stable workspace", async () => {
const calls: unknown[] = []
const location = { directory: "/repo/current", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: async (input: { path?: string }) => {
calls.push(input)
return {
location,
data:
input.path === "/repo"
? [
{ path: "./", type: "directory" },
{ path: "../sibling/", type: "directory" },
]
: [{ path: "../sibling/src/", type: "directory" }],
}
},
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
{ name: "current", absolute: "/repo/current", type: "directory" },
{ name: "sibling", absolute: "/repo/sibling", type: "directory" },
])
expect(await listPickerDirectory(sdk, location, "/repo/sibling")).toEqual([
{ name: "src", absolute: "/repo/sibling/src", type: "directory" },
])
expect(calls).toEqual([
{ location, path: "/repo" },
{ location, path: "/repo/sibling" },
])
})
test("uses listings for typed searches outside the current location", async () => {
const calls: unknown[] = []
const location = { directory: "/repo/current", workspace: "workspace_1" }
const sdk = {
api: {
file: {
find: () => Promise.reject(new Error("outside searches must not change location")),
list: async (input: unknown) => {
calls.push(input)
return { location, data: [{ path: "../sibling/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
expect(await search("sib")).toEqual(["/repo/sibling"])
expect(calls).toEqual([{ location, path: "/repo" }])
})
test("keeps literal tilde directory names in server listing and search results", async () => {
const location = { directory: "/repo" }
const sdk = {
api: {
file: {
list: async () => ({ location, data: [{ path: "~/", type: "directory" }] }),
find: async () => ({ location, data: [{ path: "~/nested/", type: "directory" }] }),
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
{ name: "~", absolute: "/repo/~", type: "directory" },
])
const search = createDirectorySearch({ sdk, home: () => "/home/user", base: () => "/repo", location: () => location })
expect(await search("nested")).toEqual(["/repo/~/nested"])
})
test("discards stale typed results without changing the request location", async () => {
const location = { directory: "/repo" }
const pending = Promise.withResolvers<{
location: typeof location
data: Array<{ path: string; type: "directory" }>
}>()
const calls: unknown[] = []
const sdk = {
api: {
file: {
find: async (input: { query: string }) => {
calls.push(input)
if (input.query === "old") return pending.promise
return { location, data: [{ path: "new/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
const stale = search("old")
expect(await search("new")).toEqual(["/repo/new"])
pending.resolve({ location, data: [{ path: "old/", type: "directory" }] })
expect(await stale).toEqual([])
expect(calls).toEqual([
{ location, query: "old", type: "directory", limit: 50 },
{ location, query: "new", type: "directory", limit: 50 },
])
})
test("maps server-native drive and share paths without rebasing the location", async () => {
const calls: unknown[] = []
const sdk = {
api: {
file: {
list: async (input: { location: { directory: string }; path: string }) => {
calls.push(input)
return { location: input.location, data: [{ path: "../sibling/", type: "directory" }] }
},
},
},
} as unknown as Parameters<typeof listPickerDirectory>[0]
const drive = { directory: "C:\\Repo\\Current", workspace: "workspace_1" }
expect(await listPickerDirectory(sdk, drive, "c:/repo")).toEqual([
{ name: "sibling", type: "directory", absolute: "C:/Repo/sibling" },
])
const share = { directory: "\\\\Server\\Share\\Current", workspace: "workspace_2" }
expect(await listPickerDirectory(sdk, share, "//server/share")).toEqual([
{ name: "sibling", type: "directory", absolute: "//Server/Share/sibling" },
])
expect(calls).toEqual([
{ location: drive, path: "c:/repo" },
{ location: share, path: "//server/share" },
])
})
test("keeps indexed directory results for servers that support empty search", async () => {
const location = { directory: "/home/luke" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ location, data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const location = { directory: "/home/luke" }
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
@@ -180,13 +318,19 @@ test("lists the default directory when empty search is unsupported", async () =>
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
location,
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
const results = await search("")
expect(results).toHaveLength(60)
@@ -195,12 +339,14 @@ test("lists the default directory when empty search is unsupported", async () =>
})
test("matches the default directory listing when typed search is unsupported", async () => {
const location = { directory: "/home/luke" }
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
location,
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
@@ -209,12 +355,18 @@ test("matches the default directory listing when typed search is unsupported", a
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const search = createDirectorySearch({
sdk,
home: () => "/home/luke",
base: () => "/home/luke",
location: () => location,
})
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => {
const location = { directory: "/" }
const directories: string[] = []
const sdk = {
api: {
@@ -222,6 +374,7 @@ test("searches from an absolute root without a default base", async () => {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({
location,
data: [
{ path: "Users/", type: "directory" },
{ path: "tmp/", type: "directory" },
@@ -231,7 +384,7 @@ test("searches from an absolute root without a default base", async () => {
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined, location: () => location })
expect(await search("/")).toEqual(["/Users", "/tmp"])
expect(directories).toEqual(["/"])
+43 -19
View File
@@ -76,6 +76,11 @@ export function pickerFileSearchQuery(root: string, input: string, home: string)
export function pickerAbsoluteInput(input: string, home: string, current: string) {
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
return pickerAbsolutePath(value, current)
}
export function pickerAbsolutePath(input: string, current: string) {
const value = normalizePickerDrive(input)
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
return canonicalPickerPath(absolute)
}
@@ -321,7 +326,24 @@ export function displayPickerPath(path: string, input: string, home: string) {
return pickerTilde(value, home) || value
}
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
export async function listPickerDirectory(
sdk: ServerSDK,
location: { directory: string; workspace?: string },
directory: string,
) {
const result = await sdk.api.file.list({ location, path: directory })
return result.data.map((entry) => {
const absolute = pickerAbsolutePath(entry.path, result.location.directory)
return { name: getFilename(absolute), type: entry.type, absolute }
})
}
export function createDirectorySearch(args: {
sdk: ServerSDK
location: () => { directory: string; workspace?: string } | undefined
base: () => string | undefined
home: () => string
}) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
@@ -339,21 +361,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
}
const directories = async (directory: string) => {
const key = trimPickerPath(directory)
const location = args.location()
if (!location) return []
const key = JSON.stringify([location, trimPickerPath(directory)])
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.api.file
.list({ location: { directory: key } })
.then((result) => result.data)
const request = listPickerDirectory(args.sdk, location, directory)
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => {
const relative = trimPickerPath(normalizePickerDrive(node.path))
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
}),
)
.then((nodes) => nodes.filter((node) => node.type === "directory"))
cache.set(key, request)
return request
}
@@ -369,18 +384,27 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const active = () => token === current
const value = cleanPickerInput(filter)
const input = scoped(value)
if (!input) return [] as string[]
const location = args.location()
if (!input || !location) return [] as string[]
const raw = normalizePickerDrive(value)
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
const relative = pickerRelativePath(location.directory, input.directory)
const results =
relative === undefined
? []
: await args.sdk.api.file
.find({ location, query: joinPickerPath(relative, query), type: "directory", limit: 50 })
.then((result) =>
result.data
.map((entry) => pickerAbsolutePath(entry.path, result.location.directory))
.filter((path) => treePathWithin(input.directory, path)),
)
.catch(() => [])
if (!active()) return []
if (results.length) {
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
return results.slice(0, 50)
}
const fallback = query
? await match(input.directory, query, 50)
@@ -16,6 +16,7 @@ describe("directoryPickerKind", () => {
test("uses the native picker only for local desktop projects", () => {
expect(directoryPickerKind("desktop", local)).toBe("native")
expect(directoryPickerKind("desktop", remote)).toBe("server")
expect(directoryPickerKind("desktop", { ...local, variant: "wsl", distro: "Ubuntu" })).toBe("server")
expect(directoryPickerKind("web", local)).toBe("server")
})
})
@@ -2,6 +2,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import { lazy } from "solid-js"
import type { LocationRef } from "@opencode-ai/client/promise"
import { directoryPickerKind } from "./policy"
const DirectoryPickerDialog = lazy(() =>
@@ -10,6 +11,7 @@ const DirectoryPickerDialog = lazy(() =>
type DirectoryPickerInput = {
server: ServerConnection.Any
location?: LocationRef
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
+10 -1
View File
@@ -128,6 +128,9 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
external: ["node-gyp"],
format: "esm",
minify: true,
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
bytecode: false,
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
@@ -138,13 +141,19 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary),
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--no-warnings", "--"],
execArgv: [
`--user-agent=opencode/${Script.channel}/${Script.version}/cli`,
"--use-system-ca",
"--no-warnings",
"--",
],
windows: {},
},
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_ARTIFACT: `'cli'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
+16 -8
View File
@@ -23,7 +23,13 @@ async function publish(dir: string, name: string, version: string) {
}
}
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
async function publishDistribution(input: {
root: string
name: string
binary: string
packagePrefix: string
artifact: string
}) {
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
const item = await Bun.file(`${input.root}/${filepath}`).json()
@@ -72,6 +78,13 @@ async function publishDistribution(input: { root: string; name: string; binary:
),
)
await publish(`${input.root}/${input.name}`, input.name, version)
await UpdateArtifact.publish({
channel: Script.channel,
name: input.artifact,
distribution: "npm",
version,
metadata: { package: input.name },
})
}
await publishDistribution({
@@ -79,6 +92,7 @@ await publishDistribution({
name: pkg.name,
binary: "opencode2",
packagePrefix: "@opencode-ai/cli-",
artifact: "cli",
})
if (existsSync("./dist/node")) {
await publishDistribution({
@@ -86,12 +100,6 @@ if (existsSync("./dist/node")) {
name: "opencode-node",
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
artifact: "cli-node",
})
}
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
distribution: "npm",
version: Script.version,
metadata: {},
})
+5 -1
View File
@@ -93,7 +93,11 @@ try {
}
const output = await Promise.all(errors)
await fs.rm(root, { recursive: true, force: true })
// Windows can retain directory handles briefly after the service processes exit.
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch((cause: unknown) => {
console.error("Failed to remove service smoke-test directory", cause)
failure ??= cause
})
if (failure)
throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
cause: failure,
+2 -1
View File
@@ -400,7 +400,8 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: Ski
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const location = { directory: cwd }
// Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog.
await client.plugin.awaitActivation({ location })
// Some providers discover models in the background after activation has settled.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
@@ -9,7 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
export default Runtime.handler(Commands, (input) =>
@@ -47,7 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -59,7 +59,7 @@ export default Runtime.handler(Commands, (input) =>
const service = server.service
yield* run({
app: {
name: process.env.OPENCODE_CLIENT ?? "cli",
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
version: OPENCODE_VERSION,
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
},
@@ -83,13 +83,13 @@ export default Runtime.handler(Commands, (input) =>
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
updater: service
? {
apply: (version) => runPromise(updater.apply(version)),
}
: undefined,
packages: {
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
@@ -5,6 +5,7 @@ import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { resolveConfigPath } from "../mcp/add"
@@ -16,9 +17,9 @@ export default Runtime.handler(
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
const installed = yield* npm.add(input.package)
const entrypoints = Host.resolve(installed)
const target = configurationTarget(entrypoints.server, entrypoints.tui)
if (!target)
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const installed = yield* npm.resolve(target)
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
@@ -9,8 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { fileURLToPath } from "node:url"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -22,27 +23,27 @@ export default Runtime.handler(
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (entry) =>
Effect.gen(function* () {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
return []
const local = localSource(target, path.dirname(config.path))
if (local) return [{ target: fileURLToPath(local), version: "local" }]
if (local) {
const directory = fileURLToPath(local)
const entrypoints = Host.resolve({ directory })
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
}
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
const installed = yield* npm.resolve(target)
if (!Host.resolve(installed).tui) return []
return [{ target, version: installed.version }]
}),
)
const output = format(
response.data,
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
input.builtin,
)
const output = format(response.data, configured.flat(), input.builtin)
if (!output) {
process.stdout.write("No plugins found" + EOL)
return
@@ -81,9 +82,7 @@ export function format(
? plugin.source.target === item.target
: plugin.source.type === "local" &&
(plugin.source.path === item.target ||
(plugin.features.tui &&
path.dirname(plugin.source.path) ===
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
),
)
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)

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